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.
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.
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
- Your queries can be slow and you serve HTTP traffic. Every call blocks the event loop for its full duration, so one 300 ms report query stalls every other request in the process. The library's answer is worker threads, which means moving that code and its connection to another file and paying serialization on the results
- Install size is part of your bill. node_modules/better-sqlite3 lands at about 27 MB: 17 MB of that is eight prebuilt binaries (linux, linuxmusl, darwin and win32, x64 and arm64) shipped to every install regardless of the platform you are on, plus 9.9 MB of bundled SQLite amalgamation source in deps/. Lambda bundles and Docker layers notice
- You are on Node 20. Version 13 declares engines.node >= 22, so older runtimes are pinned to the 12.x line and stop getting SQLite version bumps. Electron adds its own constraint: from Electron v43 onward the published Linux binaries require glibc 2.41 or newer, which rules out older base images
- Node ships node:sqlite in core now, with DatabaseSync and StatementSync mirroring most of this API and zero install cost. It still prints an experimental warning and lags on SQLite version (3.50.4 on Node 22.22 against 3.53.4 here), but for a small tool on a modern runtime, adding a native dependency is a choice you should have to justify
- You expect concurrent writers or more than one process. SQLite takes a database-level write lock, so a second process writing gets SQLITE_BUSY until the timeout expires. Multi-container deployments, autoscaling and any read replica story are outside what this can do, and no client library changes that
- You want a query builder, migrations or types. You write SQL strings and TypeScript gives you back unknown, so you either hand-write row interfaces or add drizzle or kysely on top and accept two layers of abstraction
- Your bundler rewrites paths. Webpack, esbuild and Next.js all need the .node binary marked external, and the nativeBinding option exists precisely because so many build systems break the addon lookup
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 1SQLite 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 IMMEDIATEWithout 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 connectionWithout 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
| Package | Registry | Pick it when |
|---|---|---|
| sqlite3 | npm | You genuinely need a callback or promise API because your queries are slow and you cannot move them to a worker thread |
| @libsql/client | npm | You want the same SQLite dialect but with an async API, embedded replicas and a hosted remote option |
| drizzle-orm | npm | You want typed queries and migrations, with better-sqlite3 still doing the driving underneath |
| node-sqlite3-wasm | npm | Native addons are not an option, for example on edge runtimes or in environments where you cannot ship binaries |