6. DuckDB from Java
Sections
Reading settings
Natural voice recorded for this course, the same in every browser.
No voice for this language in this browser.
More voices
Code and English terms
Off: the page’s voice reads everything, with no pause between two voices.
No voice for this language in this browser.
More voices
More voices: Microsoft Edge has natural online voices, and Windows (Settings › Time & language › Speech), macOS and Android install others.
The same program as lesson 5, in Java, with the DuckDB JDBC driver: java/src/main/java/ci/CiQueries.java, compared with java/expected.txt on the three operating systems. The differences with C# are the interesting part.
The project
Section titled “The project”From java/pom.xml, lines 20-26:
<dependencies> <dependency> <groupId>org.duckdb</groupId> <artifactId>duckdb_jdbc</artifactId> <version>1.5.5.1</version> </dependency> </dependencies>One artifact, org.duckdb:duckdb_jdbc, versioned after DuckDB with a fourth number for driver releases: 1.5.5.1 embeds DuckDB 1.5.5. The jar is 85 MB, because it contains the native library for four platforms:
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_arm64Where DuckDB.NET splits platforms into NuGet runtime folders, the JDBC driver ships one jar for all, and picks the right file at run time. There’s no Windows on Arm build in this list.
The program runs with the Exec Maven Plugin, from code/duckdb so that data/… paths resolve, like in C#:
mvn -q -f java/pom.xml compile exec:java -Dexec.args=checkA query with a result set
Section titled “A query with a result set”From java/src/main/java/ci/CiQueries.java, line 21:
// An in-memory database, like the CLI started without a file name try (Connection connection = DriverManager.getConnection("jdbc:duckdb:")) {From java/src/main/java/ci/CiQueries.java, lines 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: with nothing after the colon is an in-memory database; jdbc:duckdb:ci.duckdb opens a file. The output is the same as the C# program’s, character for character, apart from the section title.
Parameters
Section titled “Parameters”From java/src/main/java/ci/CiQueries.java, lines 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: 3JDBC’s ? placeholders, numbered from 1. The file path is a parameter here too.
DuckDB types as Java types
Section titled “DuckDB types as Java types”The same query as in 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:16ZSide by side with lesson 5:
| DuckDB | C# (DuckDB.NET) | Java (JDBC) |
|---|---|---|
TIMESTAMP |
DateTime |
java.sql.Timestamp, or LocalDateTime with getObject(i, LocalDateTime.class) |
DATE |
DateOnly |
LocalDate |
INTERVAL |
TimeSpan |
String |
TIMESTAMPTZ |
DateTime, UTC value, Kind Unspecified |
OffsetDateTime in the JVM’s time zone |
HUGEINT |
BigInteger |
BigInteger |
LIST |
List<T> |
java.sql.Array |
STRUCT |
Dictionary<string, object> or a class |
java.sql.Struct (DuckDBStruct) |
INTERVALarrives as the text00:00:39. To compute with it, convert in SQL (epoch(took)gives seconds as aDOUBLE) rather than parsing the string.TIMESTAMP WITH TIME ZONEbecomes anOffsetDateTimeexpressed in the JVM’s default zone. The same instant printed on this machine withgetObject(i).toString():2026-09-13T11:53:16-04:00; with-Duser.timezone=UTC:2026-09-13T15:53:16Z. DuckDB’s ownSET TimeZoneor aTimeZoneconnection property changed nothing in the Java value. That’s why the program printstoInstant()andwithOffsetSameInstant(ZoneOffset.UTC), which don’t depend on where it runs.
Arrays and structs
Section titled “Arrays and structs”From java/src/main/java/ci/CiQueries.java, lines 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.TimestampJDBC predates generics in this area: Array.getArray() returns an Object to cast, and a struct is a java.sql.Struct whose standard getAttributes() gives the values by position only. The driver’s DuckDBStruct.getMap() adds the names, at the price of a cast to a driver class. There’s no mapping to a record or a class: that’s the job of a library like Spring’s JdbcTemplate or jOOQ, not tried in this course.
Errors: a statement that closes itself
Section titled “Errors: a statement that closes itself”From java/src/main/java/ci/CiQueries.java, lines 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 closedThree differences with C#:
- The message starts with a generic line,
Attempting to execute an unsuccessful or closed pending query result, and the actual error comes on the second line, afterError:. A log that keeps only the first line of the message keeps nothing useful. SQLStateisnull: code that checks SQL states, such as a retry on40001, gets nothing from this driver.- The statement is closed after the error. In C#, the same
DuckDBCommandran the next query. In Java, a loop that reuses oneStatementand catches errors to continue fails on every query after the first error, withStatement was closed. Create a statement per query, whichtry-with-resources makes natural.
Native access on JDK 25
Section titled “Native access on JDK 25”Through Maven, the program prints nothing but its output. Run directly with java, it starts with four warnings:
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 checkTo verify: run on Windows only; the classpath separator is : outside 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 checkTo verify: run on Windows only; the classpath separator is : outside 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 enabledLoading a native library is a “restricted method” since JEP 472: a warning today, an error in a future JDK. java --enable-native-access=ALL-UNNAMED -cp … removes the warnings. Maven didn’t show them because its launch script already passes that option to the JVM it runs in (bin/mvn, Maven 3.9.16), and exec:java runs the program inside that JVM. A packaged application must add the option to its own launch command.
Bulk loading
Section titled “Bulk loading”The timings mode loads a million rows with the driver’s appender, then 10,000 rows with a batch and with single INSERT statements, in a transaction (lines 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 is the JDBC way to reach a driver’s own class from a Connection. The CI timings, with the C# ones of lesson 5 for comparison:
| Runner | Java appender, 1,000,000 rows | Java batch, 10,000 rows | Java one by one, 10,000 rows | C# INSERT, 10,000 rows |
|---|---|---|---|---|
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 |
- The appender loads a million rows in about the time C# takes, apart from Windows.
addBatchandexecuteBatchbring nothing: the batch was no faster than single statements on any runner, or on this machine (609 ms against 532 ms). With this driver, a JDBC batch doesn’t send the rows in one call. Use the appender, orINSERT … SELECTfrom a file.- The single
INSERTstatements are 1.6 to 3.8 times faster in Java than in C#. Not investigated: both prepare the statement once; the difference may be in how each wrapper binds parameters.
Key takeaways
Section titled “Key takeaways”- One jar,
org.duckdb:duckdb_jdbc, holds the driver and the native libraries of four platforms; the JDBC URLjdbc:duckdb:opens an in-memory database. INTERVALis aString, andTIMESTAMPTZanOffsetDateTimein the JVM’s zone: convert to anInstantbefore printing or comparing.- Arrays and structs go through
java.sql.ArrayandDuckDBStruct.getMap(), with casts. - After an error, the statement is closed: one statement per query.
- JDK 25 warns about native access unless the JVM is started with
--enable-native-access=ALL-UNNAMED. - JDBC batches don’t speed up inserts with this driver; the appender does.
Exercises
Section titled “Exercises”- A service runs
SELECT createdAt AT TIME ZONE 'UTC' FROM runsand returnsrs.getObject(1).toString()in a JSON response. It gives2026-09-13T15:53:16Zin production and2026-09-13T11:53:16-04:00on a developer’s laptop. Is one of them wrong? How do you make them agree?
Solution
Neither is wrong: both are the same instant, with different offsets. The value depends on the JVM’s default time zone (user.timezone, or the OS setting), as the lesson measured. To make the output independent of the machine, choose the representation in code: rs.getObject(1, OffsetDateTime.class).toInstant() prints 2026-09-13T15:53:16Z everywhere, and so does withOffsetSameInstant(ZoneOffset.UTC). Starting the JVM with -Duser.timezone=UTC also works, but it changes every date of the application, and it’s easy to forget on one machine.
- This loop inserts rows and skips the invalid ones. With the DuckDB driver, what happens after the first invalid row?
try (var statement = connection.createStatement()) { for (String sql : statements) { try { statement.execute(sql); } catch (SQLException e) { System.err.println("skipped: " + e.getMessage()); } }}Solution
Every following statement fails with Statement was closed, and the loop “skips” all the remaining rows, valid or not: section 5 of the program shows the statement closed after a failed query. Create the statement inside the loop:
for (String sql : statements) { try (var statement = connection.createStatement()) { statement.execute(sql); } catch (SQLException e) { System.err.println("skipped: " + e.getMessage()); }}Checked in a scratch program with three INSERT statements, the second one inserting 'x' into an INTEGER column:
skipped: Invalid Input Error: Attempting to execute an unsuccessful or closed pending query resultskipped: Statement was closedrows: 1The third row, valid, was never inserted.
- The C# program formats dates with
CultureInfo.InvariantCulture. Does the Java program have the same problem withprintf("%,d")in itstimingsmode, and does it matter for the CI?
Solution
Yes: %,d uses the default locale’s grouping separator. The CI printed 1,000,000; in a scratch program, String.format(Locale.FRANCE, "%,d", 1_000_000) separated the groups with U+202F, a narrow no-break space, which looks like a space and isn’t one for a parser. It doesn’t matter for the CI, because check.sh only compares the check mode’s output, and the timings aren’t compared. For output that is compared or parsed, use String.format(Locale.ROOT, …), which gave 1,000,000.