mrkeyoor.com_
Fri 07 Aug 19:00 UTC
npmDataupdated 07 Aug 2026

better-sqlite3

better-sqlite3 is a native Node addon that embeds SQLite and exposes it through a fully synchronous API. You call new Database('app.db'), then db.prepare(sql) to get a Statement, then .get() for one row, .all() for an array, .run() for writes, or .iterate() to stream. There are no callbacks and no promises anywhere in the API. That sounds backwards for Node until you notice that SQLite reads from a local file through the page cache in microseconds, and that SQLite serializes writes internally anyway, so an async wrapper mostly buys you queue hops and mutex contention. On top of the query surface it gives you db.transaction() wrappers that roll back on throw and nest as savepoints, JavaScript functions and aggregates callable from SQL, virtual tables driven by generator functions, online backups, BigInt support for 64-bit integers, and extension loading. Version 13 rebuilt the addon on N-API and now ships prebuilt binaries inside the package instead of downloading them at install time.

Verdict

For anything single-process with a local database file, this is the correct default and has been for years: the synchronous API is faster and simpler than the async ones it replaced, and v13's N-API rewrite finally kills install-time binary downloads. Go in clear-eyed about the two hard limits, that a slow query freezes your whole process and that 27 MB of prebuilt binaries rides along in every deployment.

API stability4/5The Database and Statement surface has been stable since v7 and upgrades are usually a version bump. Majors move often though (11.0.0 in May 2024, 12.0.0 in June 2025, 13.0.0 in July 2026), and most of them change the runtime contract rather than the API: v13 raised engines.node to 22 and swapped the whole addon to N-API, and 13.0.1 had to patch a binding regression that rejected plain objects from other realms in Jest.
Docs4/5docs/api.md documents every method with real examples and honest caveats, including the transaction rules and why async functions cannot be wrapped. Separate pages cover performance, 64-bit integers, worker threads, unsafe mode and compilation. It loses a point for living entirely in the repo as markdown with no searchable site, and for scattering things like safeIntegers across files.
Maintenance4/5Actively maintained by one primary author with steady community PRs: 13.0.3 shipped 2026-08-05, the repo was pushed the same day, and the bundled SQLite tracks upstream closely at 3.53.4. The tracker holds 53 open issues (67 including PRs), and the README's funding plea is a fair warning that this is unpaid work carrying a large share of the Node SQLite ecosystem.
Ecosystem5/5The de facto SQLite driver for Node at roughly 9.3M weekly downloads. Drizzle, Kysely, Prisma's SQLite path, Astro's content layer and most Electron apps that store data locally either use it or offer it as a first-class adapter.

Use it if

  • You are building a CLI, desktop app, Electron app, background worker or single-node service where the database is a file next to the code, and the query latency is measured in microseconds rather than round trips
  • You want the full SQLite feature set rather than a subset: user-defined functions and aggregates, window functions via aggregate inverse(), virtual tables from generator functions, online .backup(), .serialize() to a Buffer, and loadExtension for things like FTS5 helpers or sqlite-vec
  • You want transactions that are hard to get wrong: db.transaction(fn) begins, commits on return and rolls back on throw, nests as savepoints, and offers deferred, immediate and exclusive variants for controlling lock acquisition
  • You need 64-bit integers handled honestly: safeIntegers() returns BigInt instead of silently losing precision above 2^53, which matters for Snowflake ids and anything counting nanoseconds
  • You want prepared statements you can inspect: .columns() for result metadata, .toString() for expanded SQL, and db.explain('QUERY PLAN ...') added in v13 for checking an index is actually used
Skip it if

Setup reality

npm install better-sqlite3 on v13 no longer runs prebuild-install and no longer needs a compiler for common platforms, because the N-API rewrite lets one binary per platform ship inside the published tarball. The trade is size: you download and keep all eight prebuilt binaries plus the SQLite source, roughly 27 MB on disk, and there is no way to prune the ones you do not need. If your platform is not covered, node-gyp compiles from deps/ at install time and you need Python and a C++ toolchain on the machine, which is the classic CI failure on Alpine and on Windows without build tools. Node 22 or newer is required by engines. After install, two settings are not defaults and you almost certainly want them: db.pragma('journal_mode = WAL') so readers do not block the writer, and foreign_keys = ON, which SQLite leaves off per connection. The constructor takes timeout (5000 ms by default) for how long a locked database is retried before SQLITE_BUSY. In bundled apps you will meet the missing-addon error at least once and fix it with the nativeBinding option or a bundler externals entry. Electron needs the addon rebuilt against the Electron ABI, normally through electron-rebuild.

Patterns

Open a database and set the pragmas that matteropen-and-tune

import Database from 'better-sqlite3';

const db = new Database('app.db', { timeout: 5000 });
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.pragma('synchronous = NORMAL');

console.log(db.pragma('journal_mode', { simple: true })); // 'wal'

journal_mode is written into the file and persists, but foreign_keys and synchronous are per connection and reset every time you open one. Use db.pragma() rather than db.prepare('PRAGMA ...') because it normalizes SQLite's inconsistent return shapes.

Read one row, many rows, or a single columnprepare-and-query

const byId = db.prepare('SELECT id, name, email FROM users WHERE id = ?');
const user = byId.get(42);              // row object, or undefined

const active = db.prepare('SELECT * FROM users WHERE active = 1').all();

const emails = db.prepare('SELECT email FROM users').pluck().all();
// ['a@b.co', 'c@d.co']

Prepare once and reuse the Statement, since preparing inside a loop re-parses the SQL every iteration. .get() returns undefined rather than null when nothing matches, and pluck() is sticky on that Statement until you call pluck(false).

Bind parameters by name instead of positionnamed-parameters

const insert = db.prepare(
  'INSERT INTO users (name, email) VALUES (@name, @email)'
);
const info = insert.run({ name: 'Ada', email: 'ada@example.com' });
console.log(info.changes, info.lastInsertRowid); // 1 1

SQLite accepts @foo, :foo and $foo and better-sqlite3 supports all three, but the keys you pass must be bare names with no prefix. An extra key that no placeholder uses throws, which catches typos that positional binding would silently swallow.

Insert thousands of rows in one transactionbatch-insert-transaction

const insert = db.prepare('INSERT INTO events (kind, payload) VALUES (?, ?)');

const insertMany = db.transaction((rows) => {
  for (const r of rows) insert.run(r.kind, r.payload);
  return rows.length;
});

insertMany(batch);              // BEGIN
insertMany.immediate(batch);    // BEGIN IMMEDIATE

Without a transaction each insert is its own fsync, which is the single biggest reason bulk loads feel slow. The wrapped function must be synchronous: an async function returns at the first await and the transaction commits before your code runs.

Stream a big result set instead of buffering ititerate-large-result

const stmt = db.prepare('SELECT id, payload FROM events ORDER BY id');

for (const row of stmt.iterate()) {
  process(row);
  if (row.id > cutoff) break;
}

Breaking out of the loop closes the iterator and releases the read transaction; leaving it hanging keeps the statement marked busy and blocks writers. If you are going to read every row anyway, .all() is measurably faster than .iterate().

Branch on a constraint violationhandle-sqlite-errors

import Database, { SqliteError } from 'better-sqlite3';

try {
  insertUser.run({ email });
} catch (err) {
  if (err instanceof SqliteError && err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
    return { ok: false, reason: 'email-taken' };
  }
  throw err;
}

err.code is an extended SQLite result code string, so match SQLITE_CONSTRAINT_UNIQUE rather than the message text. Inside a transaction function, check db.inTransaction before continuing after a caught error, because SQLite may already have rolled back for you.

Read 64-bit integers without losing precisionsafe-integers

const stmt = db.prepare('SELECT id FROM snowflakes WHERE user = ?');
console.log(stmt.pluck().get(1));                 // 9007199254740992 (wrong)
console.log(stmt.safeIntegers().pluck().get(1));  // 9007199254740993n

db.defaultSafeIntegers(true); // apply to every statement on this connection

Without this, any INTEGER above 2^53 comes back as a lossy Number and nothing warns you. Turning it on flips every integer column to BigInt, including counts and rowids, so JSON.stringify starts throwing until you add a replacer.

Call a JavaScript function from SQLuser-defined-function

db.function('slugify', { deterministic: true }, (s) =>
  String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-')
);

const rows = db
  .prepare('SELECT id FROM posts WHERE slugify(title) = ?')
  .all('hello-world');

The callback runs once per row inside the SQLite loop and blocks the process while it does, so keep it cheap and never await. Mark it deterministic only if it truly is, since that lets SQLite use it in a partial index and cache results.

Define an aggregate that also works as a window functionwindow-aggregate

db.aggregate('addAll', {
  start: 0,
  step: (total, value) => total + value,
  inverse: (total, dropped) => total - dropped,
  result: (total) => Math.round(total),
});

db.prepare(`
  SELECT ts, dollars, addAll(dollars) OVER day AS dayTotal
  FROM expenses WINDOW day AS (PARTITION BY date(ts)) ORDER BY ts
`).all();

Supplying inverse() is what makes it usable in an OVER clause; without it SQLite rejects the window usage. If step() returns undefined the accumulator is left unchanged, which quietly produces wrong totals when your step function forgets a return.

Check that a query actually uses your indexexplain-query-plan

console.table(db.explain('QUERY PLAN SELECT * FROM users WHERE email = ?'));
// detail: 'SEARCH users USING INDEX idx_users_email (email=?)'

// without the QUERY PLAN prefix you get raw bytecode instead:
db.explain('SELECT * FROM users WHERE email = ?');

Added in v13. Unlike a prepared statement it does not need bound parameters, so you can inspect a query without inventing values. Seeing SCAN instead of SEARCH in the detail column is the signal that your index is missing or unusable.

Back up a live database without stopping writesonline-backup

await db.backup(`backup-${Date.now()}.db`, {
  progress({ totalPages, remainingPages }) {
    console.log(`${totalPages - remainingPages}/${totalPages}`);
    return 200; // pages per event loop cycle
  },
});

This is the one asynchronous method in the library, and it yields between chunks so the event loop keeps running. If a different connection writes during the copy, the backup restarts from scratch, so route writes through one connection while it runs.

Open a read-only connection and close cleanly on exitreadonly-and-shutdown

const reader = new Database('app.db', { readonly: true, fileMustExist: true });

process.on('exit', () => reader.close());
process.on('SIGINT', () => process.exit(128 + 2));
process.on('SIGTERM', () => process.exit(128 + 15));

readonly connections cannot take the write lock, which makes them safe to hand to reporting code and to spread across worker threads. Signal handlers matter because Node does not run exit handlers on a bare SIGINT, so an unclosed WAL can be left behind.

Alternatives

PackageRegistryPick it when
sqlite3npmYou genuinely need a callback or promise API because your queries are slow and you cannot move them to a worker thread
@libsql/clientnpmYou want the same SQLite dialect but with an async API, embedded replicas and a hosted remote option
drizzle-ormnpmYou want typed queries and migrations, with better-sqlite3 still doing the driving underneath
node-sqlite3-wasmnpmNative addons are not an option, for example on edge runtimes or in environments where you cannot ship binaries