<?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>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>