Node.js
SQLite
node:sqlite
better-sqlite3
TypeScript
Worker Threads

Node SQLite: the built-in node:sqlite module in Node 26

Node's built-in node:sqlite in TypeScript: the v26.11 Database rename, the transaction helper it lacks, the sync trade-off, and when better-sqlite3 wins.

12 min read
Chamikara Nayanajith

Node.js ships SQLite as a built-in module, so you can use it without installing anything from npm: import { Database } from 'node:sqlite', open a file, and call prepare() with run(), get() or all(). On Node 22.13 and 24 the class is still called DatabaseSync. Node 26.11 renamed it to Database. Every call is synchronous, there is no transaction helper, and the module's official status is release candidate, not stable.

That rename landed on 7 October 2026, three weeks before Node 26 becomes the active LTS line on 28 October, according to the Node.js release schedule. Most tutorials you will find still use DatabaseSync, and plenty still tell you to pass --experimental-sqlite, a flag Node has not needed since 22.13.0. Everything below was run on Node 26.11.1 (bundling SQLite 3.53.4) and Node 24.16.0, on a Windows laptop with an Intel i5-8250U and a SATA SSD. The output blocks are copied from those runs, not typed by hand.

A complete node:sqlite example

This is the whole API you need for most work: exec() for statements with no parameters, prepare() for everything else, and three ways to run a prepared statement. It runs as a .ts file on Node 26 with no build step, because type stripping handles the file and the code has no types to strip.

typescript
import { Database } from 'node:sqlite';

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

db.exec(`
  CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY,
    email TEXT NOT NULL UNIQUE,
    created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
  )
`);

const insert = db.prepare('INSERT INTO users (email) VALUES (?)');
console.log(insert.run('ada@example.com'));
console.log(insert.run('linus@example.com'));

const byId = db.prepare('SELECT * FROM users WHERE id = ?');
console.log(byId.get(1));
console.log(byId.get(99));

const byEmail = db.prepare('SELECT id FROM users WHERE email = :email');
console.log(byEmail.get({ email: 'linus@example.com' }));

console.log(db.prepare('SELECT id, email FROM users').all());
db.close();
text
{ changes: 1, lastInsertRowid: 1 }
{ changes: 1, lastInsertRowid: 2 }
[Object: null prototype] {
  id: 1,
  email: 'ada@example.com',
  created_at: '2026-10-11 04:21:03'
}
undefined
[Object: null prototype] { id: 2 }
[
  [Object: null prototype] { id: 1, email: 'ada@example.com' },
  [Object: null prototype] { id: 2, email: 'linus@example.com' }
]

A few things in that output are worth knowing before you build on it:

  • run() returns { changes, lastInsertRowid }, which is what you need after an INSERT or UPDATE.
  • get() returns undefined when no row matches, not null and not an error.
  • Rows are objects with a null prototype. They serialize to JSON normally, but row.hasOwnProperty('id') throws because the method does not exist. Use Object.hasOwn(row, 'id').
  • Named parameters work without the prefix in the object: :email in SQL, email in JavaScript. That is the allowBareNamedParameters option, on by default.

Run the script a second time and the first insert fails with UNIQUE constraint failed: users.email. The thrown error carries code: 'ERR_SQLITE_ERROR', plus errcode: 2067 (SQLite's extended result code, SQLITE_CONSTRAINT_UNIQUE) and errstr: 'constraint failed', the generic text for that code. Branch on errcode when you need to turn a duplicate into a 409 rather than a 500.

DatabaseSync is deprecated: moving to Database in Node 26.11

Node v26.11.0 renamed DatabaseSync to Database and StatementSync to Statement. The old names are aliases now, deprecated as DEP0210 and DEP0211. Both are documentation-only deprecations, so nothing changes at runtime. On 26.11.1, sqlite.Database === sqlite.DatabaseSync is true, and code using the old name prints no warning, even with --pending-deprecation.

The breakage goes the other way. Import the new name on Node 24:

text
import { Database } from 'node:sqlite';
         ^^^^^^^^
SyntaxError: The requested module 'node:sqlite' does not provide an export named 'Database'

Node 24 enters maintenance on 20 October and stays supported until April 2028, so you will have both versions in the wild for a while. If you want code that runs on both and already uses the new name, so nothing changes if DEP0210 later becomes a runtime warning, pick the constructor at runtime:

typescript
import * as sqlite from 'node:sqlite';

// Node 26.11+ exports Database; Node 22.13 to 26.10 only DatabaseSync.
const { Database = sqlite.DatabaseSync } = sqlite as {
  Database?: typeof sqlite.DatabaseSync;
};

For an application that only runs on Node 26, use Database. For a library that supports 22 and 24, plain DatabaseSync is simpler and works everywhere today; the alias costs nothing and is not scheduled for removal.

How do you type node:sqlite rows in TypeScript?

You narrow them yourself. @types/node types every row as Record<string, SQLOutputValue>, where SQLOutputValue is null | number | bigint | string | Uint8Array, because the driver cannot know your schema. A type guard or a schema validator turns that into your row type at the boundary, once.

There is a second problem first. As of 11 October 2026, the newest @types/node, 26.6.5, does not know about the rename, so TypeScript 7.0.2 rejects code that Node 26.11 runs fine:

text
error TS2305: Module '"node:sqlite"' has no exported member 'Database'.

Until the types catch up, a short declaration file closes the gap. Put it anywhere your tsconfig.json includes:

typescript
// sqlite-26.d.ts: delete once @types/node exports Database
declare module 'node:sqlite' {
  const Database: typeof DatabaseSync;
  type Database = DatabaseSync;
  const Statement: typeof StatementSync;
  type Statement = StatementSync;
}

Then the rows. Reading a column straight off a result fails twice, once for null and once for the number in the union:

text
error TS18047: 'row.email' is possibly 'null'.
error TS2339: Property 'toUpperCase' does not exist on type 'string | number | bigint | NonSharedUint8Array'.

Casting with as User makes the error go away and lies to the compiler the moment a column is renamed or a SELECT leaves one out. A type guard keeps the check honest:

typescript
import { Database, type SQLOutputValue } from 'node:sqlite';

type User = { id: number; email: string; created_at: string };

function isUser(row: Record<string, SQLOutputValue> | undefined): row is User {
  return (
    row !== undefined &&
    typeof row.id === 'number' &&
    typeof row.email === 'string' &&
    typeof row.created_at === 'string'
  );
}

const db = new Database('app.db');
const row = db.prepare('SELECT * FROM users WHERE id = ?').get(1);
if (!isUser(row)) throw new Error('unexpected row shape');

console.log(row.email.toUpperCase()); // row is User here

For more than a couple of tables, hand-written guards get tedious. A schema library does the same job and gives you better error messages; the approach in validating API input with Zod works unchanged on database rows, since a row is just another value arriving from outside your type system.

How do you run a transaction in node:sqlite?

With plain SQL: exec('BEGIN'), your statements, then exec('COMMIT'), or exec('ROLLBACK') if anything throws. The module has no transaction() method. The only related API is the read-only db.isTransaction property, so wrapping the pattern in a helper is your job.

Here is why you want one. A transfer between two accounts, with a CHECK (balance >= 0) constraint, and no transaction:

typescript
import { Database } from 'node:sqlite';

const db = new Database(':memory:');
db.exec(`CREATE TABLE accounts (
  id INTEGER PRIMARY KEY,
  owner TEXT NOT NULL,
  balance INTEGER NOT NULL CHECK (balance >= 0)
)`);
db.exec(`INSERT INTO accounts (owner, balance) VALUES ('ada', 70), ('linus', 80)`);

const debit = db.prepare('UPDATE accounts SET balance = balance - ? WHERE id = ?');
const credit = db.prepare('UPDATE accounts SET balance = balance + ? WHERE id = ?');

try {
  credit.run(500, 2); // linus +500
  debit.run(500, 1); // ada -500, but ada only has 70
} catch (err) {
  console.log((err as Error).message);
}
console.log(db.prepare('SELECT owner, balance FROM accounts').all());
text
CHECK constraint failed: balance >= 0
[
  [Object: null prototype] { owner: 'ada', balance: 70 },
  [Object: null prototype] { owner: 'linus', balance: 580 }
]

The helper below commits when the callback returns, rolls back when it throws, and turns nested calls into savepoints, so an inner failure only undoes the inner work. That is the same contract better-sqlite3's transaction() offers, except that it always starts with BEGIN IMMEDIATE, which better-sqlite3 reserves for .immediate().

typescript
import type { Database } from 'node:sqlite';

let savepointId = 0;

export function transaction<T>(db: Database, fn: () => T): T {
  const nested = db.isTransaction;
  const savepoint = `sp_${++savepointId}`;
  db.exec(nested ? `SAVEPOINT ${savepoint}` : 'BEGIN IMMEDIATE');
  try {
    const result = fn();
    if (result instanceof Promise) {
      throw new TypeError('transaction() callbacks must be synchronous');
    }
    db.exec(nested ? `RELEASE ${savepoint}` : 'COMMIT');
    return result;
  } catch (err) {
    if (nested) {
      db.exec(`ROLLBACK TO ${savepoint}`);
      db.exec(`RELEASE ${savepoint}`);
    } else if (db.isTransaction) {
      db.exec('ROLLBACK');
    }
    throw err;
  }
}

BEGIN IMMEDIATE takes the write lock up front instead of on the first write, which avoids a class of deadlock-style SQLITE_BUSY errors when two connections both start by reading. With the transfer wrapped in transaction(db, () => { ... }), the same failing call leaves both balances where they were:

text
failed: CHECK constraint failed: balance >= 0 ERR_SQLITE_ERROR 275
after 500: [
  [Object: null prototype] { owner: 'ada', balance: 70 },
  [Object: null prototype] { owner: 'linus', balance: 80 }
]
isTransaction: false

Why the helper refuses async callbacks

A transaction belongs to the connection, not to the request that opened it. If you await inside one, every other request sharing that connection runs inside your transaction until you commit. Two concurrent handlers that each do BEGIN, insert, await a 10 ms call, then COMMIT produce this on the second request:

text
fulfilled
rejected cannot start a transaction within a transaction

That is the lucky version, because BEGIN sat outside the try. Move it inside, as many hand-rolled helpers do, and the second request's catch sees isTransaction is true and rolls back the first request's insert. Then the first request's COMMIT finds nothing to commit:

text
rejected cannot commit - no transaction is active
rejected cannot start a transaction within a transaction
[]

Both requests fail and the table is empty. Do the network call before the transaction, then write everything in one synchronous block.

Transactions are also the speed setting

By default each statement outside a transaction is its own commit, and each commit waits for the disk. 1,000 single-row inserts into a file database, measured twice:

Setup1,000 inserts
Default journal, no transaction6,799 ms and 6,895 ms
Default journal, one transaction9.5 ms and 6.1 ms
WAL with synchronous=NORMAL, no transaction39 ms and 45 ms
WAL with synchronous=NORMAL, one transaction1.0 ms

Seven seconds against under ten milliseconds is the difference between a seed script you run without thinking and one you start to dread. Batch writes go in a transaction, always.

node:sqlite is synchronous: what that does to an HTTP server

The Node docs say it plainly: "All APIs exposed by this class execute synchronously." While a query runs, your process does nothing else. For a primary-key lookup that is irrelevant: 100,000 get() calls took about 560 ms, roughly 5.6 microseconds each. A table scan is a different matter.

To see how different, I seeded 2 million rows (an 87 MB file) and served a report next to a health check. The report filters on a substring of a JSON column, so no index can help and SQLite reads every row:

typescript
// report-query.ts
// Table: events (id INTEGER PRIMARY KEY, kind TEXT, payload TEXT, created_at INTEGER)
// Seeded with 2,000,000 rows, payload like {"user":4242,"n":17}
export const REPORT_SQL = `
  SELECT kind, count(*) AS n, sum(length(payload)) AS bytes
  FROM events
  WHERE payload LIKE '%"user":42,%'
  GROUP BY kind`;
typescript
import { createServer } from 'node:http';
import { Database } from 'node:sqlite';
import { REPORT_SQL } from './report-query.ts';

const db = new Database('app.db');
const report = db.prepare(REPORT_SQL);

createServer((req, res) => {
  if (req.url === '/report') {
    res.end(JSON.stringify(report.all())); // blocks every other request
  } else {
    res.end('ok');
  }
}).listen(3000);

A second process requested /report once and /health every 25 ms while it ran. Across three runs the report took 515 to 522 ms, and the worst health check took 464 to 475 ms. A one-line endpoint that should answer in a millisecond waited half a second, because the event loop was inside SQLite.

The fix is to run slow queries on a worker thread with its own connection. SQLite handles multiple connections to one file, and WAL mode lets readers proceed while a writer works:

typescript
// report-worker.ts
import { parentPort } from 'node:worker_threads';
import { Database } from 'node:sqlite';
import { REPORT_SQL } from './report-query.ts';

const db = new Database('app.db', { readOnly: true });
const report = db.prepare(REPORT_SQL);

parentPort!.on('message', (id: number) => {
  parentPort!.postMessage({ id, rows: report.all() });
});
typescript
// server.ts
import { createServer } from 'node:http';
import { Worker } from 'node:worker_threads';

const worker = new Worker(new URL('./report-worker.ts', import.meta.url));
const pending = new Map<number, (rows: unknown) => void>();
let nextId = 0;

worker.on('message', ({ id, rows }: { id: number; rows: unknown }) => {
  pending.get(id)?.(rows);
  pending.delete(id);
});

function runReport(): Promise<unknown> {
  const id = nextId++;
  return new Promise((resolve) => {
    pending.set(id, resolve);
    worker.postMessage(id);
  });
}

createServer(async (req, res) => {
  if (req.url === '/report') {
    res.end(JSON.stringify(await runReport()));
  } else {
    res.end('ok');
  }
}).listen(3000);

Same probe, three runs: the report took 536 to 584 ms, a little slower than on the main thread, and the worst health check over 15 probes per run was 23 to 28 ms. One worker serializes reports, which is usually what you want for something this heavy; reach for a pool only when reports queue up.

Before reaching for a worker, check whether the query can use an index. This one cannot, because a LIKE that starts with % has to scan, but most "SQLite is blocking my server" problems are a missing index on an ordinary WHERE, and a worker thread only hides them. For large exports, iterate() yields one row at a time, which pairs with the approach in Node.js streams and backpressure so you never hold the full result in memory.

node:sqlite vs better-sqlite3 vs sqlite3

better-sqlite3 13.0.3 installed with a prebuilt binary on Node 26.11.1 and bundles the same SQLite 3.53.4, which makes a fair comparison. I ran each workload five times on a WAL-mode file database, using the same four-column events table with an integer primary key, and took the median, then ran the whole thing again:

Workloadnode:sqlitebetter-sqlite3
100,000 inserts, one transaction118 / 128 ms206 / 199 ms
100,000 primary-key get() calls560 / 573 ms533 / 531 ms
100 pages of 1,000 rows with all()89 / 89 ms54 / 56 ms

Neither one wins outright. node:sqlite was faster at bulk inserts, better-sqlite3 was faster at materializing many rows into objects, and better-sqlite3 was 5 to 8% faster at point lookups. That is one laptop and one schema, so treat it as "the same league" rather than a ranking. If speed decides your choice, rerun it on your own data.

The differences that matter more are elsewhere:

node:sqlitebetter-sqlite3sqlite3
InstallBuilt in (Node 22.13+)Native addon, prebuilt or compiledNative addon, prebuilt or compiled
API styleSynchronousSynchronousAsynchronous, callbacks
Transaction helperNone, write your owndb.transaction() with savepointsNone
TypeScript@types/node, behind the 26.11 rename@types/better-sqlite3Bundled types
API stabilityRelease candidate (1.2)Stable, major version 13Stable, major version 6

The npm package called sqlite3 is the older, callback-based driver, which is what most "node sqlite3" searches are about. Its async API does not make SQLite concurrent: writes are still serialized by the database, so you trade a simple synchronous call for callbacks and gain little. I would not start a new project on it. The similarly named sqlite package is a promise wrapper usually run on top of sqlite3, so the same applies to it.

The defaults to change before production

The docs list node:sqlite at Stability 1.2, release candidate, since v25.7.0. The API is still moving: v26.10.0 started binding undefined as NULL, and v26.11.0 did the rename. Pin your Node minor version in CI and read the changelog when you upgrade.

Reading the defaults back from a fresh connection on 26.11.1 gives journal_mode of delete, synchronous of 2 (FULL), a busy timeout of 0, and foreign_keys of 1. That last one is good news: unlike the sqlite3 shell, node:sqlite enforces foreign keys out of the box through the enableForeignKeyConstraints option. The busy timeout is the one that bites:

typescript
import { Database } from 'node:sqlite';

const db = new Database('app.db', { timeout: 5000 });
db.exec(`
  PRAGMA journal_mode = WAL;
  PRAGMA synchronous = NORMAL;
`);

WAL lets readers and one writer work at the same time, and synchronous = NORMAL in WAL mode skips most of the disk syncs without risking corruption; a power cut can lose the last few commits, not the file. If losing a recent commit is unacceptable, keep FULL and accept slower writes.

Which one I would pick

For a new service, CLI or internal tool that runs on Node 26 and lives on a single machine, use node:sqlite. No native build, no dependency to patch, and it kept pace with better-sqlite3 in the benchmark above. Add the transaction helper above, the declaration file until @types/node catches up, and the two pragmas. It also suits small pieces of state that would otherwise need Redis, such as a single-instance store for API rate limiting.

Stay on better-sqlite3 if you already use it, or if you rely on its transaction() wrapper and stable API across many call sites. Switching an existing codebase gains you one fewer dependency and not much else yet.

And if you are running several app instances that all write, none of these is the answer. SQLite is one file on one disk. That is the point to move to Postgres, not to tune the driver.

Frequently asked questions

How do I use SQLite in Node.js without installing a package?

Import the built-in module: on Node 26.11 or later, import Database from node:sqlite, and on Node 22.13 through 26.10 import DatabaseSync instead, which is the same class under its older name. Create it with a file path, or with :memory: for a throwaway database, call exec for schema statements, and call prepare followed by run, get, all or iterate for everything with parameters. No flag is needed: the --experimental-sqlite flag that older tutorials mention was dropped in Node 22.13.0 and 23.4.0. Nothing is downloaded or compiled, because SQLite is linked into the Node binary itself, which is also why the SQLite version you get depends on your Node version. Node 26.11.1 bundles SQLite 3.53.4.

Is node:sqlite faster than better-sqlite3?

Roughly the same, with different strengths. On Node 26.11.1 with both using SQLite 3.53.4 and a WAL-mode file database, node:sqlite inserted 100,000 rows in one transaction in about 120 ms against about 200 ms for better-sqlite3 13.0.3. better-sqlite3 was faster at reading many rows into objects, about 55 ms against 89 ms for 100 pages of 1,000 rows, and better-sqlite3 was 5 to 8 percent faster at primary-key lookups. Those numbers come from one laptop and one schema, so the honest conclusion is that both are in the same league and the choice should rest on API stability, the transaction helper and dependencies rather than speed. If performance decides it, benchmark your own queries.

Does node:sqlite have an async API?

No. The Node documentation states that all APIs exposed by the Database and Statement classes execute synchronously, so a query blocks the event loop until it finishes. For indexed lookups that costs microseconds and does not matter. For table scans and reports it does: an unindexed query over 2 million rows held a health-check endpoint for about 470 ms in a test on Node 26.11.1. The fix is to add the index first and then move genuinely slow queries to a worker thread with its own read-only connection, which brought the same health check down to under 30 ms. Do not wrap the synchronous calls in a Promise and assume that helps, because the work still runs on the main thread.

Is node:sqlite stable enough for production?

It is usable, with care. The module is at Stability 1.2, release candidate, since Node 25.7.0, which means the API is expected to settle but can still change. It did change twice in a row: Node 26.10.0 started binding undefined as NULL, and 26.11.0 renamed DatabaseSync to Database. SQLite itself underneath is the same mature library everyone uses. For production, pin the Node minor version in CI, read the changelog on upgrades, set the timeout option so concurrent connections wait instead of failing with database is locked, and turn on WAL mode. For a single-machine service that is a reasonable setup. For many instances writing at once, use a server database instead.

What is the difference between sqlite and sqlite3 in Node.js?

SQLite is the database engine, and sqlite3 is both the name of its command-line shell and the name of an npm package. In Node.js, the sqlite3 package is an older native driver with an asynchronous, callback-based API, and it has to download a prebuilt binary or compile one when you install it. node:sqlite is the module built into Node 22.13 and later, with a synchronous API and nothing to install. better-sqlite3 is a third option, also synchronous, also a native addon. All three talk to SQLite 3, the current major version of the engine. For new code on a recent Node version, node:sqlite or better-sqlite3 is the better starting point, since the async API of sqlite3 does not make SQLite writes concurrent.

Related Articles