Skip to content

Blog

PostgreSQL Support & Built-in Migration Tracking

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.

Until now, SQG generated a getMigrations() 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.

With v0.8.0, you can now enable a built-in applyMigrations() method:

sqg.yaml
version: 1
name: my-app
sql:
- files:
- queries.sql
gen:
- generator: typescript/sqlite
output: ./src/generated/
config:
migrations: true # enable built-in migration tracking

Then in your application:

import Database from 'better-sqlite3';
import { MyApp } from './generated/my-app';
const db = new Database('app.db');
// One line — creates tracking table, checks what's applied, runs the rest
MyApp.applyMigrations(db);
const queries = new MyApp(db);

The generated applyMigrations() method:

  1. Creates a _sqg_migrations table if it doesn’t exist
  2. Checks which migrations have already been applied for this project
  3. Applies new migrations in order
  4. Records each migration with a timestamp
  5. Wraps everything in a transaction for safety

The tracking table uses a composite primary key of (project, migration_id), which means multiple SQG projects can share the same database without conflicts. The project name comes from the name field in your sqg.yaml.

// Default project name from sqg.yaml
MyApp.applyMigrations(db);
// Override for multi-tenant scenarios
MyApp.applyMigrations(db, 'tenant-123');

The migration runner works with every SQG generator:

GeneratorMethod
TypeScript/SQLite (better-sqlite3)MyApp.applyMigrations(db)
TypeScript/SQLite (node:sqlite)MyApp.applyMigrations(db)
TypeScript/SQLite (libSQL)await MyApp.applyMigrations(client)
TypeScript/DuckDBawait MyApp.applyMigrations(conn)
Java/JDBC (any engine)MyApp.applyMigrations(connection)
Java/DuckDB ArrowAnalytics.applyMigrations(connection)

Each implementation uses the appropriate transaction mechanism for its engine — BEGIN IMMEDIATE for SQLite, setAutoCommit(false) for JDBC, and so on.

The feature is entirely opt-in. Without config.migrations: true, SQG generates the same getMigrations() method as before. You can continue using external migration tools like Flyway, Liquibase, or your own scripts.

v0.8.0 significantly improves PostgreSQL support for the java/postgres generator.

SQG now introspects PostgreSQL’s pg_type system catalog to resolve user-defined types. This means ENUMs work out of the box:

-- MIGRATE 1
CREATE TYPE task_status AS ENUM ('pending', 'active', 'completed', 'cancelled');
CREATE TABLE tasks (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
status task_status DEFAULT 'pending'
);
-- QUERY get_tasks_by_status
@set status = 'active'
SELECT id, title, status FROM tasks WHERE status = ${status}::task_status;

SQG resolves the ENUM OID from pg_type and maps it to String in the generated Java code.

PostgreSQL array columns like TEXT[] and INTEGER[] are now properly mapped to List<T> in Java:

CREATE TABLE tasks (
id SERIAL PRIMARY KEY,
tags TEXT[],
priority_scores INTEGER[]
);
// Generated record
record GetAllTasksResult(Integer id, String title, List<String> tags, List<Integer> priorityScores) {}
for (var task : queries.getAllTasks()) {
System.out.println(task.tags()); // [urgent, backend]
System.out.println(task.priorityScores()); // [10, 20, 30]
}

TIMESTAMPTZ columns are now correctly mapped to OffsetDateTime (instead of LocalDateTime), preserving timezone information:

// TIMESTAMPTZ -> OffsetDateTime with UTC offset
record EventResult(Integer id, OffsetDateTime createdAt) {}

If the SQG_POSTGRES_URL environment variable is not set, SQG now automatically starts a PostgreSQL container using Testcontainers. This makes it easy to get started without installing PostgreSQL locally — just have Docker running:

Terminal window
# No env var needed — SQG starts a container automatically
sqg sqg.yaml

For CI/CD or production builds, set the environment variable to point to your PostgreSQL server:

Terminal window
export SQG_POSTGRES_URL="postgresql://user:password@localhost:5432/mydb"
sqg sqg.yaml

Install or update SQG:

Terminal window
pnpm add -g @sqg/sqg@latest

Enable migration tracking by adding config.migrations: true to your generator config. Check out the updated SQL Syntax Reference and generator documentation for full details.


Have questions or feedback? Open an issue on GitHub.

SQLite Driver Benchmark: Comparing better-sqlite3, node:sqlite, libSQL, Turso

Choosing the right SQLite driver for Node.js can impact your application’s performance.

We ran performance tests comparing better-sqlite3, node:sqlite, libSQL, and Turso across common database operations. Here’s what we found.

This library has been tested in production for years, offering excellent performance through a synchronous API. The synchronous API makes it very performant and easy to use, and is perfect for fast queries.

GitHub better-sqlite3

Node.js 22+ includes a built-in SQLite module (still experimental). It provides zero-dependency SQLite access with a synchronous API similar to better-sqlite3, and also an asynchronous API.

Node.js SQlite Documentation

An open-source fork of SQLite created by the Turso team. It provides an async API and supports both local database files and remote libSQL/Turso servers. Fully compatible with the original SQLite (file format, API)

libSQL TypeScript

Turso is a SQLite compatible database written in Rust. Currently still in beta.

Turso for Javascript

We tested all four drivers with identical queries. A simple scenario with a user and posts table.

  • 10,000 users and 500,000 posts (50 per user)
  • Optimized pragma settings (WAL mode, 64MB cache, memory-mapped I/O)
  • Various query patterns: simple selects, indexed lookups, JOINs, aggregates, inserts, updates

Note: better-sqlite3 and node:sqlite use synchronous APIs, while libSQL and Turso use async/await.

All databases used these optimized settings:

PRAGMA journal_mode = WAL; -- Write-Ahead Logging
PRAGMA synchronous = NORMAL; -- Balance safety/speed
PRAGMA cache_size = -64000; -- 64MB cache
PRAGMA temp_store = MEMORY; -- Temp tables in memory
PRAGMA mmap_size = 268435456; -- 256MB memory-mapped I/O

SQLite Driver Benchmark Report

Comparing better-sqlite3 vs node:sqlite vs libsql vs turso (baseline: node:sqlite)

System Information

Node.js Versionv25.3.0
Platformlinux
Architecturex64
CPU12th Gen Intel(R) Core(TM) i9-12900K
CPU Cores24
Total Memory31.07 GB

Summary

Operation better-sqlite3 node:sqlite libsql turso Winner vs node:sqlite
getAllUsers 360 ops/s 268 ops/s 50 ops/s 104 ops/s better-sqlite3 1.34x
getUserById 1,223,260 ops/s 1,073,001 ops/s 61,093 ops/s 707,859 ops/s better-sqlite3 1.14x
getUserByEmail 557,631 ops/s 457,659 ops/s 49,510 ops/s 233,913 ops/s better-sqlite3 1.22x
countUsers (pluck) 538,031 ops/s 398,431 ops/s 108,632 ops/s 5,593 ops/s better-sqlite3 1.35x
getPostsByUser 1,090,293 ops/s 980,550 ops/s 47,304 ops/s 414,672 ops/s better-sqlite3 1.11x
getPublishedPosts (JOIN) 27 ops/s 27 ops/s 54 ops/s 7 ops/s libsql 1.98x
getPostWithAuthor (JOIN :one) 477,271 ops/s 379,911 ops/s 32,433 ops/s 236,297 ops/s better-sqlite3 1.26x
countPostsByUser (pluck) 1,151,783 ops/s 689,478 ops/s 111,824 ops/s 377,235 ops/s better-sqlite3 1.67x
insertUser 53,693 ops/s 41,291 ops/s 28,385 ops/s 63,017 ops/s turso 1.53x
updatePostViews 136,399 ops/s 97,956 ops/s 53,598 ops/s 59,273 ops/s better-sqlite3 1.39x
  • better-sqlite3 is the fastest for most operations, with node:sqlite second
  • Turso has a surprisingly slow query for countPostsByUser (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.

The benchmark is implemented using SQG, a SQL to code generator.

One advantage of using a code generator like SQG 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:

# sqg.yaml - generate code for multiple drivers
sql:
- files:
- queries.sql
gen:
- generator: typescript/sqlite/better-sqlite3
output: ./src/db-better-sqlite3.ts
- generator: typescript/sqlite/node
output: ./src/db-node-sqlite.ts
- generator: typescript/sqlite/libsql
output: ./src/db-libsql.ts
- generator: typescript/sqlite/turso
output: ./src/db-turso.ts

This makes it easy to benchmark with your actual queries and switch drivers by changing imports.

The benchmark code is available in our examples repository:

Terminal window
git clone https://github.com/sqg-dev/sqg
cd sqg/examples/typescript-sqlite-benchmark
pnpm install
pnpm generate
pnpm bench

This will generate an HTML report with the results table shown above.

I think for most applications, better-sqlite3 remains the best choice.


Benchmarks generated using SQG. Have questions? Open an issue on GitHub.

Introducing SQG

We’re excited to announce SQG (SQL Query Generator), a tool that generates type-safe database access code from your SQL queries.

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?

SQG reads annotated .sql files, executes queries against real databases to introspect column types, and generates fully-typed wrapper code.

Write your SQL with simple annotations:

-- MIGRATE createUsersTable
CREATE TABLE users (id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT);
-- QUERY getUserById :one
@set id = 1
SELECT id, name, email FROM users WHERE id = ${id};
-- QUERY getUsers
SELECT id, name, email FROM users;
-- EXEC insertUser
@set name = 'John'
@set email = '[email protected]'
INSERT INTO users (name, email) VALUES (${name}, ${email});

SQG generates type-safe code:

export class Queries {
getUserById(id: number): { id: number; name: string; email: string | null } | undefined
getUsers(): { id: number; name: string; email: string | null }[]
insertUser(name: string, email: string): RunResult
}
  • Type-safe by design - Column types inferred from your actual database
  • Multiple databases - SQLite, DuckDB, and PostgreSQL
  • Multiple languages - Generate TypeScript or Java from the same SQL
  • DBeaver compatible - Develop queries in DBeaver, generate code from the same file
  • Zero runtime overhead - Generated code is plain function calls

Install SQG:

Terminal window
pnpm add -g @sqg/sqg
pnpm approve-builds -g # needed for sqlite dependency

Create a sqg.yaml config and your SQL file, then run sqg sqg.yaml. Check out the Getting Started guide for details.

We’d love your feedback! File issues and feature requests on GitHub.