<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>SQG - Compile SQL to Type-Safe Code | Blog</title><description/><link>https://sqg.dev/</link><language>en</language><item><title>Benchmarking DuckDB From Java: Fast INSERT, UPDATE, and DELETE</title><link>https://sqg.dev/blog/java-duckdb-benchmark/</link><guid isPermaLink="true">https://sqg.dev/blog/java-duckdb-benchmark/</guid><pubDate>Tue, 21 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;DuckDB’s &lt;a href=&quot;https://duckdb.org/docs/current/clients/java&quot;&gt;Java documentation&lt;/a&gt; recommends the Appender as the efficient way to insert data. But two questions come up in practice: &lt;strong&gt;how do you efficiently UPDATE and DELETE?&lt;/strong&gt; And &lt;strong&gt;is the Appender really the fastest option for INSERT, or are there faster alternatives?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;We benchmarked 7 different methods — from naive row-by-row JDBC to the Appender, Arrow streams, and DuckDB’s &lt;a href=&quot;https://github.com/duckdb/duckdb-java/blob/main/UDF.MD&quot;&gt;Java table function API&lt;/a&gt; — across INSERT, UPDATE, and DELETE, scaling from 1K to 1M rows.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-setup&quot;&gt;The Setup&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;We used a sensor-readings table with 9 columns covering DuckDB’s core types: &lt;code dir=&quot;auto&quot;&gt;UUID&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;TIMESTAMPTZ&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;DOUBLE&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;DECIMAL(10,2)&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;SMALLINT&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;BOOLEAN&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;VARCHAR&lt;/code&gt;, and &lt;code dir=&quot;auto&quot;&gt;VARCHAR[]&lt;/code&gt; (same schema as &lt;a href=&quot;https://sqg.dev/blog/java-postgres-insert-benchmark/&quot;&gt;our PostgreSQL benchmark&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;Each method was warmed up, then measured multiple times (median taken). After every operation, we verified correctness: row counts, spot-checked values, and for UPDATE specifically verified the target column was actually modified. DuckDB runs on disk with a single connection.&lt;/p&gt;
&lt;p&gt;All benchmark code is open source: &lt;a href=&quot;https://github.com/sqg-dev/sqg/tree/main/examples/java-duckdb-benchmark&quot;&gt;examples/java-duckdb-benchmark&lt;/a&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;insert-methods&quot;&gt;INSERT Methods&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;We tested 6 insert methods:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Individual INSERT&lt;/strong&gt; — One &lt;code dir=&quot;auto&quot;&gt;executeUpdate()&lt;/code&gt; per row in a transaction. Simple but slow: ~3.3K rows/sec at all sizes. (&lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-duckdb-benchmark/src/main/java/benchmark/InsertMethods.java#L118&quot;&gt;code&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. JDBC Batch&lt;/strong&gt; — Standard JDBC &lt;code dir=&quot;auto&quot;&gt;addBatch()&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;executeBatch()&lt;/code&gt; via the &lt;a href=&quot;https://duckdb.org/docs/current/clients/java&quot;&gt;DuckDB JDBC driver&lt;/a&gt;. On PostgreSQL this is a solid optimization; on DuckDB it barely helps — ~5K rows/sec. (&lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-duckdb-benchmark/src/main/java/benchmark/InsertMethods.java#L131&quot;&gt;code&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Multi-value INSERT&lt;/strong&gt; — Build &lt;code dir=&quot;auto&quot;&gt;INSERT INTO t VALUES (...),(...),(...) ...&lt;/code&gt; with up to 10K rows per statement. ~14K rows/sec. (&lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-duckdb-benchmark/src/main/java/benchmark/InsertMethods.java#L154&quot;&gt;code&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. Appender&lt;/strong&gt; — DuckDB’s &lt;a href=&quot;https://duckdb.org/docs/current/clients/java#appender&quot;&gt;recommended bulk insert API&lt;/a&gt;. Bypasses the SQL parser — you call &lt;code dir=&quot;auto&quot;&gt;beginRow()&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;append()&lt;/code&gt; for each column, and &lt;code dir=&quot;auto&quot;&gt;endRow()&lt;/code&gt;: (&lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-duckdb-benchmark/src/main/java/benchmark/InsertMethods.java#L179&quot;&gt;code&lt;/a&gt;)&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;try&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;appender&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; ((DuckDBConnection) conn)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;createAppender&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;DuckDBConnection&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;DEFAULT_SCHEMA&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;sensor_readings&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;r&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; rows) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;appender&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;beginRow&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;appender&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;append&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;r&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;deviceId&lt;/span&gt;&lt;span&gt;())&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;appender&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;append&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;r&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;timestamp&lt;/span&gt;&lt;span&gt;())&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;appender&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;append&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;r&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;temperature&lt;/span&gt;&lt;span&gt;())&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;// ... remaining columns&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;appender&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;endRow&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;5. Arrow Stream&lt;/strong&gt; — Build &lt;a href=&quot;https://arrow.apache.org/&quot;&gt;Apache Arrow&lt;/a&gt; columnar vectors in Java, register them with DuckDB via &lt;a href=&quot;https://duckdb.org/docs/current/clients/java#arrow-import&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;registerArrowStream&lt;/code&gt;&lt;/a&gt;, then &lt;code dir=&quot;auto&quot;&gt;INSERT INTO ... SELECT FROM stream&lt;/code&gt;. Since Arrow vectors must be fully built before registering, the data is processed in chunks of 10K rows — each chunk requires a separate &lt;code dir=&quot;auto&quot;&gt;INSERT ... SELECT&lt;/code&gt; statement. (&lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-duckdb-benchmark/src/main/java/benchmark/InsertMethods.java#L192&quot;&gt;code&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;6. Table Function (UDF)&lt;/strong&gt; — Register a Java table function via DuckDB’s &lt;a href=&quot;https://github.com/duckdb/duckdb-java/blob/main/UDF.MD#table-functions-duckdbtablefunction&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;DuckDBFunctions.tableFunction()&lt;/code&gt; API&lt;/a&gt;. DuckDB pulls rows directly from a Java &lt;code dir=&quot;auto&quot;&gt;Iterator&lt;/code&gt; in chunks of 2,048. Unlike Arrow, this executes a &lt;strong&gt;single SQL statement&lt;/strong&gt; regardless of data size — DuckDB calls the &lt;code dir=&quot;auto&quot;&gt;apply()&lt;/code&gt; callback repeatedly until the iterator is exhausted: (&lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-duckdb-benchmark/src/main/java/benchmark/TableFunctionMethods.java#L35&quot;&gt;code&lt;/a&gt;)&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;DuckDBFunctions&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;tableFunction&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;withName&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;_bench_insert_rows&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;withFunction&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;DuckDBTableFunction&lt;/span&gt;&lt;span&gt;&amp;#x3C;&gt;() {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;long&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;apply&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;DuckDBTableFunctionCallInfo&lt;/span&gt;&lt;span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;info&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                          &lt;/span&gt;&lt;span&gt;DuckDBDataChunkWriter&lt;/span&gt;&lt;span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;output&lt;/span&gt;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;// Fill columnar vectors, up to output.capacity() rows&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;// DuckDB calls this repeatedly until we return 0&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;register&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;conn&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Then: INSERT INTO sensor_readings SELECT ... FROM _bench_insert_rows()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;update-and-delete-methods&quot;&gt;UPDATE and DELETE Methods&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;For UPDATE and DELETE, Individual and JDBC Batch work the same way as for INSERT — one statement per row or batch. (&lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-duckdb-benchmark/src/main/java/benchmark/UpdateMethods.java#L38&quot;&gt;update code&lt;/a&gt;, &lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-duckdb-benchmark/src/main/java/benchmark/DeleteMethods.java#L35&quot;&gt;delete code&lt;/a&gt;)&lt;/p&gt;
&lt;p&gt;The interesting methods are the set-based approaches:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;UNNEST list params&lt;/strong&gt; — Pass all keys as parallel array parameters: (&lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-duckdb-benchmark/src/main/java/benchmark/DeleteMethods.java#L61&quot;&gt;code&lt;/a&gt;)&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;DELETE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;FROM&lt;/span&gt;&lt;span&gt; sensor_readings&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;WHERE&lt;/span&gt;&lt;span&gt; (device_id, &lt;/span&gt;&lt;span&gt;timestamp&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;IN&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;SELECT&lt;/span&gt;&lt;span&gt; UNNEST(?::UUID[]), UNNEST(?::&lt;/span&gt;&lt;span&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span&gt;[])&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Arrow stream join&lt;/strong&gt; — Stage keys as Arrow vectors, then join against the main table. Same &lt;code dir=&quot;auto&quot;&gt;registerArrowStream&lt;/code&gt; technique but with only the key columns. (&lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-duckdb-benchmark/src/main/java/benchmark/UpdateMethods.java#L83&quot;&gt;update code&lt;/a&gt;, &lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-duckdb-benchmark/src/main/java/benchmark/DeleteMethods.java#L77&quot;&gt;delete code&lt;/a&gt;)&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;DELETE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;FROM&lt;/span&gt;&lt;span&gt; sensor_readings s&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;USING&lt;/span&gt;&lt;span&gt; arrow_stream k&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;WHERE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;s&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;device_id&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;k&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;device_id&lt;/span&gt;&lt;span&gt;::UUID &lt;/span&gt;&lt;span&gt;AND&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;s&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;timestamp&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;k&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;timestamp&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Temp table + Appender&lt;/strong&gt; — Create a temp table, bulk-load keys with the Appender, then execute a set-based join. This turns DuckDB’s INSERT-only Appender into a tool for bulk UPDATE and DELETE: (&lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-duckdb-benchmark/src/main/java/benchmark/UpdateMethods.java#L121&quot;&gt;update code&lt;/a&gt;, &lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-duckdb-benchmark/src/main/java/benchmark/DeleteMethods.java#L110&quot;&gt;delete code&lt;/a&gt;)&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// 1. Create staging table&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;stmt&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;execute&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;CREATE TEMP TABLE _keys (device_id UUID, timestamp TIMESTAMPTZ)&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// 2. Bulk-load keys via Appender&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;try&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;appender&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; ((DuckDBConnection) conn)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;createAppender&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;DuckDBConnection&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;DEFAULT_SCHEMA&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;_keys&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;r&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; rows) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;appender&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;beginRow&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;appender&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;append&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;r&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;deviceId&lt;/span&gt;&lt;span&gt;())&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;appender&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;append&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;r&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;timestamp&lt;/span&gt;&lt;span&gt;())&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;appender&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;endRow&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// 3. Set-based delete&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;stmt&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;execute&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&quot;&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;DELETE FROM sensor_readings s&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;USING _keys k&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;WHERE s.device_id = k.device_id AND s.timestamp = k.timestamp&lt;/span&gt;&lt;span&gt;&quot;&quot;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;stmt&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;execute&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;DROP TABLE _keys&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The same pattern works for UPDATE — add the new values to the staging table:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;CREATE&lt;/span&gt;&lt;span&gt; TEMP &lt;/span&gt;&lt;span&gt;TABLE&lt;/span&gt;&lt;span&gt; _update_keys (device_id UUID, &lt;/span&gt;&lt;span&gt;timestamp&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span&gt;, temperature DOUBLE);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;-- Appender-load keys + new values&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;UPDATE&lt;/span&gt;&lt;span&gt; sensor_readings s&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;SET&lt;/span&gt;&lt;span&gt; temperature &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;k&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;temperature&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;FROM&lt;/span&gt;&lt;span&gt; _update_keys k&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;WHERE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;s&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;device_id&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;k&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;device_id&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;AND&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;s&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;timestamp&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;k&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;timestamp&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Table function join&lt;/strong&gt; — Same as the insert table function, but DuckDB uses it as a key source in a &lt;code dir=&quot;auto&quot;&gt;DELETE ... USING&lt;/code&gt; or &lt;code dir=&quot;auto&quot;&gt;UPDATE ... FROM&lt;/code&gt; join. (&lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-duckdb-benchmark/src/main/java/benchmark/TableFunctionMethods.java#L204&quot;&gt;update code&lt;/a&gt;, &lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-duckdb-benchmark/src/main/java/benchmark/TableFunctionMethods.java#L148&quot;&gt;delete code&lt;/a&gt;)&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-results&quot;&gt;The Results&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;  &lt;div&gt;
  
  
  &lt;div&gt;
    &lt;h1&gt;DuckDB Insert / Update / Delete Benchmark&lt;/h1&gt;
    &lt;p&gt;Comparing JDBC :batch, appender, Arrow, UNNEST list params, and temp-table join&lt;/p&gt;

    &lt;div&gt;
  &lt;h2&gt;INSERT: Throughput vs Total Rows&lt;/h2&gt;
  &lt;div&gt;&lt;canvas id=&quot;insertChart&quot;&gt;&lt;/canvas&gt;&lt;/div&gt;
  
&lt;/div&gt;
&lt;div&gt;
  &lt;h2&gt;UPDATE: Throughput vs Total Rows&lt;/h2&gt;
  &lt;div&gt;&lt;canvas id=&quot;updateChart&quot;&gt;&lt;/canvas&gt;&lt;/div&gt;
  
&lt;/div&gt;
&lt;div&gt;
  &lt;h2&gt;DELETE: Throughput vs Total Rows&lt;/h2&gt;
  &lt;div&gt;&lt;canvas id=&quot;deleteChart&quot;&gt;&lt;/canvas&gt;&lt;/div&gt;
  
&lt;/div&gt;


    &lt;details&gt;
&lt;summary&gt;INSERT results&lt;/summary&gt;
&lt;div&gt;
  &lt;h3&gt;1,000 rows&lt;/h3&gt;
  &lt;table&gt;
    &lt;thead&gt;&lt;tr&gt;&lt;th&gt;Method&lt;/th&gt;&lt;th&gt;Time (ms)&lt;/th&gt;&lt;th&gt;Rows/sec&lt;/th&gt;&lt;th&gt;vs Best&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;&lt;td&gt;Individual INSERT&lt;/td&gt;&lt;td&gt;345.9&lt;/td&gt;&lt;td&gt;2,891&lt;/td&gt;&lt;td&gt;29.3x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch (:batch)&lt;/td&gt;&lt;td&gt;216.8&lt;/td&gt;&lt;td&gt;4,613&lt;/td&gt;&lt;td&gt;18.3x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Multi-value INSERT&lt;/td&gt;&lt;td&gt;53.8&lt;/td&gt;&lt;td&gt;18,582&lt;/td&gt;&lt;td&gt;4.6x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Appender (:appender)&lt;/td&gt;&lt;td&gt;11.8&lt;/td&gt;&lt;td&gt;84,630&lt;/td&gt;&lt;td&gt;fastest&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Arrow stream&lt;/td&gt;&lt;td&gt;19.2&lt;/td&gt;&lt;td&gt;51,977&lt;/td&gt;&lt;td&gt;1.6x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Table function&lt;/td&gt;&lt;td&gt;13.6&lt;/td&gt;&lt;td&gt;73,622&lt;/td&gt;&lt;td&gt;1.1x slower&lt;/td&gt;&lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;
&lt;div&gt;
  &lt;h3&gt;5,000 rows&lt;/h3&gt;
  &lt;table&gt;
    &lt;thead&gt;&lt;tr&gt;&lt;th&gt;Method&lt;/th&gt;&lt;th&gt;Time (ms)&lt;/th&gt;&lt;th&gt;Rows/sec&lt;/th&gt;&lt;th&gt;vs Best&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;&lt;td&gt;Individual INSERT&lt;/td&gt;&lt;td&gt;1,582.3&lt;/td&gt;&lt;td&gt;3,160&lt;/td&gt;&lt;td&gt;64.4x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch (:batch)&lt;/td&gt;&lt;td&gt;1,009.2&lt;/td&gt;&lt;td&gt;4,954&lt;/td&gt;&lt;td&gt;41.1x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Multi-value INSERT&lt;/td&gt;&lt;td&gt;334.5&lt;/td&gt;&lt;td&gt;14,948&lt;/td&gt;&lt;td&gt;13.6x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Appender (:appender)&lt;/td&gt;&lt;td&gt;35.0&lt;/td&gt;&lt;td&gt;142,985&lt;/td&gt;&lt;td&gt;1.4x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Arrow stream&lt;/td&gt;&lt;td&gt;26.1&lt;/td&gt;&lt;td&gt;191,677&lt;/td&gt;&lt;td&gt;1.1x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Table function&lt;/td&gt;&lt;td&gt;24.6&lt;/td&gt;&lt;td&gt;203,537&lt;/td&gt;&lt;td&gt;fastest&lt;/td&gt;&lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;
&lt;div&gt;
  &lt;h3&gt;10,000 rows&lt;/h3&gt;
  &lt;table&gt;
    &lt;thead&gt;&lt;tr&gt;&lt;th&gt;Method&lt;/th&gt;&lt;th&gt;Time (ms)&lt;/th&gt;&lt;th&gt;Rows/sec&lt;/th&gt;&lt;th&gt;vs Best&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;&lt;td&gt;Individual INSERT&lt;/td&gt;&lt;td&gt;3,016.6&lt;/td&gt;&lt;td&gt;3,315&lt;/td&gt;&lt;td&gt;111.4x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch (:batch)&lt;/td&gt;&lt;td&gt;1,950.2&lt;/td&gt;&lt;td&gt;5,128&lt;/td&gt;&lt;td&gt;72.0x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Multi-value INSERT&lt;/td&gt;&lt;td&gt;750.5&lt;/td&gt;&lt;td&gt;13,325&lt;/td&gt;&lt;td&gt;27.7x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Appender (:appender)&lt;/td&gt;&lt;td&gt;62.3&lt;/td&gt;&lt;td&gt;160,472&lt;/td&gt;&lt;td&gt;2.3x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Arrow stream&lt;/td&gt;&lt;td&gt;27.1&lt;/td&gt;&lt;td&gt;369,291&lt;/td&gt;&lt;td&gt;fastest&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Table function&lt;/td&gt;&lt;td&gt;39.2&lt;/td&gt;&lt;td&gt;254,808&lt;/td&gt;&lt;td&gt;1.4x slower&lt;/td&gt;&lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;
&lt;div&gt;
  &lt;h3&gt;50,000 rows&lt;/h3&gt;
  &lt;table&gt;
    &lt;thead&gt;&lt;tr&gt;&lt;th&gt;Method&lt;/th&gt;&lt;th&gt;Time (ms)&lt;/th&gt;&lt;th&gt;Rows/sec&lt;/th&gt;&lt;th&gt;vs Best&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;&lt;td&gt;Individual INSERT&lt;/td&gt;&lt;td&gt;15,062.9&lt;/td&gt;&lt;td&gt;3,319&lt;/td&gt;&lt;td&gt;106.5x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch (:batch)&lt;/td&gt;&lt;td&gt;9,805.0&lt;/td&gt;&lt;td&gt;5,099&lt;/td&gt;&lt;td&gt;69.3x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Multi-value INSERT&lt;/td&gt;&lt;td&gt;3,722.1&lt;/td&gt;&lt;td&gt;13,433&lt;/td&gt;&lt;td&gt;26.3x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Appender (:appender)&lt;/td&gt;&lt;td&gt;347.8&lt;/td&gt;&lt;td&gt;143,751&lt;/td&gt;&lt;td&gt;2.5x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Arrow stream&lt;/td&gt;&lt;td&gt;188.1&lt;/td&gt;&lt;td&gt;265,791&lt;/td&gt;&lt;td&gt;1.3x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Table function&lt;/td&gt;&lt;td&gt;141.5&lt;/td&gt;&lt;td&gt;353,377&lt;/td&gt;&lt;td&gt;fastest&lt;/td&gt;&lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;
&lt;div&gt;
  &lt;h3&gt;100,000 rows&lt;/h3&gt;
  &lt;table&gt;
    &lt;thead&gt;&lt;tr&gt;&lt;th&gt;Method&lt;/th&gt;&lt;th&gt;Time (ms)&lt;/th&gt;&lt;th&gt;Rows/sec&lt;/th&gt;&lt;th&gt;vs Best&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;&lt;td&gt;Individual INSERT&lt;/td&gt;&lt;td&gt;30,299.7&lt;/td&gt;&lt;td&gt;3,300&lt;/td&gt;&lt;td&gt;128.7x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch (:batch)&lt;/td&gt;&lt;td&gt;19,854.5&lt;/td&gt;&lt;td&gt;5,037&lt;/td&gt;&lt;td&gt;84.3x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Multi-value INSERT&lt;/td&gt;&lt;td&gt;7,300.8&lt;/td&gt;&lt;td&gt;13,697&lt;/td&gt;&lt;td&gt;31.0x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Appender (:appender)&lt;/td&gt;&lt;td&gt;629.1&lt;/td&gt;&lt;td&gt;158,945&lt;/td&gt;&lt;td&gt;2.7x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Arrow stream&lt;/td&gt;&lt;td&gt;235.4&lt;/td&gt;&lt;td&gt;424,734&lt;/td&gt;&lt;td&gt;fastest&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Table function&lt;/td&gt;&lt;td&gt;250.2&lt;/td&gt;&lt;td&gt;399,683&lt;/td&gt;&lt;td&gt;1.1x slower&lt;/td&gt;&lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;
&lt;div&gt;
  &lt;h3&gt;500,000 rows&lt;/h3&gt;
  &lt;table&gt;
    &lt;thead&gt;&lt;tr&gt;&lt;th&gt;Method&lt;/th&gt;&lt;th&gt;Time (ms)&lt;/th&gt;&lt;th&gt;Rows/sec&lt;/th&gt;&lt;th&gt;vs Best&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;&lt;td&gt;Individual INSERT&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;skipped&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch (:batch)&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;skipped&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Multi-value INSERT&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;skipped&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Appender (:appender)&lt;/td&gt;&lt;td&gt;3,120.3&lt;/td&gt;&lt;td&gt;160,243&lt;/td&gt;&lt;td&gt;2.6x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Arrow stream&lt;/td&gt;&lt;td&gt;1,288.7&lt;/td&gt;&lt;td&gt;387,994&lt;/td&gt;&lt;td&gt;1.1x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Table function&lt;/td&gt;&lt;td&gt;1,179.9&lt;/td&gt;&lt;td&gt;423,747&lt;/td&gt;&lt;td&gt;fastest&lt;/td&gt;&lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;
&lt;div&gt;
  &lt;h3&gt;1,000,000 rows&lt;/h3&gt;
  &lt;table&gt;
    &lt;thead&gt;&lt;tr&gt;&lt;th&gt;Method&lt;/th&gt;&lt;th&gt;Time (ms)&lt;/th&gt;&lt;th&gt;Rows/sec&lt;/th&gt;&lt;th&gt;vs Best&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;&lt;td&gt;Individual INSERT&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;skipped&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch (:batch)&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;skipped&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Multi-value INSERT&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;skipped&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Appender (:appender)&lt;/td&gt;&lt;td&gt;6,544.0&lt;/td&gt;&lt;td&gt;152,812&lt;/td&gt;&lt;td&gt;2.5x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Arrow stream&lt;/td&gt;&lt;td&gt;2,922.8&lt;/td&gt;&lt;td&gt;342,139&lt;/td&gt;&lt;td&gt;1.1x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Table function&lt;/td&gt;&lt;td&gt;2,608.4&lt;/td&gt;&lt;td&gt;383,377&lt;/td&gt;&lt;td&gt;fastest&lt;/td&gt;&lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;
&lt;/details&gt;
&lt;details&gt;
&lt;summary&gt;UPDATE results&lt;/summary&gt;
&lt;div&gt;
  &lt;h3&gt;1,000 rows&lt;/h3&gt;
  &lt;table&gt;
    &lt;thead&gt;&lt;tr&gt;&lt;th&gt;Method&lt;/th&gt;&lt;th&gt;Time (ms)&lt;/th&gt;&lt;th&gt;Rows/sec&lt;/th&gt;&lt;th&gt;vs Best&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;&lt;td&gt;Individual UPDATE&lt;/td&gt;&lt;td&gt;226.2&lt;/td&gt;&lt;td&gt;4,421&lt;/td&gt;&lt;td&gt;20.8x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch (:batch)&lt;/td&gt;&lt;td&gt;192.0&lt;/td&gt;&lt;td&gt;5,208&lt;/td&gt;&lt;td&gt;17.7x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;UNNEST list params&lt;/td&gt;&lt;td&gt;12.2&lt;/td&gt;&lt;td&gt;81,756&lt;/td&gt;&lt;td&gt;1.1x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Arrow stream join&lt;/td&gt;&lt;td&gt;11.6&lt;/td&gt;&lt;td&gt;85,900&lt;/td&gt;&lt;td&gt;1.1x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Temp table join&lt;/td&gt;&lt;td&gt;10.9&lt;/td&gt;&lt;td&gt;91,933&lt;/td&gt;&lt;td&gt;fastest&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Table function&lt;/td&gt;&lt;td&gt;11.0&lt;/td&gt;&lt;td&gt;90,759&lt;/td&gt;&lt;td&gt;~same&lt;/td&gt;&lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;
&lt;div&gt;
  &lt;h3&gt;5,000 rows&lt;/h3&gt;
  &lt;table&gt;
    &lt;thead&gt;&lt;tr&gt;&lt;th&gt;Method&lt;/th&gt;&lt;th&gt;Time (ms)&lt;/th&gt;&lt;th&gt;Rows/sec&lt;/th&gt;&lt;th&gt;vs Best&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;&lt;td&gt;Individual UPDATE&lt;/td&gt;&lt;td&gt;1,179.6&lt;/td&gt;&lt;td&gt;4,239&lt;/td&gt;&lt;td&gt;95.1x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch (:batch)&lt;/td&gt;&lt;td&gt;1,052.6&lt;/td&gt;&lt;td&gt;4,750&lt;/td&gt;&lt;td&gt;84.8x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;UNNEST list params&lt;/td&gt;&lt;td&gt;17.0&lt;/td&gt;&lt;td&gt;294,131&lt;/td&gt;&lt;td&gt;1.4x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Arrow stream join&lt;/td&gt;&lt;td&gt;15.2&lt;/td&gt;&lt;td&gt;329,765&lt;/td&gt;&lt;td&gt;1.2x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Temp table join&lt;/td&gt;&lt;td&gt;12.4&lt;/td&gt;&lt;td&gt;402,927&lt;/td&gt;&lt;td&gt;fastest&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Table function&lt;/td&gt;&lt;td&gt;12.6&lt;/td&gt;&lt;td&gt;396,662&lt;/td&gt;&lt;td&gt;~same&lt;/td&gt;&lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;
&lt;div&gt;
  &lt;h3&gt;10,000 rows&lt;/h3&gt;
  &lt;table&gt;
    &lt;thead&gt;&lt;tr&gt;&lt;th&gt;Method&lt;/th&gt;&lt;th&gt;Time (ms)&lt;/th&gt;&lt;th&gt;Rows/sec&lt;/th&gt;&lt;th&gt;vs Best&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;&lt;td&gt;Individual UPDATE&lt;/td&gt;&lt;td&gt;2,863.7&lt;/td&gt;&lt;td&gt;3,492&lt;/td&gt;&lt;td&gt;211.6x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch (:batch)&lt;/td&gt;&lt;td&gt;2,298.9&lt;/td&gt;&lt;td&gt;4,350&lt;/td&gt;&lt;td&gt;169.9x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;UNNEST list params&lt;/td&gt;&lt;td&gt;24.0&lt;/td&gt;&lt;td&gt;417,146&lt;/td&gt;&lt;td&gt;1.8x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Arrow stream join&lt;/td&gt;&lt;td&gt;16.7&lt;/td&gt;&lt;td&gt;597,372&lt;/td&gt;&lt;td&gt;1.2x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Temp table join&lt;/td&gt;&lt;td&gt;13.5&lt;/td&gt;&lt;td&gt;739,041&lt;/td&gt;&lt;td&gt;fastest&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Table function&lt;/td&gt;&lt;td&gt;15.1&lt;/td&gt;&lt;td&gt;660,487&lt;/td&gt;&lt;td&gt;1.1x slower&lt;/td&gt;&lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;
&lt;div&gt;
  &lt;h3&gt;50,000 rows&lt;/h3&gt;
  &lt;table&gt;
    &lt;thead&gt;&lt;tr&gt;&lt;th&gt;Method&lt;/th&gt;&lt;th&gt;Time (ms)&lt;/th&gt;&lt;th&gt;Rows/sec&lt;/th&gt;&lt;th&gt;vs Best&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;&lt;td&gt;Individual UPDATE&lt;/td&gt;&lt;td&gt;14,630.4&lt;/td&gt;&lt;td&gt;3,418&lt;/td&gt;&lt;td&gt;688.1x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch (:batch)&lt;/td&gt;&lt;td&gt;13,578.4&lt;/td&gt;&lt;td&gt;3,682&lt;/td&gt;&lt;td&gt;638.6x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;UNNEST list params&lt;/td&gt;&lt;td&gt;119.9&lt;/td&gt;&lt;td&gt;417,188&lt;/td&gt;&lt;td&gt;5.6x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Arrow stream join&lt;/td&gt;&lt;td&gt;69.0&lt;/td&gt;&lt;td&gt;724,504&lt;/td&gt;&lt;td&gt;3.2x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Temp table join&lt;/td&gt;&lt;td&gt;21.3&lt;/td&gt;&lt;td&gt;2,351,675&lt;/td&gt;&lt;td&gt;fastest&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Table function&lt;/td&gt;&lt;td&gt;24.6&lt;/td&gt;&lt;td&gt;2,033,870&lt;/td&gt;&lt;td&gt;1.2x slower&lt;/td&gt;&lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;
&lt;div&gt;
  &lt;h3&gt;100,000 rows&lt;/h3&gt;
  &lt;table&gt;
    &lt;thead&gt;&lt;tr&gt;&lt;th&gt;Method&lt;/th&gt;&lt;th&gt;Time (ms)&lt;/th&gt;&lt;th&gt;Rows/sec&lt;/th&gt;&lt;th&gt;vs Best&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;&lt;td&gt;Individual UPDATE&lt;/td&gt;&lt;td&gt;36,936.2&lt;/td&gt;&lt;td&gt;2,707&lt;/td&gt;&lt;td&gt;1199.7x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch (:batch)&lt;/td&gt;&lt;td&gt;31,714.7&lt;/td&gt;&lt;td&gt;3,153&lt;/td&gt;&lt;td&gt;1030.1x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;UNNEST list params&lt;/td&gt;&lt;td&gt;232.0&lt;/td&gt;&lt;td&gt;431,070&lt;/td&gt;&lt;td&gt;7.5x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Arrow stream join&lt;/td&gt;&lt;td&gt;138.0&lt;/td&gt;&lt;td&gt;724,798&lt;/td&gt;&lt;td&gt;4.5x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Temp table join&lt;/td&gt;&lt;td&gt;30.8&lt;/td&gt;&lt;td&gt;3,248,036&lt;/td&gt;&lt;td&gt;fastest&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Table function&lt;/td&gt;&lt;td&gt;36.9&lt;/td&gt;&lt;td&gt;2,707,682&lt;/td&gt;&lt;td&gt;1.2x slower&lt;/td&gt;&lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;
&lt;div&gt;
  &lt;h3&gt;500,000 rows&lt;/h3&gt;
  &lt;table&gt;
    &lt;thead&gt;&lt;tr&gt;&lt;th&gt;Method&lt;/th&gt;&lt;th&gt;Time (ms)&lt;/th&gt;&lt;th&gt;Rows/sec&lt;/th&gt;&lt;th&gt;vs Best&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;&lt;td&gt;Individual UPDATE&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;skipped&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch (:batch)&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;skipped&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;UNNEST list params&lt;/td&gt;&lt;td&gt;1,223.8&lt;/td&gt;&lt;td&gt;408,575&lt;/td&gt;&lt;td&gt;7.6x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Arrow stream join&lt;/td&gt;&lt;td&gt;669.7&lt;/td&gt;&lt;td&gt;746,591&lt;/td&gt;&lt;td&gt;4.2x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Temp table join&lt;/td&gt;&lt;td&gt;160.9&lt;/td&gt;&lt;td&gt;3,106,897&lt;/td&gt;&lt;td&gt;fastest&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Table function&lt;/td&gt;&lt;td&gt;198.9&lt;/td&gt;&lt;td&gt;2,513,792&lt;/td&gt;&lt;td&gt;1.2x slower&lt;/td&gt;&lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;
&lt;div&gt;
  &lt;h3&gt;1,000,000 rows&lt;/h3&gt;
  &lt;table&gt;
    &lt;thead&gt;&lt;tr&gt;&lt;th&gt;Method&lt;/th&gt;&lt;th&gt;Time (ms)&lt;/th&gt;&lt;th&gt;Rows/sec&lt;/th&gt;&lt;th&gt;vs Best&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;&lt;td&gt;Individual UPDATE&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;skipped&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch (:batch)&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;skipped&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;UNNEST list params&lt;/td&gt;&lt;td&gt;2,340.3&lt;/td&gt;&lt;td&gt;427,299&lt;/td&gt;&lt;td&gt;9.1x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Arrow stream join&lt;/td&gt;&lt;td&gt;1,321.6&lt;/td&gt;&lt;td&gt;756,654&lt;/td&gt;&lt;td&gt;5.1x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Temp table join&lt;/td&gt;&lt;td&gt;257.3&lt;/td&gt;&lt;td&gt;3,887,168&lt;/td&gt;&lt;td&gt;fastest&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Table function&lt;/td&gt;&lt;td&gt;330.8&lt;/td&gt;&lt;td&gt;3,023,035&lt;/td&gt;&lt;td&gt;1.3x slower&lt;/td&gt;&lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;
&lt;/details&gt;
&lt;details&gt;
&lt;summary&gt;DELETE results&lt;/summary&gt;
&lt;div&gt;
  &lt;h3&gt;1,000 rows&lt;/h3&gt;
  &lt;table&gt;
    &lt;thead&gt;&lt;tr&gt;&lt;th&gt;Method&lt;/th&gt;&lt;th&gt;Time (ms)&lt;/th&gt;&lt;th&gt;Rows/sec&lt;/th&gt;&lt;th&gt;vs Best&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;&lt;td&gt;Individual DELETE&lt;/td&gt;&lt;td&gt;199.4&lt;/td&gt;&lt;td&gt;5,016&lt;/td&gt;&lt;td&gt;20.1x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch (:batch)&lt;/td&gt;&lt;td&gt;166.8&lt;/td&gt;&lt;td&gt;5,997&lt;/td&gt;&lt;td&gt;16.8x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;UNNEST list params&lt;/td&gt;&lt;td&gt;10.9&lt;/td&gt;&lt;td&gt;91,474&lt;/td&gt;&lt;td&gt;1.1x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Arrow stream join&lt;/td&gt;&lt;td&gt;12.8&lt;/td&gt;&lt;td&gt;78,130&lt;/td&gt;&lt;td&gt;1.3x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Temp table join&lt;/td&gt;&lt;td&gt;10.6&lt;/td&gt;&lt;td&gt;94,494&lt;/td&gt;&lt;td&gt;1.1x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Table function&lt;/td&gt;&lt;td&gt;9.9&lt;/td&gt;&lt;td&gt;100,844&lt;/td&gt;&lt;td&gt;fastest&lt;/td&gt;&lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;
&lt;div&gt;
  &lt;h3&gt;5,000 rows&lt;/h3&gt;
  &lt;table&gt;
    &lt;thead&gt;&lt;tr&gt;&lt;th&gt;Method&lt;/th&gt;&lt;th&gt;Time (ms)&lt;/th&gt;&lt;th&gt;Rows/sec&lt;/th&gt;&lt;th&gt;vs Best&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;&lt;td&gt;Individual DELETE&lt;/td&gt;&lt;td&gt;916.2&lt;/td&gt;&lt;td&gt;5,457&lt;/td&gt;&lt;td&gt;81.0x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch (:batch)&lt;/td&gt;&lt;td&gt;808.0&lt;/td&gt;&lt;td&gt;6,188&lt;/td&gt;&lt;td&gt;71.5x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;UNNEST list params&lt;/td&gt;&lt;td&gt;15.6&lt;/td&gt;&lt;td&gt;320,613&lt;/td&gt;&lt;td&gt;1.4x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Arrow stream join&lt;/td&gt;&lt;td&gt;14.5&lt;/td&gt;&lt;td&gt;343,747&lt;/td&gt;&lt;td&gt;1.3x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Temp table join&lt;/td&gt;&lt;td&gt;11.8&lt;/td&gt;&lt;td&gt;423,315&lt;/td&gt;&lt;td&gt;~same&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Table function&lt;/td&gt;&lt;td&gt;11.3&lt;/td&gt;&lt;td&gt;442,306&lt;/td&gt;&lt;td&gt;fastest&lt;/td&gt;&lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;
&lt;div&gt;
  &lt;h3&gt;10,000 rows&lt;/h3&gt;
  &lt;table&gt;
    &lt;thead&gt;&lt;tr&gt;&lt;th&gt;Method&lt;/th&gt;&lt;th&gt;Time (ms)&lt;/th&gt;&lt;th&gt;Rows/sec&lt;/th&gt;&lt;th&gt;vs Best&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;&lt;td&gt;Individual DELETE&lt;/td&gt;&lt;td&gt;1,820.0&lt;/td&gt;&lt;td&gt;5,495&lt;/td&gt;&lt;td&gt;142.6x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch (:batch)&lt;/td&gt;&lt;td&gt;1,784.6&lt;/td&gt;&lt;td&gt;5,603&lt;/td&gt;&lt;td&gt;139.8x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;UNNEST list params&lt;/td&gt;&lt;td&gt;22.5&lt;/td&gt;&lt;td&gt;444,311&lt;/td&gt;&lt;td&gt;1.8x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Arrow stream join&lt;/td&gt;&lt;td&gt;16.5&lt;/td&gt;&lt;td&gt;606,296&lt;/td&gt;&lt;td&gt;1.3x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Temp table join&lt;/td&gt;&lt;td&gt;12.8&lt;/td&gt;&lt;td&gt;783,441&lt;/td&gt;&lt;td&gt;fastest&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Table function&lt;/td&gt;&lt;td&gt;14.5&lt;/td&gt;&lt;td&gt;689,856&lt;/td&gt;&lt;td&gt;1.1x slower&lt;/td&gt;&lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;
&lt;div&gt;
  &lt;h3&gt;50,000 rows&lt;/h3&gt;
  &lt;table&gt;
    &lt;thead&gt;&lt;tr&gt;&lt;th&gt;Method&lt;/th&gt;&lt;th&gt;Time (ms)&lt;/th&gt;&lt;th&gt;Rows/sec&lt;/th&gt;&lt;th&gt;vs Best&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;&lt;td&gt;Individual DELETE&lt;/td&gt;&lt;td&gt;10,988.2&lt;/td&gt;&lt;td&gt;4,550&lt;/td&gt;&lt;td&gt;584.2x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch (:batch)&lt;/td&gt;&lt;td&gt;8,356.6&lt;/td&gt;&lt;td&gt;5,983&lt;/td&gt;&lt;td&gt;444.3x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;UNNEST list params&lt;/td&gt;&lt;td&gt;94.6&lt;/td&gt;&lt;td&gt;528,407&lt;/td&gt;&lt;td&gt;5.0x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Arrow stream join&lt;/td&gt;&lt;td&gt;64.5&lt;/td&gt;&lt;td&gt;774,737&lt;/td&gt;&lt;td&gt;3.4x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Temp table join&lt;/td&gt;&lt;td&gt;18.8&lt;/td&gt;&lt;td&gt;2,658,112&lt;/td&gt;&lt;td&gt;fastest&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Table function&lt;/td&gt;&lt;td&gt;25.1&lt;/td&gt;&lt;td&gt;1,993,260&lt;/td&gt;&lt;td&gt;1.3x slower&lt;/td&gt;&lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;
&lt;div&gt;
  &lt;h3&gt;100,000 rows&lt;/h3&gt;
  &lt;table&gt;
    &lt;thead&gt;&lt;tr&gt;&lt;th&gt;Method&lt;/th&gt;&lt;th&gt;Time (ms)&lt;/th&gt;&lt;th&gt;Rows/sec&lt;/th&gt;&lt;th&gt;vs Best&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;&lt;td&gt;Individual DELETE&lt;/td&gt;&lt;td&gt;23,546.4&lt;/td&gt;&lt;td&gt;4,247&lt;/td&gt;&lt;td&gt;836.7x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch (:batch)&lt;/td&gt;&lt;td&gt;21,117.7&lt;/td&gt;&lt;td&gt;4,735&lt;/td&gt;&lt;td&gt;750.4x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;UNNEST list params&lt;/td&gt;&lt;td&gt;192.2&lt;/td&gt;&lt;td&gt;520,213&lt;/td&gt;&lt;td&gt;6.8x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Arrow stream join&lt;/td&gt;&lt;td&gt;130.4&lt;/td&gt;&lt;td&gt;766,958&lt;/td&gt;&lt;td&gt;4.6x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Temp table join&lt;/td&gt;&lt;td&gt;28.1&lt;/td&gt;&lt;td&gt;3,553,297&lt;/td&gt;&lt;td&gt;fastest&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Table function&lt;/td&gt;&lt;td&gt;40.0&lt;/td&gt;&lt;td&gt;2,500,696&lt;/td&gt;&lt;td&gt;1.4x slower&lt;/td&gt;&lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;
&lt;div&gt;
  &lt;h3&gt;500,000 rows&lt;/h3&gt;
  &lt;table&gt;
    &lt;thead&gt;&lt;tr&gt;&lt;th&gt;Method&lt;/th&gt;&lt;th&gt;Time (ms)&lt;/th&gt;&lt;th&gt;Rows/sec&lt;/th&gt;&lt;th&gt;vs Best&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;&lt;td&gt;Individual DELETE&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;skipped&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch (:batch)&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;skipped&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;UNNEST list params&lt;/td&gt;&lt;td&gt;956.8&lt;/td&gt;&lt;td&gt;522,595&lt;/td&gt;&lt;td&gt;9.4x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Arrow stream join&lt;/td&gt;&lt;td&gt;642.3&lt;/td&gt;&lt;td&gt;778,404&lt;/td&gt;&lt;td&gt;6.3x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Temp table join&lt;/td&gt;&lt;td&gt;101.6&lt;/td&gt;&lt;td&gt;4,922,569&lt;/td&gt;&lt;td&gt;fastest&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Table function&lt;/td&gt;&lt;td&gt;148.8&lt;/td&gt;&lt;td&gt;3,359,951&lt;/td&gt;&lt;td&gt;1.5x slower&lt;/td&gt;&lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;
&lt;div&gt;
  &lt;h3&gt;1,000,000 rows&lt;/h3&gt;
  &lt;table&gt;
    &lt;thead&gt;&lt;tr&gt;&lt;th&gt;Method&lt;/th&gt;&lt;th&gt;Time (ms)&lt;/th&gt;&lt;th&gt;Rows/sec&lt;/th&gt;&lt;th&gt;vs Best&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;&lt;td&gt;Individual DELETE&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;skipped&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch (:batch)&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;skipped&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;UNNEST list params&lt;/td&gt;&lt;td&gt;1,891.2&lt;/td&gt;&lt;td&gt;528,756&lt;/td&gt;&lt;td&gt;10.3x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Arrow stream join&lt;/td&gt;&lt;td&gt;1,248.1&lt;/td&gt;&lt;td&gt;801,192&lt;/td&gt;&lt;td&gt;6.8x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Temp table join&lt;/td&gt;&lt;td&gt;184.2&lt;/td&gt;&lt;td&gt;5,429,384&lt;/td&gt;&lt;td&gt;fastest&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Table function&lt;/td&gt;&lt;td&gt;302.2&lt;/td&gt;&lt;td&gt;3,308,981&lt;/td&gt;&lt;td&gt;1.6x slower&lt;/td&gt;&lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;


&lt;/details&gt;

    &lt;div&gt;
      &lt;h3&gt;System Information&lt;/h3&gt;
      &lt;table&gt;
                &lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Java&lt;/td&gt;&lt;td&gt;25 (Eclipse Adoptium)&lt;/td&gt;&lt;/tr&gt;
        &lt;tr&gt;&lt;td&gt;OS&lt;/td&gt;&lt;td&gt;Linux 6.8.0-107-generic&lt;/td&gt;&lt;/tr&gt;
        &lt;tr&gt;&lt;td&gt;Arch&lt;/td&gt;&lt;td&gt;amd64&lt;/td&gt;&lt;/tr&gt;
        &lt;tr&gt;&lt;td&gt;CPUs&lt;/td&gt;&lt;td&gt;24&lt;/td&gt;&lt;/tr&gt;
        &lt;tr&gt;&lt;td&gt;Max Memory&lt;/td&gt;&lt;td&gt;7,956 MB&lt;/td&gt;&lt;/tr&gt;
        &lt;tr&gt;&lt;td&gt;DuckDB&lt;/td&gt;&lt;td&gt;v1.5.2&lt;/td&gt;&lt;/tr&gt;
        &lt;tr&gt;&lt;td&gt;Warmup Runs&lt;/td&gt;&lt;td&gt;1&lt;/td&gt;&lt;/tr&gt;
        &lt;tr&gt;&lt;td&gt;Measurement Runs&lt;/td&gt;&lt;td&gt;3&lt;/td&gt;&lt;/tr&gt;

      &lt;/tbody&gt;&lt;/table&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;
 &lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;appender-the-simple-default&quot;&gt;Appender: The Simple Default&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The &lt;a href=&quot;https://duckdb.org/docs/current/clients/java#appender&quot;&gt;Appender&lt;/a&gt; is the right starting point for most Java applications. It’s simple, has no extra dependencies, and at 160K rows/sec it’s &lt;strong&gt;30× faster than JDBC batch&lt;/strong&gt;. For batches under 5K rows, it’s actually the fastest method.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;table-functions-and-arrow-for-higher-throughput&quot;&gt;Table Functions and Arrow: For Higher Throughput&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Above 10K rows, &lt;a href=&quot;https://github.com/duckdb/duckdb-java/blob/main/UDF.MD#table-functions-duckdbtablefunction&quot;&gt;Table Functions&lt;/a&gt; (427K rows/sec at 1M) and &lt;a href=&quot;https://duckdb.org/docs/current/clients/java#arrow-import&quot;&gt;Arrow&lt;/a&gt; (381K rows/sec) both deliver &lt;strong&gt;2.5–2.7× the Appender’s throughput&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Table Functions&lt;/strong&gt; need no extra dependencies — just the DuckDB JDBC driver. You implement a &lt;code dir=&quot;auto&quot;&gt;bind()&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;init()&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;apply()&lt;/code&gt; callback that writes columnar vectors. The key advantage: the entire operation runs as a &lt;strong&gt;single SQL statement&lt;/strong&gt;, regardless of data size. DuckDB streams through the callback internally. This is why Table Functions overtake Arrow at large sizes (500K+). For UPDATE/DELETE they place second behind temp-table-join at 3.0–3.4M rows/sec.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Arrow&lt;/strong&gt; requires Apache Arrow JARs and more code (schema definitions, vector building, stream registration, off-heap memory management). Arrow vectors must be fully built in memory before registering, so data is processed in chunks — each chunk is a separate SQL statement. At 1M rows with 10K-row chunks, that’s 100 statements vs Table Function’s 1. Arrow wins at medium sizes (10K–100K) but falls behind Table Functions at 1M. It’s the right choice when your data is already columnar or when you need interop with other Arrow-based systems.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h3 id=&quot;temp-table--appender-for-bulk-mutations&quot;&gt;Temp Table + Appender: For Bulk Mutations&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The temp-table-join pattern delivers &lt;strong&gt;5.8M deletes/sec&lt;/strong&gt; and &lt;strong&gt;3.8M updates/sec&lt;/strong&gt; at 1M rows — over &lt;strong&gt;1,000× faster than JDBC batch&lt;/strong&gt;. Instead of executing 1M individual statements, DuckDB executes a single set-based join. UNNEST (the other set-based approach) is 9–11× slower at 1M.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;jdbc-batch-for-small-operations&quot;&gt;JDBC Batch: For Small Operations&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;On DuckDB, &lt;code dir=&quot;auto&quot;&gt;addBatch()&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;executeBatch()&lt;/code&gt; provides almost no speedup over individual statements (~5K vs ~3.3K rows/sec). DuckDB doesn’t rewrite batched statements the way PostgreSQL does with &lt;code dir=&quot;auto&quot;&gt;reWriteBatchedInserts&lt;/code&gt;. JDBC Batch is still the simplest option and works across both DuckDB and PostgreSQL, making it useful for portable code or small batches.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;methodology&quot;&gt;Methodology&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://duckdb.org/docs/current/clients/java&quot;&gt;DuckDB JDBC&lt;/a&gt; (in-process, on-disk database)&lt;/li&gt;
&lt;li&gt;Java 25 (Temurin)&lt;/li&gt;
&lt;li&gt;Apache Arrow 19.0.0&lt;/li&gt;
&lt;li&gt;Each method: warmup + measurement runs, median reported&lt;/li&gt;
&lt;li&gt;Data verified after every operation (row count + value checks)&lt;/li&gt;
&lt;li&gt;All methods use a single connection&lt;/li&gt;
&lt;li&gt;All methods accept &lt;code dir=&quot;auto&quot;&gt;Iterable&lt;/code&gt; (not &lt;code dir=&quot;auto&quot;&gt;List&lt;/code&gt;) — no method assumes the full dataset fits in memory&lt;/li&gt;
&lt;li&gt;Methods that need batching (Arrow, UNNEST, Multi-value, JDBC Batch) chunk at 10,000 rows internally&lt;/li&gt;
&lt;li&gt;The Table Function streams naturally via its &lt;code dir=&quot;auto&quot;&gt;Iterator&lt;/code&gt;-based callback at 2,048 rows per chunk&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;try-it-yourself&quot;&gt;Try It Yourself&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;git&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;clone&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;https://github.com/sqg-dev/sqg&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;cd&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;sqg/examples/java-duckdb-benchmark&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;just&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;run&lt;/span&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;# normal (skips slow methods at large sizes)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;just&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;run&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;--all&lt;/span&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;# include Individual/Batch at every size&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;how-we-built-this&quot;&gt;How We Built This&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The benchmark uses &lt;a href=&quot;https://sqg.dev&quot;&gt;SQG&lt;/a&gt;, a type-safe SQL code generator. You write annotated &lt;code dir=&quot;auto&quot;&gt;.sql&lt;/code&gt; files with your queries, and SQG introspects them against a real database at build time to generate type-safe access code for Java, TypeScript, or Python. No ORM, no runtime reflection — just plain SQL in, type-safe code out.&lt;/p&gt;
&lt;p&gt;For example, this annotation:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;-- TABLE sensor_readings :appender&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;generates a complete type-safe &lt;code dir=&quot;auto&quot;&gt;SensorReadingsAppender&lt;/code&gt; class with &lt;code dir=&quot;auto&quot;&gt;append(UUID deviceId, OffsetDateTime timestamp, ...)&lt;/code&gt; — column types inferred directly from DuckDB.&lt;/p&gt;
&lt;p&gt;From the single &lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-duckdb-benchmark/queries.sql&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;queries.sql&lt;/code&gt;&lt;/a&gt; file, SQG generated most of the code used in this benchmark:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The Appender for &lt;code dir=&quot;auto&quot;&gt;sensor_readings&lt;/code&gt; (&lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-duckdb-benchmark/queries.sql#L14&quot;&gt;from &lt;code dir=&quot;auto&quot;&gt;TABLE sensor_readings :appender&lt;/code&gt;&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Batch INSERT/UPDATE/DELETE methods (&lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-duckdb-benchmark/queries.sql#L27&quot;&gt;from &lt;code dir=&quot;auto&quot;&gt;:batch&lt;/code&gt; annotations&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;UNNEST bulk delete/update methods (&lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-duckdb-benchmark/queries.sql#L65&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;delete_readings_unnest&lt;/code&gt;&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;The temp-table staging pattern: &lt;code dir=&quot;auto&quot;&gt;CREATE&lt;/code&gt; EXECs, staging Appenders (&lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-duckdb-benchmark/queries.sql#L80&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;TABLE _delete_keys :appender&lt;/code&gt;&lt;/a&gt;), and set-based join EXECs&lt;/li&gt;
&lt;li&gt;Individual row methods and verification queries&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The Arrow and Table Function methods are the only hand-written ones — everything else is generated from SQL.&lt;/p&gt;
&lt;p&gt;SQG can currently generate DuckDB appender code and also produce Arrow code for reading results (&lt;code dir=&quot;auto&quot;&gt;SELECT&lt;/code&gt;). We are planning to add
more support for generating code for UDFs and methods which use Arrow as input.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Questions or feedback? Join the &lt;a href=&quot;https://github.com/sqg-dev/sqg/discussions&quot;&gt;discussion on GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded></item><item><title>I Benchmarked 9 Ways to Insert Data Into PostgreSQL From Java. COPY BINARY Won by a Landslide.</title><link>https://sqg.dev/blog/java-postgres-insert-benchmark/</link><guid isPermaLink="true">https://sqg.dev/blog/java-postgres-insert-benchmark/</guid><pubDate>Thu, 26 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; We benchmarked 9 different methods of inserting data into PostgreSQL from Java, from naive individual INSERTs to DuckDB-via-Arrow pipelines. COPY BINARY wins decisively at scale: &lt;strong&gt;712K rows/sec&lt;/strong&gt; for 1M rows — 2x faster than COPY CSV, 5x faster than JDBC batch, and 18x faster than individual INSERTs. The sweet spot for most applications is &lt;code dir=&quot;auto&quot;&gt;reWriteBatchedInserts=true&lt;/code&gt; (one URL parameter, 2.5x speedup over naive batch). For high-volume ingestion, COPY BINARY via &lt;a href=&quot;https://github.com/PgBulkInsert/PgBulkInsert&quot;&gt;PgBulkInsert&lt;/a&gt; is unbeatable.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-setup&quot;&gt;The Setup&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;We built a benchmark that simulates an IoT sensor ingestion pipeline — a table with 9 columns covering the interesting PostgreSQL types: &lt;code dir=&quot;auto&quot;&gt;UUID&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;TIMESTAMPTZ&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;DOUBLE PRECISION&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;NUMERIC&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;SMALLINT&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;BOOLEAN&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;TEXT&lt;/code&gt;, and &lt;code dir=&quot;auto&quot;&gt;TEXT[]&lt;/code&gt;. One million rows of randomized but deterministic sensor readings (temperature, humidity, pressure, battery level, anomaly flags, location tags).&lt;/p&gt;
&lt;p&gt;Each method was warmed up (2 runs), then measured (5 runs, median taken). After every insert, we verified both row count and spot-checked the first and last 10 rows against source data. PostgreSQL 18 via TestContainers. Java 25.&lt;/p&gt;
&lt;p&gt;All benchmark code is open source: &lt;a href=&quot;https://github.com/sqg-dev/sqg/tree/main/examples/java-postgres-benchmark&quot;&gt;examples/java-postgres-benchmark&lt;/a&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-methods&quot;&gt;The Methods&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Here’s what we tested, roughly in order of complexity:&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;1-individual-insert-the-naive-approach&quot;&gt;1. Individual INSERT (the naive approach)&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;One &lt;code dir=&quot;auto&quot;&gt;executeUpdate()&lt;/code&gt; per row, wrapped in a single transaction. This is what most beginners write, and what many ORMs produce. (&lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-postgres-benchmark/src/main/java/benchmark/InsertMethods.java#L137&quot;&gt;code&lt;/a&gt;)&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;2-batch-insert-addbatch--executebatch&quot;&gt;2. Batch INSERT (&lt;code dir=&quot;auto&quot;&gt;addBatch&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;executeBatch&lt;/code&gt;)&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Standard JDBC batching. Accumulate rows with &lt;code dir=&quot;auto&quot;&gt;addBatch()&lt;/code&gt;, flush with &lt;code dir=&quot;auto&quot;&gt;executeBatch()&lt;/code&gt;. The driver sends them as individual INSERT statements but pipelines the network calls. (&lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-postgres-benchmark/src/main/java/benchmark/InsertMethods.java#L149&quot;&gt;code&lt;/a&gt;)&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;3-batch-insert-with-rewritebatchedinsertstrue&quot;&gt;3. Batch INSERT with &lt;code dir=&quot;auto&quot;&gt;reWriteBatchedInserts=true&lt;/code&gt;&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Same code as #2, but with one URL parameter added: &lt;code dir=&quot;auto&quot;&gt;?reWriteBatchedInserts=true&lt;/code&gt;. The PostgreSQL JDBC driver automatically rewrites individual batched INSERTs into multi-value statements: &lt;code dir=&quot;auto&quot;&gt;INSERT INTO t VALUES (...),(...),...&lt;/code&gt;. This is the &lt;strong&gt;lowest-effort optimization&lt;/strong&gt; you can make. (&lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-postgres-benchmark/src/main/java/benchmark/InsertMethods.java#L153&quot;&gt;code&lt;/a&gt;)&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;4-multi-value-insert-hand-crafted&quot;&gt;4. Multi-value INSERT (hand-crafted)&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Manually build &lt;code dir=&quot;auto&quot;&gt;INSERT INTO t VALUES (...),(...),(...) ...&lt;/code&gt; with chunks of 1,000 rows. The hand-rolled version of what &lt;code dir=&quot;auto&quot;&gt;reWriteBatchedInserts&lt;/code&gt; does automatically. (&lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-postgres-benchmark/src/main/java/benchmark/InsertMethods.java#L179&quot;&gt;code&lt;/a&gt;)&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;5-unnest&quot;&gt;5. UNNEST&lt;/h3&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; t &lt;/span&gt;&lt;span&gt;SELECT&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;*&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;FROM&lt;/span&gt;&lt;span&gt; unnest($&lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;::uuid[], $&lt;/span&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt;::&lt;/span&gt;&lt;span&gt;timestamptz&lt;/span&gt;&lt;span&gt;[], ...)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Pass one array per column instead of one parameter per cell. Only 9 parameters regardless of row count, so it avoids PostgreSQL’s 65K parameter limit. Reduces query planning overhead. (&lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-postgres-benchmark/src/main/java/benchmark/InsertMethods.java#L209&quot;&gt;code&lt;/a&gt;, &lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-postgres-benchmark/src/main/java/generated/Queries.java#L393&quot;&gt;generated&lt;/a&gt;)&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;6-copy-csv&quot;&gt;6. COPY CSV&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;PostgreSQL’s &lt;code dir=&quot;auto&quot;&gt;COPY ... FROM STDIN WITH (FORMAT CSV)&lt;/code&gt; via the JDBC driver’s &lt;code dir=&quot;auto&quot;&gt;CopyManager&lt;/code&gt;. Build a CSV string in memory, stream it to PostgreSQL. This is the standard “fast path” that most PostgreSQL performance guides recommend. (&lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-postgres-benchmark/src/main/java/benchmark/InsertMethods.java#L243&quot;&gt;code&lt;/a&gt;)&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;7-copy-binary&quot;&gt;7. COPY BINARY&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;PostgreSQL’s &lt;code dir=&quot;auto&quot;&gt;COPY ... FROM STDIN (FORMAT BINARY)&lt;/code&gt; via &lt;a href=&quot;https://github.com/PgBulkInsert/PgBulkInsert&quot;&gt;PgBulkInsert&lt;/a&gt;. Instead of text CSV, it writes PostgreSQL’s native binary wire format — no parsing overhead on the server side. (&lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-postgres-benchmark/src/main/java/benchmark/InsertMethods.java#L263&quot;&gt;code&lt;/a&gt;, &lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-postgres-benchmark/src/main/java/generated/Queries.java#L457&quot;&gt;generated&lt;/a&gt;)&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;8-duckdb--postgresql&quot;&gt;8. DuckDB → PostgreSQL&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Load data into an in-memory DuckDB table via DuckDB’s Appender API (extremely fast — ~850K rows/sec into DuckDB), then push to PostgreSQL via DuckDB’s postgres extension: &lt;code dir=&quot;auto&quot;&gt;INSERT INTO pg.table SELECT * FROM local_table&lt;/code&gt;. DuckDB uses COPY BINARY under the hood for the transfer. (&lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-postgres-benchmark/src/main/java/benchmark/InsertMethods.java#L276&quot;&gt;code&lt;/a&gt;, &lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-postgres-benchmark/src/main/java/generated/QueriesDuckdb.java#L240&quot;&gt;generated&lt;/a&gt;)&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;9-arrow--duckdb--postgresql&quot;&gt;9. Arrow → DuckDB → PostgreSQL&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Build Apache Arrow columnar vectors in Java, register them with DuckDB as a virtual table (zero-copy), then push to PostgreSQL via the postgres extension. Skips the DuckDB staging table entirely. (&lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-postgres-benchmark/src/main/java/benchmark/InsertMethods.java#L307&quot;&gt;code&lt;/a&gt;)&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-results&quot;&gt;The Results&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The interactive report below shows all batch sizes from 100 to 1,000,000 rows:&lt;/p&gt;
&lt;div&gt;  &lt;div&gt;
  
  
  &lt;div&gt;
    &lt;h1&gt;PostgreSQL Insert Benchmark&lt;/h1&gt;
    &lt;p&gt;Comparing 9 insert methods across batch sizes from 100 to 1,000,000 rows&lt;/p&gt;

    &lt;div&gt;
  &lt;h2&gt;Throughput vs Total Rows&lt;/h2&gt;
  &lt;div&gt;&lt;canvas id=&quot;scaleChart&quot;&gt;&lt;/canvas&gt;&lt;/div&gt;
  
&lt;/div&gt;


    &lt;div&gt;
  &lt;h2&gt;100 rows&lt;/h2&gt;
  &lt;table&gt;
    &lt;thead&gt;&lt;tr&gt;&lt;th&gt;Method&lt;/th&gt;&lt;th&gt;Time (ms)&lt;/th&gt;&lt;th&gt;Rows/sec&lt;/th&gt;&lt;th&gt;vs Best&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;&lt;td&gt;Individual INSERT&lt;/td&gt;&lt;td&gt;9.0&lt;/td&gt;&lt;td&gt;11,057&lt;/td&gt;&lt;td&gt;10.0x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch INSERT&lt;/td&gt;&lt;td&gt;1.4&lt;/td&gt;&lt;td&gt;72,420&lt;/td&gt;&lt;td&gt;1.5x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch (rewrite=true)&lt;/td&gt;&lt;td&gt;1.4&lt;/td&gt;&lt;td&gt;69,433&lt;/td&gt;&lt;td&gt;1.6x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Multi-value INSERT&lt;/td&gt;&lt;td&gt;1.4&lt;/td&gt;&lt;td&gt;71,185&lt;/td&gt;&lt;td&gt;1.6x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;UNNEST&lt;/td&gt;&lt;td&gt;1.3&lt;/td&gt;&lt;td&gt;74,810&lt;/td&gt;&lt;td&gt;1.5x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;COPY CSV&lt;/td&gt;&lt;td&gt;0.9&lt;/td&gt;&lt;td&gt;110,549&lt;/td&gt;&lt;td&gt;fastest&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;COPY BINARY&lt;/td&gt;&lt;td&gt;2.0&lt;/td&gt;&lt;td&gt;51,234&lt;/td&gt;&lt;td&gt;2.2x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;DuckDB -&gt; PostgreSQL&lt;/td&gt;&lt;td&gt;3.0&lt;/td&gt;&lt;td&gt;33,531&lt;/td&gt;&lt;td&gt;3.3x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Arrow -&gt; DuckDB -&gt; PG&lt;/td&gt;&lt;td&gt;4.2&lt;/td&gt;&lt;td&gt;23,937&lt;/td&gt;&lt;td&gt;4.6x slower&lt;/td&gt;&lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;
&lt;div&gt;
  &lt;h2&gt;1,000 rows&lt;/h2&gt;
  &lt;table&gt;
    &lt;thead&gt;&lt;tr&gt;&lt;th&gt;Method&lt;/th&gt;&lt;th&gt;Time (ms)&lt;/th&gt;&lt;th&gt;Rows/sec&lt;/th&gt;&lt;th&gt;vs Best&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;&lt;td&gt;Individual INSERT&lt;/td&gt;&lt;td&gt;39.0&lt;/td&gt;&lt;td&gt;25,612&lt;/td&gt;&lt;td&gt;9.7x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch INSERT&lt;/td&gt;&lt;td&gt;6.8&lt;/td&gt;&lt;td&gt;147,246&lt;/td&gt;&lt;td&gt;1.7x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch (rewrite=true)&lt;/td&gt;&lt;td&gt;5.1&lt;/td&gt;&lt;td&gt;196,576&lt;/td&gt;&lt;td&gt;1.3x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Multi-value INSERT&lt;/td&gt;&lt;td&gt;7.9&lt;/td&gt;&lt;td&gt;127,238&lt;/td&gt;&lt;td&gt;2.0x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;UNNEST&lt;/td&gt;&lt;td&gt;6.4&lt;/td&gt;&lt;td&gt;155,451&lt;/td&gt;&lt;td&gt;1.6x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;COPY CSV&lt;/td&gt;&lt;td&gt;4.0&lt;/td&gt;&lt;td&gt;249,664&lt;/td&gt;&lt;td&gt;fastest&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;COPY BINARY&lt;/td&gt;&lt;td&gt;4.9&lt;/td&gt;&lt;td&gt;205,923&lt;/td&gt;&lt;td&gt;1.2x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;DuckDB -&gt; PostgreSQL&lt;/td&gt;&lt;td&gt;7.5&lt;/td&gt;&lt;td&gt;133,547&lt;/td&gt;&lt;td&gt;1.9x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Arrow -&gt; DuckDB -&gt; PG&lt;/td&gt;&lt;td&gt;10.9&lt;/td&gt;&lt;td&gt;91,756&lt;/td&gt;&lt;td&gt;2.7x slower&lt;/td&gt;&lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;
&lt;div&gt;
  &lt;h2&gt;10,000 rows&lt;/h2&gt;
  &lt;table&gt;
    &lt;thead&gt;&lt;tr&gt;&lt;th&gt;Method&lt;/th&gt;&lt;th&gt;Time (ms)&lt;/th&gt;&lt;th&gt;Rows/sec&lt;/th&gt;&lt;th&gt;vs Best&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;&lt;td&gt;Individual INSERT&lt;/td&gt;&lt;td&gt;266.0&lt;/td&gt;&lt;td&gt;37,589&lt;/td&gt;&lt;td&gt;16.5x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch INSERT&lt;/td&gt;&lt;td&gt;64.3&lt;/td&gt;&lt;td&gt;155,544&lt;/td&gt;&lt;td&gt;4.0x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch (rewrite=true)&lt;/td&gt;&lt;td&gt;36.1&lt;/td&gt;&lt;td&gt;276,876&lt;/td&gt;&lt;td&gt;2.2x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Multi-value INSERT&lt;/td&gt;&lt;td&gt;39.8&lt;/td&gt;&lt;td&gt;251,090&lt;/td&gt;&lt;td&gt;2.5x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;UNNEST&lt;/td&gt;&lt;td&gt;40.5&lt;/td&gt;&lt;td&gt;247,087&lt;/td&gt;&lt;td&gt;2.5x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;COPY CSV&lt;/td&gt;&lt;td&gt;32.2&lt;/td&gt;&lt;td&gt;310,479&lt;/td&gt;&lt;td&gt;2.0x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;COPY BINARY&lt;/td&gt;&lt;td&gt;16.1&lt;/td&gt;&lt;td&gt;621,596&lt;/td&gt;&lt;td&gt;fastest&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;DuckDB -&gt; PostgreSQL&lt;/td&gt;&lt;td&gt;40.4&lt;/td&gt;&lt;td&gt;247,228&lt;/td&gt;&lt;td&gt;2.5x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Arrow -&gt; DuckDB -&gt; PG&lt;/td&gt;&lt;td&gt;39.3&lt;/td&gt;&lt;td&gt;254,744&lt;/td&gt;&lt;td&gt;2.4x slower&lt;/td&gt;&lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;
&lt;div&gt;
  &lt;h2&gt;100,000 rows&lt;/h2&gt;
  &lt;table&gt;
    &lt;thead&gt;&lt;tr&gt;&lt;th&gt;Method&lt;/th&gt;&lt;th&gt;Time (ms)&lt;/th&gt;&lt;th&gt;Rows/sec&lt;/th&gt;&lt;th&gt;vs Best&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;&lt;td&gt;Individual INSERT&lt;/td&gt;&lt;td&gt;2,650.2&lt;/td&gt;&lt;td&gt;37,733&lt;/td&gt;&lt;td&gt;17.8x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch INSERT&lt;/td&gt;&lt;td&gt;633.3&lt;/td&gt;&lt;td&gt;157,907&lt;/td&gt;&lt;td&gt;4.2x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch (rewrite=true)&lt;/td&gt;&lt;td&gt;375.6&lt;/td&gt;&lt;td&gt;266,216&lt;/td&gt;&lt;td&gt;2.5x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Multi-value INSERT&lt;/td&gt;&lt;td&gt;386.2&lt;/td&gt;&lt;td&gt;258,964&lt;/td&gt;&lt;td&gt;2.6x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;UNNEST&lt;/td&gt;&lt;td&gt;392.0&lt;/td&gt;&lt;td&gt;255,124&lt;/td&gt;&lt;td&gt;2.6x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;COPY CSV&lt;/td&gt;&lt;td&gt;278.0&lt;/td&gt;&lt;td&gt;359,671&lt;/td&gt;&lt;td&gt;1.9x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;COPY BINARY&lt;/td&gt;&lt;td&gt;149.1&lt;/td&gt;&lt;td&gt;670,830&lt;/td&gt;&lt;td&gt;fastest&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;DuckDB -&gt; PostgreSQL&lt;/td&gt;&lt;td&gt;354.2&lt;/td&gt;&lt;td&gt;282,339&lt;/td&gt;&lt;td&gt;2.4x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Arrow -&gt; DuckDB -&gt; PG&lt;/td&gt;&lt;td&gt;290.4&lt;/td&gt;&lt;td&gt;344,363&lt;/td&gt;&lt;td&gt;1.9x slower&lt;/td&gt;&lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;
&lt;div&gt;
  &lt;h2&gt;1,000,000 rows&lt;/h2&gt;
  &lt;table&gt;
    &lt;thead&gt;&lt;tr&gt;&lt;th&gt;Method&lt;/th&gt;&lt;th&gt;Time (ms)&lt;/th&gt;&lt;th&gt;Rows/sec&lt;/th&gt;&lt;th&gt;vs Best&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;&lt;td&gt;Individual INSERT&lt;/td&gt;&lt;td&gt;25,494.3&lt;/td&gt;&lt;td&gt;39,225&lt;/td&gt;&lt;td&gt;18.2x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch INSERT&lt;/td&gt;&lt;td&gt;6,173.2&lt;/td&gt;&lt;td&gt;161,990&lt;/td&gt;&lt;td&gt;4.4x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Batch (rewrite=true)&lt;/td&gt;&lt;td&gt;3,423.6&lt;/td&gt;&lt;td&gt;292,091&lt;/td&gt;&lt;td&gt;2.4x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Multi-value INSERT&lt;/td&gt;&lt;td&gt;3,960.3&lt;/td&gt;&lt;td&gt;252,504&lt;/td&gt;&lt;td&gt;2.8x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;UNNEST&lt;/td&gt;&lt;td&gt;4,124.8&lt;/td&gt;&lt;td&gt;242,437&lt;/td&gt;&lt;td&gt;2.9x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;COPY CSV&lt;/td&gt;&lt;td&gt;2,774.5&lt;/td&gt;&lt;td&gt;360,420&lt;/td&gt;&lt;td&gt;2.0x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;COPY BINARY&lt;/td&gt;&lt;td&gt;1,403.7&lt;/td&gt;&lt;td&gt;712,418&lt;/td&gt;&lt;td&gt;fastest&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;DuckDB -&gt; PostgreSQL&lt;/td&gt;&lt;td&gt;3,274.8&lt;/td&gt;&lt;td&gt;305,362&lt;/td&gt;&lt;td&gt;2.3x slower&lt;/td&gt;&lt;/tr&gt;
      &lt;tr&gt;&lt;td&gt;Arrow -&gt; DuckDB -&gt; PG&lt;/td&gt;&lt;td&gt;2,830.8&lt;/td&gt;&lt;td&gt;353,263&lt;/td&gt;&lt;td&gt;2.0x slower&lt;/td&gt;&lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;/div&gt;


    &lt;div&gt;
      &lt;h3&gt;System Information&lt;/h3&gt;
      &lt;table&gt;
                &lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Java&lt;/td&gt;&lt;td&gt;Java 25 (Temurin 25+36-LTS)&lt;/td&gt;&lt;/tr&gt;
        &lt;tr&gt;&lt;td&gt;OS&lt;/td&gt;&lt;td&gt;Linux 6.8.0-100-generic&lt;/td&gt;&lt;/tr&gt;
        &lt;tr&gt;&lt;td&gt;Arch&lt;/td&gt;&lt;td&gt;amd64&lt;/td&gt;&lt;/tr&gt;
        &lt;tr&gt;&lt;td&gt;CPUs&lt;/td&gt;&lt;td&gt;24&lt;/td&gt;&lt;/tr&gt;
        &lt;tr&gt;&lt;td&gt;Max Memory&lt;/td&gt;&lt;td&gt;7,956 MB&lt;/td&gt;&lt;/tr&gt;
        &lt;tr&gt;&lt;td&gt;PostgreSQL&lt;/td&gt;&lt;td&gt;18 (TestContainers)&lt;/td&gt;&lt;/tr&gt;
        &lt;tr&gt;&lt;td&gt;Warmup Runs&lt;/td&gt;&lt;td&gt;2&lt;/td&gt;&lt;/tr&gt;
        &lt;tr&gt;&lt;td&gt;Measurement Runs&lt;/td&gt;&lt;td&gt;5&lt;/td&gt;&lt;/tr&gt;

      &lt;/tbody&gt;&lt;/table&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;
 &lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;how-they-scale&quot;&gt;How They Scale&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The performance characteristics change with data volume. At small sizes (100-1,000 rows), the overhead of COPY setup makes simpler methods competitive. COPY CSV actually wins below ~5K rows. But once you cross 10K rows, COPY BINARY pulls away and the gap widens linearly.&lt;/p&gt;
&lt;p&gt;At 1M rows, COPY BINARY delivers &lt;strong&gt;712K rows/sec&lt;/strong&gt; — 2x faster than COPY CSV, 2.4x faster than &lt;code dir=&quot;auto&quot;&gt;reWriteBatchedInserts&lt;/code&gt;, and 18x faster than individual INSERTs.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-duckdb-detour-what-we-learned&quot;&gt;The DuckDB Detour: What We Learned&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The DuckDB approach was the most fun to benchmark. DuckDB is known for being heavily optimized, and we were curious whether routing data through its postgres extension could beat a direct COPY. The idea: spin up an in-memory DuckDB instance as a temporary buffer — no files, no persistence, just a fast columnar engine to stage data before pushing it to PostgreSQL.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;DuckDB Appender path (method 8):&lt;/strong&gt; DuckDB’s appender loads 1M rows into an in-memory table in ~1.1s (~935K rows/sec). But then the DuckDB postgres extension takes ~2.3s to push that data to PostgreSQL. Total: 3.3s. The bottleneck is the cross-engine transfer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Arrow path (method 9):&lt;/strong&gt; Building Arrow vectors directly and registering them as a virtual table with DuckDB (zero-copy, no staging table needed) is faster than the appender path (~635ms vs ~1.1s for 1M rows). DuckDB scans the Arrow memory directly. But the copy-to-PG step takes the same ~2.2s. Total: 2.8s.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Key insight:&lt;/strong&gt; DuckDB’s postgres extension already uses COPY BINARY under the hood (we confirmed this by reading the &lt;a href=&quot;https://github.com/duckdb/duckdb-postgres/blob/main/src/storage/postgres_insert.cpp&quot;&gt;source&lt;/a&gt;). So the DuckDB → PG transfer is doing the same thing as PgBulkInsert, but with additional overhead from the cross-engine bridge. Direct PgBulkInsert cuts out the middleman.&lt;/p&gt;
&lt;p&gt;Where DuckDB shines is if your data is already in DuckDB (from a Parquet file, a CSV, a data pipeline). In that case, &lt;code dir=&quot;auto&quot;&gt;ATTACH&lt;/code&gt; + &lt;code dir=&quot;auto&quot;&gt;INSERT INTO&lt;/code&gt; is the most natural path and gives you COPY BINARY for free without any extra libraries. For data that starts in Java, going direct is faster.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;recommendations&quot;&gt;Recommendations&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;for-most-applications-rewritebatchedinsertstrue&quot;&gt;For most applications: &lt;code dir=&quot;auto&quot;&gt;reWriteBatchedInserts=true&lt;/code&gt;&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;If you’re using JDBC (directly or through Spring/Hibernate), just add &lt;code dir=&quot;auto&quot;&gt;?reWriteBatchedInserts=true&lt;/code&gt; to your connection URL. One parameter change, zero code changes, ~2.5x throughput improvement over naive batching. This is the best ROI optimization.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;jdbc:postgresql://host:5432/db?reWriteBatchedInserts=true&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;for-bulk-ingestion-copy-binary-via-pgbulkinsert&quot;&gt;For bulk ingestion: COPY BINARY via PgBulkInsert&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;If you’re ingesting thousands or millions of rows — ETL pipelines, IoT data, analytics ingestion, data migrations — use &lt;a href=&quot;https://github.com/PgBulkInsert/PgBulkInsert&quot;&gt;PgBulkInsert&lt;/a&gt; by Philipp Wagner. It’s a well-maintained Java library that handles the binary wire format, supports all PostgreSQL types (including arrays, UUIDs, JSONB, timestamps with timezone), and delivers ~700K rows/sec.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;for-copy-csv-users&quot;&gt;For COPY CSV users&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;If you’re already using COPY CSV (a common recommendation), know that switching to COPY BINARY roughly doubles your throughput. The difference is server-side parsing: CSV requires text → type conversion for every value, while binary sends data in PostgreSQL’s native format.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;what-about-unnest&quot;&gt;What about UNNEST?&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;UNNEST (&lt;code dir=&quot;auto&quot;&gt;INSERT INTO t SELECT * FROM unnest(...)&lt;/code&gt;) is often recommended as “faster than multi-value INSERT” because it reduces query planning overhead. In our benchmark with 9 columns, it was roughly equivalent to multi-value INSERT at most sizes. The advantage may be more pronounced with wider tables or simpler column types. Its real benefit is avoiding the 65K parameter limit — with 9 columns, multi-value INSERT maxes out at ~7,200 rows per statement.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;how-we-built-this&quot;&gt;How We Built This&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The benchmark was built using &lt;a href=&quot;https://sqg.dev&quot;&gt;SQG&lt;/a&gt;, a SQL-first code generator. SQG reads annotated &lt;code dir=&quot;auto&quot;&gt;.sql&lt;/code&gt; files and generates type-safe database access code for Java, TypeScript, and Python — queries, inserts, COPY BINARY appenders, DuckDB bulk insert appenders, all from a single SQL file.&lt;/p&gt;
&lt;p&gt;In this benchmark, SQG generated the COPY BINARY appender (with PgBulkInsert mapper and row record), the DuckDB appender, the UNNEST insert, the individual INSERT, and the verification queries — all from &lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/java-postgres-benchmark/queries.sql&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;queries.sql&lt;/code&gt;&lt;/a&gt;. The only hand-written insert methods are Batch INSERT and COPY CSV, which use raw JDBC.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;methodology&quot;&gt;Methodology&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;PostgreSQL 18 (alpine) via TestContainers 2.0.4&lt;/li&gt;
&lt;li&gt;Java 25 (Temurin 25+36-LTS)&lt;/li&gt;
&lt;li&gt;DuckDB 1.5.1, Apache Arrow 19.0.0, PgBulkInsert 9.0.0&lt;/li&gt;
&lt;li&gt;Each method: 2 warmup runs + 5 measurement runs, median reported&lt;/li&gt;
&lt;li&gt;Data verified after every insert (row count + first/last row spot checks)&lt;/li&gt;
&lt;li&gt;All methods use a single connection (no parallel streams)&lt;/li&gt;
&lt;li&gt;TestContainers runs PostgreSQL in Docker with default settings (no tuning)&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;try-it-yourself&quot;&gt;Try It Yourself&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;git&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;clone&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;https://github.com/sqg-dev/sqg&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;cd&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;sqg/examples/java-postgres-benchmark&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;just&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;run&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;This generates the code from SQL files, runs the full benchmark, and writes an HTML report with interactive charts.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;credits&quot;&gt;Credits&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/PgBulkInsert/PgBulkInsert&quot;&gt;PgBulkInsert&lt;/a&gt; by Philipp Wagner — the library that makes COPY BINARY accessible from Java&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://duckdb.org/&quot;&gt;DuckDB&lt;/a&gt; and its &lt;a href=&quot;https://github.com/duckdb/duckdb-postgres&quot;&gt;postgres extension&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://testcontainers.com/&quot;&gt;TestContainers&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Have questions or want to challenge the methodology? Open an issue on &lt;a href=&quot;https://github.com/sqg-dev/sqg&quot;&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded></item><item><title>SQG v0.10.0: Java Streams &amp; List Type Support</title><link>https://sqg.dev/blog/java-streams-and-list-types/</link><guid isPermaLink="true">https://sqg.dev/blog/java-streams-and-list-types/</guid><pubDate>Thu, 05 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;SQG is a type-safe SQL code generator — you write &lt;code dir=&quot;auto&quot;&gt;.sql&lt;/code&gt; files with annotated queries, and it generates strongly-typed database access code for TypeScript and Java by introspecting your queries against real database engines at build time.&lt;/p&gt;
&lt;p&gt;Here’s what’s new in v0.10.0.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;java-stream-based-result-methods&quot;&gt;Java: Stream-based result methods&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Generated Java code now includes methods that return &lt;code dir=&quot;auto&quot;&gt;Stream&amp;#x3C;T&gt;&lt;/code&gt; in addition to &lt;code dir=&quot;auto&quot;&gt;List&amp;#x3C;T&gt;&lt;/code&gt;. This gives you lazy evaluation, easier composition with the standard library, and avoids materializing large result sets into memory when you don’t need to.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;try&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;Stream&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;User&lt;/span&gt;&lt;span&gt;&gt; &lt;/span&gt;&lt;span&gt;users&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;queries&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;getAllUsersStream&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;users&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;forEach&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;user &lt;/span&gt;&lt;span&gt;-&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;process&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;user&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The stream holds a reference to the underlying &lt;code dir=&quot;auto&quot;&gt;ResultSet&lt;/code&gt;, so it needs to be closed after use — hence the try-with-resources.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;java-better-arraylist-field-support&quot;&gt;Java: Better array/list field support&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Array columns like &lt;code dir=&quot;auto&quot;&gt;TEXT[]&lt;/code&gt; or &lt;code dir=&quot;auto&quot;&gt;INTEGER[]&lt;/code&gt; are now handled correctly in generated Java code. Previously these types could produce incorrect mappings — they now resolve to proper &lt;code dir=&quot;auto&quot;&gt;List&amp;#x3C;String&gt;&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;List&amp;#x3C;Integer&gt;&lt;/code&gt;, etc.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;duckdb-list-types-in-appender&quot;&gt;DuckDB: List types in Appender&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The DuckDB appender now supports list/array column types. If your table has a column like &lt;code dir=&quot;auto&quot;&gt;tags VARCHAR[]&lt;/code&gt;, the generated appender method accepts the corresponding list type and writes it correctly using DuckDB’s bulk insert API.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Upgrade with &lt;code dir=&quot;auto&quot;&gt;npm install -g @sqg/sqg@0.10.0&lt;/code&gt; or update your project’s dependency. Full source on &lt;a href=&quot;https://github.com/sqg-dev/sqg&quot;&gt;GitHub&lt;/a&gt;. Try it out in the &lt;a href=&quot;https://sqg.dev/playground&quot;&gt;playground&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Discuss this on &lt;a href=&quot;https://news.ycombinator.com/item?id=47265856&quot;&gt;Hackernews&lt;/a&gt;.&lt;/p&gt;</content:encoded></item><item><title>PostgreSQL Support &amp; Built-in Migration Tracking</title><link>https://sqg.dev/blog/postgres-migrations/</link><guid isPermaLink="true">https://sqg.dev/blog/postgres-migrations/</guid><pubDate>Fri, 06 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;SQG v0.8.0 brings two major features: improved PostgreSQL support for Java and a built-in migration runner that tracks which migrations have been applied.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;built-in-migration-tracking&quot;&gt;Built-in Migration Tracking&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Until now, SQG generated a &lt;code dir=&quot;auto&quot;&gt;getMigrations()&lt;/code&gt; method that returned raw SQL strings — you were responsible for tracking which ones had been applied. That meant writing your own migration runner or integrating an external tool.&lt;/p&gt;
&lt;p&gt;With v0.8.0, you can now enable a built-in &lt;code dir=&quot;auto&quot;&gt;applyMigrations()&lt;/code&gt; method:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;sqg.yaml&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;version&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;my-app&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;sql&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;- &lt;/span&gt;&lt;span&gt;files&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;- &lt;/span&gt;&lt;span&gt;queries.sql&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;gen&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;- &lt;/span&gt;&lt;span&gt;generator&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;typescript/sqlite&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;output&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;./src/generated/&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;config&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;          &lt;/span&gt;&lt;span&gt;migrations&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;# enable built-in migration tracking&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Then in your application:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; Database &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;better-sqlite3&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { MyApp } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;./generated/my-app&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;db&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Database&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;app.db&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// One line — creates tracking table, checks what&apos;s applied, runs the rest&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;MyApp&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;applyMigrations&lt;/span&gt;&lt;span&gt;(db);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;queries&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;MyApp&lt;/span&gt;&lt;span&gt;(db);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;how-it-works&quot;&gt;How It Works&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The generated &lt;code dir=&quot;auto&quot;&gt;applyMigrations()&lt;/code&gt; method:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Creates a &lt;code dir=&quot;auto&quot;&gt;_sqg_migrations&lt;/code&gt; table&lt;/strong&gt; if it doesn’t exist&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Checks which migrations&lt;/strong&gt; have already been applied for this project&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Applies new migrations&lt;/strong&gt; in order&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Records each migration&lt;/strong&gt; with a timestamp&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Wraps everything in a transaction&lt;/strong&gt; for safety&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The tracking table uses a composite primary key of &lt;code dir=&quot;auto&quot;&gt;(project, migration_id)&lt;/code&gt;, which means &lt;strong&gt;multiple SQG projects can share the same database&lt;/strong&gt; without conflicts. The project name comes from the &lt;code dir=&quot;auto&quot;&gt;name&lt;/code&gt; field in your &lt;code dir=&quot;auto&quot;&gt;sqg.yaml&lt;/code&gt;.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Default project name from sqg.yaml&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;MyApp&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;applyMigrations&lt;/span&gt;&lt;span&gt;(db);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Override for multi-tenant scenarios&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;MyApp&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;applyMigrations&lt;/span&gt;&lt;span&gt;(db, &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;tenant-123&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;supported-across-all-generators&quot;&gt;Supported Across All Generators&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The migration runner works with every SQG generator:&lt;/p&gt;

































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Generator&lt;/th&gt;&lt;th&gt;Method&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;TypeScript/SQLite (better-sqlite3)&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;MyApp.applyMigrations(db)&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;TypeScript/SQLite (node:sqlite)&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;MyApp.applyMigrations(db)&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;TypeScript/SQLite (libSQL)&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;await MyApp.applyMigrations(client)&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;TypeScript/DuckDB&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;await MyApp.applyMigrations(conn)&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Java/JDBC (any engine)&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;MyApp.applyMigrations(connection)&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Java/DuckDB Arrow&lt;/td&gt;&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Analytics.applyMigrations(connection)&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Each implementation uses the appropriate transaction mechanism for its engine — &lt;code dir=&quot;auto&quot;&gt;BEGIN IMMEDIATE&lt;/code&gt; for SQLite, &lt;code dir=&quot;auto&quot;&gt;setAutoCommit(false)&lt;/code&gt; for JDBC, and so on.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;still-optional&quot;&gt;Still Optional&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The feature is entirely opt-in. Without &lt;code dir=&quot;auto&quot;&gt;config.migrations: true&lt;/code&gt;, SQG generates the same &lt;code dir=&quot;auto&quot;&gt;getMigrations()&lt;/code&gt; method as before. You can continue using external migration tools like Flyway, Liquibase, or your own scripts.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;postgresql-improvements&quot;&gt;PostgreSQL Improvements&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;v0.8.0 significantly improves PostgreSQL support for the &lt;code dir=&quot;auto&quot;&gt;java/postgres&lt;/code&gt; generator.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;user-defined-types-enums&quot;&gt;User-Defined Types (ENUMs)&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;SQG now introspects PostgreSQL’s &lt;code dir=&quot;auto&quot;&gt;pg_type&lt;/code&gt; system catalog to resolve user-defined types. This means ENUMs work out of the box:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;-- MIGRATE 1&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;CREATE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;TYPE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;task_status&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;AS&lt;/span&gt;&lt;span&gt; ENUM (&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;pending&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;active&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;completed&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;cancelled&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;CREATE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;TABLE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;tasks&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;id &lt;/span&gt;&lt;span&gt;SERIAL&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;PRIMARY KEY&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;title &lt;/span&gt;&lt;span&gt;TEXT&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;status&lt;/span&gt;&lt;span&gt; task_status &lt;/span&gt;&lt;span&gt;DEFAULT&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;pending&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;-- QUERY get_tasks_by_status&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;@set &lt;/span&gt;&lt;span&gt;status&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;active&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;SELECT&lt;/span&gt;&lt;span&gt; id, title, &lt;/span&gt;&lt;span&gt;status&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;FROM&lt;/span&gt;&lt;span&gt; tasks &lt;/span&gt;&lt;span&gt;WHERE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;status&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; ${&lt;/span&gt;&lt;span&gt;status&lt;/span&gt;&lt;span&gt;}::task_status;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;SQG resolves the ENUM OID from &lt;code dir=&quot;auto&quot;&gt;pg_type&lt;/code&gt; and generates a &lt;strong&gt;type-safe Java enum class&lt;/strong&gt; with &lt;code dir=&quot;auto&quot;&gt;getValue()&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;fromValue()&lt;/code&gt; methods. Query parameters and results use the generated enum type directly instead of raw strings. See the &lt;a href=&quot;https://sqg.dev/generators/java-jdbc/#user-defined-types-enums&quot;&gt;Java + JDBC PostgreSQL documentation&lt;/a&gt; for details.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;array-types&quot;&gt;Array Types&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;PostgreSQL array columns like &lt;code dir=&quot;auto&quot;&gt;TEXT[]&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;INTEGER[]&lt;/code&gt; are now properly mapped to &lt;code dir=&quot;auto&quot;&gt;List&amp;#x3C;T&gt;&lt;/code&gt; in Java:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;CREATE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;TABLE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;tasks&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;id &lt;/span&gt;&lt;span&gt;SERIAL&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;PRIMARY KEY&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;tags &lt;/span&gt;&lt;span&gt;TEXT&lt;/span&gt;&lt;span&gt;[],&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;priority_scores &lt;/span&gt;&lt;span&gt;INTEGER&lt;/span&gt;&lt;span&gt;[]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Generated record&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;record&lt;/span&gt;&lt;span&gt; GetAllTasksResult&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;Integer&lt;/span&gt;&lt;span&gt; id, &lt;/span&gt;&lt;span&gt;String&lt;/span&gt;&lt;span&gt; title, &lt;/span&gt;&lt;span&gt;List&amp;#x3C;&lt;/span&gt;&lt;span&gt;String&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;span&gt; tags, &lt;/span&gt;&lt;span&gt;List&amp;#x3C;&lt;/span&gt;&lt;span&gt;Integer&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;span&gt; priorityScores&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; {}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;task&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;queries&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;getAllTasks&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;System&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;out&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;println&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;task&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;tags&lt;/span&gt;&lt;span&gt;())&lt;/span&gt;&lt;span&gt;;            &lt;/span&gt;&lt;span&gt;// [urgent, backend]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;System&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;out&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;println&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;task&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;priorityScores&lt;/span&gt;&lt;span&gt;())&lt;/span&gt;&lt;span&gt;;  &lt;/span&gt;&lt;span&gt;// [10, 20, 30]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;timestamptz-support&quot;&gt;TIMESTAMPTZ Support&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;TIMESTAMPTZ&lt;/code&gt; columns are now correctly mapped to &lt;code dir=&quot;auto&quot;&gt;OffsetDateTime&lt;/code&gt; (instead of &lt;code dir=&quot;auto&quot;&gt;LocalDateTime&lt;/code&gt;), preserving timezone information:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// TIMESTAMPTZ -&gt; OffsetDateTime with UTC offset&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;record&lt;/span&gt;&lt;span&gt; EventResult&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;Integer&lt;/span&gt;&lt;span&gt; id, &lt;/span&gt;&lt;span&gt;OffsetDateTime&lt;/span&gt;&lt;span&gt; createdAt&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; {}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;automatic-testcontainers&quot;&gt;Automatic Testcontainers&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;If the &lt;code dir=&quot;auto&quot;&gt;SQG_POSTGRES_URL&lt;/code&gt; environment variable is not set, SQG now automatically starts a PostgreSQL container using &lt;a href=&quot;https://testcontainers.com/&quot;&gt;Testcontainers&lt;/a&gt;. This makes it easy to get started without installing PostgreSQL locally — just have Docker running:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;# No env var needed — SQG starts a container automatically&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;sqg&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;sqg.yaml&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;For CI/CD or production builds, set the environment variable to point to your PostgreSQL server:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;export&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;SQG_POSTGRES_URL&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;postgresql://user:password@localhost:5432/mydb&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;sqg&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;sqg.yaml&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;get-started&quot;&gt;Get Started&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Install or update SQG:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;pnpm&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-g&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;@sqg/sqg@latest&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Enable migration tracking by adding &lt;code dir=&quot;auto&quot;&gt;config.migrations: true&lt;/code&gt; to your generator config. Check out the updated &lt;a href=&quot;https://sqg.dev/guides/sql-syntax/#built-in-migration-runner&quot;&gt;SQL Syntax Reference&lt;/a&gt; and &lt;a href=&quot;https://sqg.dev/generators/java-jdbc/#postgresql&quot;&gt;generator documentation&lt;/a&gt; for full details.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Have questions or feedback? Open an issue on &lt;a href=&quot;https://github.com/sqg-dev/sqg&quot;&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded></item><item><title>SQLite Driver Benchmark: Comparing better-sqlite3, node:sqlite, libSQL,   Turso</title><link>https://sqg.dev/blog/sqlite-driver-benchmark/</link><guid isPermaLink="true">https://sqg.dev/blog/sqlite-driver-benchmark/</guid><pubDate>Mon, 19 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Choosing the right SQLite driver for Node.js can impact your application’s performance.&lt;/p&gt;
&lt;p&gt;We ran performance tests comparing &lt;strong&gt;better-sqlite3&lt;/strong&gt;, &lt;strong&gt;node:sqlite&lt;/strong&gt;, &lt;strong&gt;libSQL&lt;/strong&gt;, and &lt;strong&gt;Turso&lt;/strong&gt; across common database operations. Here’s what we found.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-four-sqlite-options-for-nodejs&quot;&gt;The Four SQLite Options for Node.js&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;better-sqlite3&quot;&gt;better-sqlite3&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;This library has been tested in production for years,
offering excellent performance through a &lt;strong&gt;synchronous API&lt;/strong&gt;. The synchronous API makes it very performant
and easy to use, and is perfect for fast queries.&lt;/p&gt;
&lt;p&gt;→ &lt;a href=&quot;https://github.com/WiseLibs/better-sqlite3&quot;&gt;GitHub better-sqlite3&lt;/a&gt;&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;nodesqlite&quot;&gt;node:sqlite&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Node.js 22+ includes a &lt;strong&gt;built-in SQLite module&lt;/strong&gt; (still experimental).
It provides zero-dependency SQLite access with a synchronous API similar to better-sqlite3,
and also an asynchronous API.&lt;/p&gt;
&lt;p&gt;→ &lt;a href=&quot;https://nodejs.org/api/sqlite.html&quot;&gt;Node.js SQlite Documentation&lt;/a&gt;&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;libsql&quot;&gt;libSQL&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;An open-source fork of SQLite created by the Turso team. It provides an &lt;strong&gt;async API&lt;/strong&gt; and supports both local database files and remote libSQL/Turso servers.
Fully compatible with the original SQLite (file format, API)&lt;/p&gt;
&lt;p&gt;→ &lt;a href=&quot;https://github.com/tursodatabase/libsql-client-ts&quot;&gt;libSQL TypeScript&lt;/a&gt;&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;turso&quot;&gt;Turso&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Turso is a SQLite compatible database written in Rust. Currently still in beta.&lt;/p&gt;
&lt;p&gt;→ &lt;a href=&quot;https://github.com/tursodatabase/turso/tree/main/bindings/javascript&quot;&gt;Turso for Javascript&lt;/a&gt;&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;benchmark-methodology&quot;&gt;Benchmark Methodology&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;We tested all four drivers with identical queries. A simple scenario with a &lt;a href=&quot;https://github.com/sqg-dev/sqg/blob/main/examples/typescript-sqlite-benchmark/queries.sql&quot;&gt;user and posts table&lt;/a&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;10,000 users&lt;/strong&gt; and &lt;strong&gt;500,000 posts&lt;/strong&gt; (50 per user)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Optimized pragma settings&lt;/strong&gt; (WAL mode, 64MB cache, memory-mapped I/O)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Various query patterns&lt;/strong&gt;: simple selects, indexed lookups, JOINs, aggregates, inserts, updates&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Note: better-sqlite3 and node:sqlite use synchronous APIs, while libSQL and Turso use async/await.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;sqlite-configuration&quot;&gt;SQLite Configuration&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;All databases used these optimized settings:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;PRAGMA journal_mode &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; WAL;       &lt;/span&gt;&lt;span&gt;-- Write-Ahead Logging&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;PRAGMA synchronous &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; NORMAL;      &lt;/span&gt;&lt;span&gt;-- Balance safety/speed&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;PRAGMA cache_size &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-&lt;/span&gt;&lt;span&gt;64000&lt;/span&gt;&lt;span&gt;;       &lt;/span&gt;&lt;span&gt;-- 64MB cache&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;PRAGMA temp_store &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; MEMORY;       &lt;/span&gt;&lt;span&gt;-- Temp tables in memory&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;PRAGMA mmap_size &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;268435456&lt;/span&gt;&lt;span&gt;;     &lt;/span&gt;&lt;span&gt;-- 256MB memory-mapped I/O&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;  &lt;div&gt;
  
  &lt;div&gt;
    &lt;h1&gt;SQLite Driver Benchmark Report&lt;/h1&gt;
    &lt;p&gt;Comparing better-sqlite3 vs node:sqlite vs libsql vs turso (baseline: node:sqlite)&lt;/p&gt;

    
    &lt;div&gt;
      &lt;h3&gt;System Information&lt;/h3&gt;
      &lt;table&gt;
        &lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Node.js Version&lt;/td&gt;&lt;td&gt;v25.3.0&lt;/td&gt;&lt;/tr&gt;
        &lt;tr&gt;&lt;td&gt;Platform&lt;/td&gt;&lt;td&gt;linux&lt;/td&gt;&lt;/tr&gt;
        &lt;tr&gt;&lt;td&gt;Architecture&lt;/td&gt;&lt;td&gt;x64&lt;/td&gt;&lt;/tr&gt;
        &lt;tr&gt;&lt;td&gt;CPU&lt;/td&gt;&lt;td&gt;12th Gen Intel(R) Core(TM) i9-12900K&lt;/td&gt;&lt;/tr&gt;
        &lt;tr&gt;&lt;td&gt;CPU Cores&lt;/td&gt;&lt;td&gt;24&lt;/td&gt;&lt;/tr&gt;
        &lt;tr&gt;&lt;td&gt;Total Memory&lt;/td&gt;&lt;td&gt;31.07 GB&lt;/td&gt;&lt;/tr&gt;
      &lt;/tbody&gt;&lt;/table&gt;
    &lt;/div&gt;
  

    &lt;div&gt;
      &lt;h2&gt;Summary&lt;/h2&gt;
      &lt;table&gt;
        &lt;thead&gt;
          &lt;tr&gt;
            &lt;th&gt;Operation&lt;/th&gt;
            &lt;th&gt;better-sqlite3&lt;/th&gt;
            &lt;th&gt;node:sqlite&lt;/th&gt;
            &lt;th&gt;libsql&lt;/th&gt;
            &lt;th&gt;turso&lt;/th&gt;
            &lt;th&gt;Winner&lt;/th&gt;
            &lt;th&gt;vs node:sqlite&lt;/th&gt;
          &lt;/tr&gt;
        &lt;/thead&gt;
        &lt;tbody&gt;
          
    &lt;tr&gt;
      &lt;td&gt;getAllUsers&lt;/td&gt;
      &lt;td&gt;360 ops/s&lt;/td&gt;
          &lt;td&gt;268 ops/s&lt;/td&gt;
          &lt;td&gt;50 ops/s&lt;/td&gt;
          &lt;td&gt;104 ops/s&lt;/td&gt;
      &lt;td&gt;better-sqlite3&lt;/td&gt;
      &lt;td&gt;1.34x&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;getUserById&lt;/td&gt;
      &lt;td&gt;1,223,260 ops/s&lt;/td&gt;
          &lt;td&gt;1,073,001 ops/s&lt;/td&gt;
          &lt;td&gt;61,093 ops/s&lt;/td&gt;
          &lt;td&gt;707,859 ops/s&lt;/td&gt;
      &lt;td&gt;better-sqlite3&lt;/td&gt;
      &lt;td&gt;1.14x&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;getUserByEmail&lt;/td&gt;
      &lt;td&gt;557,631 ops/s&lt;/td&gt;
          &lt;td&gt;457,659 ops/s&lt;/td&gt;
          &lt;td&gt;49,510 ops/s&lt;/td&gt;
          &lt;td&gt;233,913 ops/s&lt;/td&gt;
      &lt;td&gt;better-sqlite3&lt;/td&gt;
      &lt;td&gt;1.22x&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;countUsers (pluck)&lt;/td&gt;
      &lt;td&gt;538,031 ops/s&lt;/td&gt;
          &lt;td&gt;398,431 ops/s&lt;/td&gt;
          &lt;td&gt;108,632 ops/s&lt;/td&gt;
          &lt;td&gt;5,593 ops/s&lt;/td&gt;
      &lt;td&gt;better-sqlite3&lt;/td&gt;
      &lt;td&gt;1.35x&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;getPostsByUser&lt;/td&gt;
      &lt;td&gt;1,090,293 ops/s&lt;/td&gt;
          &lt;td&gt;980,550 ops/s&lt;/td&gt;
          &lt;td&gt;47,304 ops/s&lt;/td&gt;
          &lt;td&gt;414,672 ops/s&lt;/td&gt;
      &lt;td&gt;better-sqlite3&lt;/td&gt;
      &lt;td&gt;1.11x&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;getPublishedPosts (JOIN)&lt;/td&gt;
      &lt;td&gt;27 ops/s&lt;/td&gt;
          &lt;td&gt;27 ops/s&lt;/td&gt;
          &lt;td&gt;54 ops/s&lt;/td&gt;
          &lt;td&gt;7 ops/s&lt;/td&gt;
      &lt;td&gt;libsql&lt;/td&gt;
      &lt;td&gt;1.98x&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;getPostWithAuthor (JOIN :one)&lt;/td&gt;
      &lt;td&gt;477,271 ops/s&lt;/td&gt;
          &lt;td&gt;379,911 ops/s&lt;/td&gt;
          &lt;td&gt;32,433 ops/s&lt;/td&gt;
          &lt;td&gt;236,297 ops/s&lt;/td&gt;
      &lt;td&gt;better-sqlite3&lt;/td&gt;
      &lt;td&gt;1.26x&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;countPostsByUser (pluck)&lt;/td&gt;
      &lt;td&gt;1,151,783 ops/s&lt;/td&gt;
          &lt;td&gt;689,478 ops/s&lt;/td&gt;
          &lt;td&gt;111,824 ops/s&lt;/td&gt;
          &lt;td&gt;377,235 ops/s&lt;/td&gt;
      &lt;td&gt;better-sqlite3&lt;/td&gt;
      &lt;td&gt;1.67x&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;insertUser&lt;/td&gt;
      &lt;td&gt;53,693 ops/s&lt;/td&gt;
          &lt;td&gt;41,291 ops/s&lt;/td&gt;
          &lt;td&gt;28,385 ops/s&lt;/td&gt;
          &lt;td&gt;63,017 ops/s&lt;/td&gt;
      &lt;td&gt;turso&lt;/td&gt;
      &lt;td&gt;1.53x&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;updatePostViews&lt;/td&gt;
      &lt;td&gt;136,399 ops/s&lt;/td&gt;
          &lt;td&gt;97,956 ops/s&lt;/td&gt;
          &lt;td&gt;53,598 ops/s&lt;/td&gt;
          &lt;td&gt;59,273 ops/s&lt;/td&gt;
      &lt;td&gt;better-sqlite3&lt;/td&gt;
      &lt;td&gt;1.39x&lt;/td&gt;
    &lt;/tr&gt;
        &lt;/tbody&gt;
      &lt;/table&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;
 &lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;key-findings&quot;&gt;Key Findings&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;better-sqlite3 is the fastest&lt;/strong&gt; for most operations, with node:sqlite second&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Turso has a surprisingly slow query&lt;/strong&gt; for &lt;code dir=&quot;auto&quot;&gt;countPostsByUser&lt;/code&gt; (better-sqlite3 is almost 100x faster here).
I did not investigate why this is, it might be that the Turso database like many others (eg Postgresql) needs
to scan the full table in order to count the number of rows and has no fast handling for this special case.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;benchmark-implementation&quot;&gt;Benchmark Implementation&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The benchmark is implemented using &lt;a href=&quot;https://sqg.dev&quot;&gt;SQG&lt;/a&gt;, a SQL to code generator.&lt;/p&gt;
&lt;p&gt;One advantage of using a code generator like &lt;a href=&quot;https://sqg.dev&quot;&gt;SQG&lt;/a&gt; is that you can switch between SQLite drivers without rewriting your queries. SQG generates type-safe code for all four drivers from the same SQL file:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;# sqg.yaml - generate code for multiple drivers&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;sql&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;- &lt;/span&gt;&lt;span&gt;files&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;- &lt;/span&gt;&lt;span&gt;queries.sql&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;gen&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;- &lt;/span&gt;&lt;span&gt;generator&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;typescript/sqlite/better-sqlite3&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;output&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;./src/db-better-sqlite3.ts&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;- &lt;/span&gt;&lt;span&gt;generator&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;typescript/sqlite/node&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;output&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;./src/db-node-sqlite.ts&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;- &lt;/span&gt;&lt;span&gt;generator&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;typescript/sqlite/libsql&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;output&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;./src/db-libsql.ts&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;- &lt;/span&gt;&lt;span&gt;generator&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;typescript/sqlite/turso&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;output&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;./src/db-turso.ts&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;This makes it easy to benchmark with your actual queries and switch drivers by changing imports.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;try-it-yourself&quot;&gt;Try It Yourself&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The benchmark code is available in our &lt;a href=&quot;https://github.com/sqg-dev/sqg/tree/main/examples/typescript-sqlite-benchmark&quot;&gt;examples repository&lt;/a&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;git&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;clone&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;https://github.com/sqg-dev/sqg&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;cd&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;sqg/examples/typescript-sqlite-benchmark&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;pnpm&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;install&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;pnpm&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;generate&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;pnpm&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;bench&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;This will generate an HTML report with the results table shown above.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;I think for most applications, &lt;a href=&quot;https://github.com/WiseLibs/better-sqlite3&quot;&gt;better-sqlite3&lt;/a&gt; remains the best choice.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Benchmarks generated using &lt;a href=&quot;https://sqg.dev&quot;&gt;SQG&lt;/a&gt;. Have questions? Open an issue on &lt;a href=&quot;https://github.com/sqg-dev/sqg&quot;&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded></item><item><title>Introducing SQG</title><link>https://sqg.dev/blog/introducing-sqg/</link><guid isPermaLink="true">https://sqg.dev/blog/introducing-sqg/</guid><pubDate>Wed, 17 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;We’re excited to announce &lt;strong&gt;SQG&lt;/strong&gt; (SQL Query Generator), a tool that generates type-safe database access code from your SQL queries.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-problem&quot;&gt;The Problem&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Writing database code means maintaining SQL queries and matching TypeScript/Java types. When schemas change, you update both—and hope nothing breaks. ORMs abstract SQL away, but what if you want to write SQL directly while keeping type safety?&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-solution&quot;&gt;The Solution&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;SQG reads annotated &lt;code dir=&quot;auto&quot;&gt;.sql&lt;/code&gt; files, executes queries against real databases to introspect column types, and generates fully-typed wrapper code.&lt;/p&gt;
&lt;p&gt;Write your SQL with simple annotations:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;-- MIGRATE createUsersTable&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;CREATE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;TABLE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;users&lt;/span&gt;&lt;span&gt; (id &lt;/span&gt;&lt;span&gt;INTEGER&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;PRIMARY KEY&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                    &lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;TEXT&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;email &lt;/span&gt;&lt;span&gt;TEXT&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;-- QUERY getUserById :one&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;@set id &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;SELECT&lt;/span&gt;&lt;span&gt; id, &lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt;, email &lt;/span&gt;&lt;span&gt;FROM&lt;/span&gt;&lt;span&gt; users &lt;/span&gt;&lt;span&gt;WHERE&lt;/span&gt;&lt;span&gt; id &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; ${id};&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;-- QUERY getUsers&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;SELECT&lt;/span&gt;&lt;span&gt; id, &lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt;, email &lt;/span&gt;&lt;span&gt;FROM&lt;/span&gt;&lt;span&gt; users;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;-- EXEC insertUser&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;@set &lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;John&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;@set email &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;john@example.com&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;INSERT INTO&lt;/span&gt;&lt;span&gt; users (&lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt;, email) &lt;/span&gt;&lt;span&gt;VALUES&lt;/span&gt;&lt;span&gt; (${&lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt;}, ${email});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;SQG generates type-safe code:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;export&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Queries&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;getUserById&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;number&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; { id&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;number&lt;/span&gt;&lt;span&gt;; name&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;; email&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;|&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt; } &lt;/span&gt;&lt;span&gt;|&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;undefined&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;getUsers&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; { id&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;number&lt;/span&gt;&lt;span&gt;; name&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;; email&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;|&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt; }[]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;insertUser&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;email&lt;/span&gt;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;RunResult&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;key-features&quot;&gt;Key Features&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Type-safe by design&lt;/strong&gt; - Column types inferred from your actual database&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Multiple databases&lt;/strong&gt; - SQLite, DuckDB, and PostgreSQL&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Multiple languages&lt;/strong&gt; - Generate TypeScript or Java from the same SQL&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DBeaver compatible&lt;/strong&gt; - Develop queries in DBeaver, generate code from the same file&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Zero runtime overhead&lt;/strong&gt; - Generated code is plain function calls&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;get-started&quot;&gt;Get Started&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Install SQG:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;pnpm&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-g&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;@sqg/sqg&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;pnpm&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;approve-builds&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-g&lt;/span&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;# needed for sqlite dependency&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Create a &lt;code dir=&quot;auto&quot;&gt;sqg.yaml&lt;/code&gt; config and your SQL file, then run &lt;code dir=&quot;auto&quot;&gt;sqg sqg.yaml&lt;/code&gt;. Check out the &lt;a href=&quot;https://sqg.dev/guides/getting-started/&quot;&gt;Getting Started guide&lt;/a&gt; for details.&lt;/p&gt;
&lt;p&gt;We’d love your feedback! File issues and feature requests on &lt;a href=&quot;https://github.com/sqg-dev/sqg&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;</content:encoded></item></channel></rss>