6. DuckDB desde Java
Secciones
Ajustes de lectura
Voz natural grabada para este curso, la misma en todos los navegadores.
Ninguna voz para este idioma en este navegador.
Más voces
Código y términos técnicos en inglés
Desactivado: la voz de la página lo lee todo, sin pausa entre dos voces.
Ninguna voz para este idioma en este navegador.
Más voces
Más voces: Microsoft Edge ofrece voces naturales en línea, y Windows (Configuración › Hora e idioma › Voz), macOS y Android instalan otras.
El mismo programa que en la lección 5, en Java, con el driver JDBC de DuckDB: java/src/main/java/ci/CiQueries.java, comparado con java/expected.txt en los tres sistemas operativos. Lo interesante son las diferencias con C#.
El proyecto
Sección titulada «El proyecto»De java/pom.xml, líneas 20-26:
<dependencies> <dependency> <groupId>org.duckdb</groupId> <artifactId>duckdb_jdbc</artifactId> <version>1.5.5.1</version> </dependency> </dependencies>Un solo artefacto, org.duckdb:duckdb_jdbc, versionado según DuckDB con un cuarto número para las versiones del driver: 1.5.5.1 incluye DuckDB 1.5.5. El jar ocupa 85 MB, porque contiene la biblioteca nativa de cuatro plataformas:
60780968 2026-08-03 22:27 libduckdb_java.so_linux_amd64108682352 2026-08-03 22:28 libduckdb_java.so_osx_universal 35090432 2026-08-03 22:28 libduckdb_java.so_windows_amd64 53584160 2026-08-03 22:28 libduckdb_java.so_linux_arm64Donde DuckDB.NET reparte las plataformas en carpetas runtime de NuGet, el driver JDBC distribuye un solo jar para todas y elige el archivo correcto en tiempo de ejecución. No hay ninguna build para Windows on Arm en esta lista.
El programa se ejecuta con el Exec Maven Plugin, desde code/duckdb para que las rutas data/… se resuelvan, como en C#:
mvn -q -f java/pom.xml compile exec:java -Dexec.args=checkUna consulta con un conjunto de resultados
Sección titulada «Una consulta con un conjunto de resultados»De java/src/main/java/ci/CiQueries.java, línea 21:
// Una base de datos en memoria, como la CLI iniciada sin nombre de archivo try (Connection connection = DriverManager.getConnection("jdbc:duckdb:")) {De java/src/main/java/ci/CiQueries.java, líneas 33-44:
try (var statement = connection.createStatement(); var rs = statement.executeQuery(""" SELECT workflowName, count(*) AS runs, count(*) FILTER (conclusion = 'failure') AS failures FROM 'data/runs.json' GROUP BY ALL ORDER BY runs DESC, workflowName LIMIT 3 """)) { while (rs.next()) { System.out.printf("%-24s %3d runs %2d failures%n", rs.getString(1), rs.getLong("runs"), rs.getLong("failures")); } }DuckDB v1.5.5
== 1. A query with a result setDeploy to GitHub Pages 65 runs 2 failuresRust course examples 18 runs 3 failuresJava course examples 7 runs 0 failuresjdbc:duckdb: sin nada después de los dos puntos es una base de datos en memoria; jdbc:duckdb:ci.duckdb abre un archivo. La salida es la misma que la del programa en C#, carácter por carácter, salvo el título de la sección.
Parámetros
Sección titulada «Parámetros»De java/src/main/java/ci/CiQueries.java, líneas 47-56:
try (var ps = connection.prepareStatement( "SELECT count(*) FROM read_json(?) WHERE workflowName = ? AND conclusion = ?")) { ps.setString(1, "data/runs.json"); ps.setString(2, "Rust course examples"); ps.setString(3, "failure"); try (var rs = ps.executeQuery()) { rs.next(); System.out.println("Rust course failures: " + rs.getLong(1)); } }== 2. ParametersRust course failures: 3Los marcadores ? de JDBC, numerados desde 1. Aquí la ruta del archivo también es un parámetro.
Tipos de DuckDB como tipos de Java
Sección titulada «Tipos de DuckDB como tipos de Java»La misma consulta que en C#:
== 3. DuckDB types as Java typesworkflowName VARCHAR java.lang.StringdatabaseId BIGINT java.lang.LongcreatedAt TIMESTAMP java.sql.Timestampday DATE java.time.LocalDatetook INTERVAL java.lang.Stringcreated_utc TIMESTAMP WITH TIME ZONE java.time.OffsetDateTimetotal HUGEINT java.math.BigIntegerpair VARCHAR[] org.duckdb.DuckDBArrayinfo STRUCT("event" VARCHAR, attempt BIGINT) org.duckdb.DuckDBStructtook: 00:00:39created_utc as an instant: 2026-09-13T15:53:16Zcreated_utc in UTC: 2026-09-13T15:53:16ZLado a lado con la lección 5:
| DuckDB | C# (DuckDB.NET) | Java (JDBC) |
|---|---|---|
TIMESTAMP |
DateTime |
java.sql.Timestamp, o LocalDateTime con getObject(i, LocalDateTime.class) |
DATE |
DateOnly |
LocalDate |
INTERVAL |
TimeSpan |
String |
TIMESTAMPTZ |
DateTime, valor UTC, Kind Unspecified |
OffsetDateTime en la zona horaria de la JVM |
HUGEINT |
BigInteger |
BigInteger |
LIST |
List<T> |
java.sql.Array |
STRUCT |
Dictionary<string, object> o una clase |
java.sql.Struct (DuckDBStruct) |
INTERVALllega como el texto00:00:39. Para calcular con él, conviértelo en SQL (epoch(took)da los segundos comoDOUBLE) en lugar de analizar la cadena.TIMESTAMP WITH TIME ZONEse convierte en unOffsetDateTimeexpresado en la zona por defecto de la JVM. El mismo instante impreso en esta máquina congetObject(i).toString():2026-09-13T11:53:16-04:00; con-Duser.timezone=UTC:2026-09-13T15:53:16Z. Ni elSET TimeZonede DuckDB ni una propiedad de conexiónTimeZonecambiaron nada en el valor Java. Por eso el programa imprimetoInstant()ywithOffsetSameInstant(ZoneOffset.UTC), que no dependen de dónde se ejecute.
Arrays y structs
Sección titulada «Arrays y structs»De java/src/main/java/ci/CiQueries.java, líneas 80-91:
try (var statement = connection.createStatement(); var rs = statement.executeQuery("SELECT labels, steps FROM 'data/jobs.json' WHERE id = 104004920113")) { rs.next(); Array labels = rs.getArray("labels"); System.out.println("labels: " + Arrays.toString((Object[]) labels.getArray())); Object[] steps = (Object[]) rs.getArray("steps").getArray(); for (int i = 0; i < 3; i++) { Map<String, Object> step = ((DuckDBStruct) steps[i]).getMap(); System.out.println("step " + step.get("number") + ": " + step.get("name") + ", " + step.get("conclusion") + ", started_at is a " + step.get("started_at").getClass().getName()); } }== 4. Lists and structslabels: [ubuntu-latest]step 1: Set up job, success, started_at is a java.sql.Timestampstep 2: Checkout, success, started_at is a java.sql.Timestampstep 3: Install, build, and upload site, success, started_at is a java.sql.TimestampEn este terreno, JDBC es anterior a los genéricos: Array.getArray() devuelve un Object que hay que castear, y un struct es un java.sql.Struct cuyo método estándar getAttributes() solo da los valores por posición. El DuckDBStruct.getMap() del driver añade los nombres, a costa de un cast a una clase del driver. No hay mapeo a un record ni a una clase: esa es la tarea de una biblioteca como JdbcTemplate de Spring o jOOQ, no probadas en este curso.
Errores: un statement que se cierra solo
Sección titulada «Errores: un statement que se cierra solo»De java/src/main/java/ci/CiQueries.java, líneas 94-109:
try (var statement = connection.createStatement()) { try { statement.executeQuery("SELECT workflow_name FROM 'data/runs.json'"); } catch (SQLException e) { String[] lines = e.getMessage().split("\n"); System.out.println(e.getClass().getName() + ", SQLState = " + e.getSQLState()); System.out.println(lines[0]); System.out.println(lines[1]); } System.out.println("statement closed after the error: " + statement.isClosed()); try { statement.executeQuery("SELECT 42"); } catch (SQLException e) { System.out.println("next query on the same statement: " + e.getMessage()); } }== 5. Errorsjava.sql.SQLException, SQLState = nullInvalid Input Error: Attempting to execute an unsuccessful or closed pending query resultError: Binder Error: Referenced column "workflow_name" not found in FROM clause!statement closed after the error: truenext query on the same statement: Statement was closedTres diferencias con C#:
- El mensaje empieza con una línea genérica,
Attempting to execute an unsuccessful or closed pending query result, y el error real llega en la segunda línea, después deError:. Un log que solo conserva la primera línea del mensaje no conserva nada útil. SQLStateesnull: el código que comprueba estados SQL, como un reintento ante40001, no obtiene nada de este driver.- El statement queda cerrado después del error. En C#, el mismo
DuckDBCommandejecutaba la consulta siguiente. En Java, un bucle que reutiliza un únicoStatementy captura los errores para continuar falla en todas las consultas posteriores al primer error, conStatement was closed. Crea un statement por consulta, algo quetry-with-resources vuelve natural.
Acceso nativo en JDK 25
Sección titulada «Acceso nativo en JDK 25»A través de Maven, el programa no imprime nada más que su salida. Ejecutado directamente con java, empieza con cuatro advertencias:
java -cp "java/target/classes;$env:USERPROFILE\.m2\repository\org\duckdb\duckdb_jdbc\1.5.5.1\duckdb_jdbc-1.5.5.1.jar" ci.CiQueries checkjava -cp "java/target/classes:$HOME/.m2/repository/org/duckdb/duckdb_jdbc/1.5.5.1/duckdb_jdbc-1.5.5.1.jar" ci.CiQueries checkPor verificar: solo se ejecutó en Windows; el separador del classpath es : fuera de Windows.
java -cp "java/target/classes:$HOME/.m2/repository/org/duckdb/duckdb_jdbc/1.5.5.1/duckdb_jdbc-1.5.5.1.jar" ci.CiQueries checkPor verificar: solo se ejecutó en Windows; el separador del classpath es : fuera de Windows.
WARNING: A restricted method in java.lang.System has been calledWARNING: java.lang.System::load has been called by org.duckdb.DuckDBNative in an unnamed module (file:/C:/Users/spare/.m2/repository/org/duckdb/duckdb_jdbc/1.5.5.1/duckdb_jdbc-1.5.5.1.jar)WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this moduleWARNING: Restricted methods will be blocked in a future release unless native access is enabledCargar una biblioteca nativa es un «método restringido» desde la JEP 472: una advertencia hoy, un error en un JDK futuro. java --enable-native-access=ALL-UNNAMED -cp … elimina las advertencias. Maven no las mostraba porque su script de arranque ya pasa esa opción a la JVM en la que se ejecuta (bin/mvn, Maven 3.9.16), y exec:java ejecuta el programa dentro de esa JVM. Una aplicación empaquetada debe añadir la opción a su propio comando de arranque.
Carga masiva
Sección titulada «Carga masiva»El modo timings carga un millón de filas con el appender del driver, y luego 10.000 filas con un batch y con sentencias INSERT individuales, dentro de una transacción (líneas 120-127):
try (var appender = connection.unwrap(DuckDBConnection.class).createAppender(DuckDBConnection.DEFAULT_SCHEMA, "numbers")) { for (int i = 0; i < appended; i++) { appender.beginRow(); appender.append(i); appender.append("n" + i); appender.endRow(); } }unwrap es la forma JDBC de llegar a la clase propia de un driver desde una Connection. Los tiempos de la CI, con los de C# de la lección 5 para comparar:
| Runner | Appender Java, 1.000.000 filas | Batch Java, 10.000 filas | Java una a una, 10.000 filas | INSERT C#, 10.000 filas |
|---|---|---|---|---|
ubuntu-latest |
257 ms | 858 ms | 832 ms | 3.165 ms |
windows-latest |
511 ms | 2.443 ms | 2.378 ms | 3.917 ms |
macos-latest |
212 ms | 690 ms | 675 ms | 1.312 ms |
- El appender carga un millón de filas en más o menos el tiempo que tarda C#, salvo en Windows.
addBatchyexecuteBatchno aportan nada: el batch no fue más rápido que las sentencias individuales en ningún runner, ni en esta máquina (609 ms frente a 532 ms). Con este driver, un batch JDBC no envía las filas en una sola llamada. Usa el appender, oINSERT … SELECTdesde un archivo.- Las sentencias
INSERTindividuales son de 1,6 a 3,8 veces más rápidas en Java que en C#. No investigado: ambos preparan la sentencia una sola vez; la diferencia puede estar en cómo cada wrapper enlaza los parámetros.
Puntos clave
Sección titulada «Puntos clave»- Un solo jar,
org.duckdb:duckdb_jdbc, contiene el driver y las bibliotecas nativas de cuatro plataformas; la URL JDBCjdbc:duckdb:abre una base de datos en memoria. INTERVALes unString, yTIMESTAMPTZunOffsetDateTimeen la zona de la JVM: convierte aInstantantes de imprimir o comparar.- Los arrays y los structs pasan por
java.sql.ArrayyDuckDBStruct.getMap(), con casts. - Después de un error, el statement queda cerrado: un statement por consulta.
- JDK 25 advierte sobre el acceso nativo salvo que la JVM se inicie con
--enable-native-access=ALL-UNNAMED. - Los batches JDBC no aceleran las inserciones con este driver; el appender sí.
Ejercicios
Sección titulada «Ejercicios»- Un servicio ejecuta
SELECT createdAt AT TIME ZONE 'UTC' FROM runsy devuelvers.getObject(1).toString()en una respuesta JSON. Da2026-09-13T15:53:16Zen producción y2026-09-13T11:53:16-04:00en el portátil de un desarrollador. ¿Alguno de los dos está mal? ¿Cómo haces que coincidan?
Solución
Ninguno está mal: los dos son el mismo instante, con desfases distintos. El valor depende de la zona horaria por defecto de la JVM (user.timezone, o la configuración del sistema operativo), como midió la lección. Para que la salida no dependa de la máquina, elige la representación en el código: rs.getObject(1, OffsetDateTime.class).toInstant() imprime 2026-09-13T15:53:16Z en todas partes, y lo mismo withOffsetSameInstant(ZoneOffset.UTC). Iniciar la JVM con -Duser.timezone=UTC también funciona, pero cambia todas las fechas de la aplicación, y es fácil olvidarlo en alguna máquina.
- Este bucle inserta filas y se salta las inválidas. Con el driver de DuckDB, ¿qué pasa después de la primera fila inválida?
try (var statement = connection.createStatement()) { for (String sql : statements) { try { statement.execute(sql); } catch (SQLException e) { System.err.println("skipped: " + e.getMessage()); } }}Solución
Todas las sentencias siguientes fallan con Statement was closed, y el bucle «se salta» todas las filas restantes, válidas o no: la sección 5 del programa muestra el statement cerrado después de una consulta fallida. Crea el statement dentro del bucle:
for (String sql : statements) { try (var statement = connection.createStatement()) { statement.execute(sql); } catch (SQLException e) { System.err.println("skipped: " + e.getMessage()); }}Comprobado en un programa de prueba desechable con tres sentencias INSERT, la segunda de las cuales inserta 'x' en una columna INTEGER:
skipped: Invalid Input Error: Attempting to execute an unsuccessful or closed pending query resultskipped: Statement was closedrows: 1La tercera fila, válida, nunca se insertó.
- El programa en C# formatea las fechas con
CultureInfo.InvariantCulture. ¿Tiene el programa Java el mismo problema conprintf("%,d")en su modotimings, y le importa a la CI?
Solución
Sí: %,d usa el separador de grupos de la configuración regional por defecto. La CI imprimió 1,000,000; en un programa de prueba desechable, String.format(Locale.FRANCE, "%,d", 1_000_000) separaba los grupos con U+202F, un espacio estrecho de no separación, que parece un espacio y no lo es para un parser. A la CI no le importa, porque check.sh solo compara la salida del modo check, y los tiempos no se comparan. Para una salida que se compara o se analiza, usa String.format(Locale.ROOT, …), que dio 1,000,000.