node:sqlite vs better-sqlite3: When to Switch

Node ships a SQLite driver in core now. node:sqlite handles ordinary reads, writes and prepared statements exactly as better-sqlite3 does, with no native build step and no extra dependency. It is still a release candidate, and it has no .transaction() helper. That pair of gaps decides most migrations.

The module landed in Node v22.5.0 behind --experimental-sqlite. The flag went away in v22.13.0 and v23.4.0. Since v24.15.0 the docs mark it Stability: 1.2 - Release candidate, which the Node documentation defines as “hopefully ready to become stable. No further breaking changes are anticipated but may still occur in response to user feedback”. Not a promise. Close to one.

What you get without installing anything

The SQLite C library is compiled into the Node binary. There is no node-gyp step, no prebuilt-binary lookup, nothing to rebuild when you move from x64 to arm64 or bump your Node major. The module is only importable under the node: scheme.

import { DatabaseSync } from 'node:sqlite';

const db = new DatabaseSync('app.db');

db.exec(`
  CREATE TABLE IF NOT EXISTS invoices (
    id INTEGER PRIMARY KEY,
    customer TEXT NOT NULL,
    pence INTEGER NOT NULL
  ) STRICT
`);

const insert = db.prepare(
  'INSERT INTO invoices (customer, pence) VALUES (?, ?)',
);
const { lastInsertRowid } = insert.run('Acme Ltd', 12500);

const row = db
  .prepare('SELECT customer, pence FROM invoices WHERE id = ?')
  .get(lastInsertRowid);

console.log(row); // { customer: 'Acme Ltd', pence: 12500 }

run() returns { changes, lastInsertRowid }, the same shape better-sqlite3 gives you. get() returns a row object or undefined. all() returns an array. iterate() returns an iterator. The design was borrowed openly, so the muscle memory transfers.

The surface has grown fast. User-defined functions arrived in v23.5.0, database.aggregate() and database.location() in v24.0.0, an async sqlite.backup() in v23.8.0, session and changeset support in v23.3.0, serialize() and deserialize() in v26.1.0. DatabaseSync and StatementSync both implement Symbol.dispose, so using db = new DatabaseSync(...) closes the handle for you.

node:sqlite vs better-sqlite3: what changes in your code

Less than you would guess, and the differences cluster in three places.

Constructor options were renamed. better-sqlite3’s readonly is readOnly. Its timeout default of 5000ms becomes 0. fileMustExist and verbose have no equivalent at all, so a logging wrapper built on verbose has to be rewritten as a wrapper around prepare().

Statement modifiers are thinner. .raw() maps to statement.setReturnArrays(true) or the returnArrays constructor option. .safeIntegers() maps to statement.setReadBigInts(true). .pluck() and .expand() have no counterpart, so code that leans on either needs the destructuring written out by hand.

Then the one that actually costs time: there is no .transaction(). better-sqlite3 gives you a wrapper that begins a transaction on call, commits on return and rolls back on throw, and turns nested calls into savepoints. node:sqlite gives you exec() and a read-only database.isTransaction. You write the wrapper.

import { DatabaseSync } from 'node:sqlite';

export function transaction<T>(db: DatabaseSync, fn: () => T): T {
  db.exec('BEGIN');
  try {
    const result = fn();
    db.exec('COMMIT');
    return result;
  } catch (error) {
    if (db.isTransaction) db.exec('ROLLBACK');
    throw error;
  }
}

That covers the flat case. Savepoint nesting, and the deferred / immediate / exclusive variants better-sqlite3 exposes, are yours to add. Worth reading the better-sqlite3 API docs on this before you port anything, because they carry a warning that applies just as hard to the version above: “async functions always return after the first await, which means the transaction will already be committed before any async code executes”. Keep the callback synchronous.

Which defaults will catch you out

Two, and they pull in opposite directions.

timeout defaults to 0. better-sqlite3 defaults to 5000ms. A second writer on the same file that used to wait politely now throws SQLITE_BUSY: database is locked on the first contended write. Set it explicitly.

enableForeignKeyConstraints defaults to true. SQLite’s own default is off: the foreign key documentation states that constraints “are disabled by default (for backwards compatibility), so must be enabled separately for each database connection”. better-sqlite3 exposes no such constructor option, so it hands you SQLite’s default and you turn enforcement on with a pragma. node:sqlite is therefore the stricter of the two out of the box, and a schema carrying orphaned rows will start failing with FOREIGN KEY constraint failed. Better to find that in a migration than in production, but find it deliberately.

There is also no .pragma() method. Pragmas go through exec(), which means enabling WAL is a string rather than a call.

const db = new DatabaseSync('app.db', {
  timeout: 5_000,
  enableForeignKeyConstraints: true,
});

db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA synchronous = NORMAL');

defensive also defaults to true from v24.14.0, which blocks writes to shadow tables. Fine for application code, occasionally awkward for tooling that pokes at FTS5 internals.

Where the built-in module cannot go

Nowhere that is not Node. Cloudflare Workers, Vercel’s edge runtime and the other V8-isolate platforms have no node:sqlite, because they have no local filesystem to put the file on. If your deployment story is edge-first, this comparison is moot and you want D1 or Turso instead. We wrote up that split in more detail in our comparison of Cloudflare Pages and Workers.

Both drivers are also fully synchronous. Every query blocks the event loop for its duration. That is a deliberate choice in better-sqlite3 and inherited by node:sqlite, and for the single-digit-millisecond queries a local SQLite file usually serves, it beats the overhead of a thread pool. It stops being fine the moment someone runs an unindexed scan over a million rows on the request path.

What I would do

New service, Node 24 or later, plain CRUD against a local file: use node:sqlite. Dropping a native addon buys you a faster npm ci, one less thing to rebuild in Docker, and one less package that can break on a Node major. The same argument that makes Node’s built-in test runner worth a look applies here.

Existing service on better-sqlite3: leave it. Version 13.0.3 shipped on 5 August 2026, it requires Node 22 or later, and it is not going anywhere. Rewriting working transaction code to reach parity with what you already have is not a good trade.

Stay on better-sqlite3 deliberately if you need savepoint-nested transactions, .pluck(), verbose logging, or a library above you that expects a better-sqlite3 handle. Drizzle now documents a node:sqlite connection, but check whatever query builder or migration tool sits above your driver rather than assuming it followed.

And if you are still on Node 22, note that it is in maintenance and node:sqlite there is missing everything backported after v22.16.0. Pin the feature list you actually use against the version history in the docs before you commit to it. We do this kind of database and schema work often enough to say that the boring answer, one file, WAL on, foreign keys on, is right more often than people expect.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch