better-sqlite3: Use SQLite in Node.js

Adding a local database to a Node.js application starts with choosing how the code will access SQLite. better-sqlite3 is a Node.js package that connects your code to an SQLite database and returns each database call’s result or error before the call returns.

In this piece, I’ll show how to install better-sqlite3 and use prepared statements for database queries.

TL;DR

better-sqlite3 gives Node.js a synchronous way to query SQLite files, with a transaction helper for grouped writes.

  • Install it with npm install better-sqlite3.
  • Use .prepare() and bound values for queries.
  • Keep transaction callbacks synchronous and short.

What is better-sqlite3?

better-sqlite3 connects Node.js code to an SQLite database file. It runs each database call synchronously, so the result or error is available as soon as the call returns. The package uses a native binding, which bridges JavaScript to SQLite’s C library.

Synchronous means the call finishes before Node.js moves to the next line. That keeps a query straightforward to read, but a long query also occupies the JavaScript thread until SQLite returns. The project’s README points to worker threads for large or slow queries.

Pick a SQLite interface based on your Node.js version and the API style the application expects.

OptionWhere it comes fromAPI styleWhen it fits
better-sqlite3npm packageSynchronous, with a transaction helperYou want its prepared-statement and transaction API.
node:sqliteBuilt into supported Node.js versionsSynchronous, with SQL transaction statementsYour runtime includes the module and you prefer no dependency.
sqlite3npm packageCallback-basedYour existing code already uses its callback API.

I use better-sqlite3 in the examples because its transaction wrapper makes the commit and rollback boundary visible. Our Node.js SQLite comparison covers how the package choices differ.

APIUseResult
db.prepare(sql)Compile one SQL statementA reusable Statement
stmt.run(values)Insert, update or deleteChange count and row ID
stmt.get(values)Read one matching rowOne object, or undefined
stmt.all(values)Read matching rowsAn array of objects

I keep SQL in prepared statements and pass changing values separately. That makes the query structure clear and avoids building SQL by concatenating user input.

A prepared statement represents one SQL statement. If your task needs several writes, prepare each statement separately and call them inside one transaction. The package maintainer gives this same distinction in a response to a question about preparing multiple statements as one object.

Use .get() when the query should return one row and .all() when the result set is small enough to load at once. For a large result, the Statement API also provides .iterate(), which yields rows one at a time instead of building an array.

How to use better-sqlite3 in a Node.js project

The steps below install the package and exercise one in-memory database, which keeps the run independent of files left from earlier.

Step 1: Install the package

From the project directory, run npm install better-sqlite3 because the package needs a native binding for your Node.js runtime and platform.

npm install better-sqlite3
npm install better-sqlite3 output

The current package documentation recommends a currently supported Node.js release. If installation fails with “Could not locate the bindings file,” use the SQLite package installation guide for the wider setup path.

Step 2: Prepare queries and read results

Open a connection with new Database(). Prepare a statement once, then bind each row’s values when you call .run(). The example below also reads one row with .get() and a list with .all().

import Database from 'better-sqlite3';

const db = new Database(':memory:');
db.exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, email TEXT)');
const insert = db.prepare('INSERT INTO users (name, email) VALUES (@name, @email)');
const insertMany = db.transaction((users) => {
  for (const user of users) insert.run(user);
});
insertMany([
  { name: 'Ada', email: 'ada@example.com' },
  { name: 'Linus', email: null }
]);
console.log('row:', db.prepare('SELECT id, name, email FROM users WHERE name = ?').get('Ada'));
console.log('all:', db.prepare('SELECT name FROM users ORDER BY id').all());
try {
  insertMany([
    { name: 'Grace', email: 'grace@example.com' },
    { name: 'Ada', email: 'duplicate@example.com' }
  ]);
} catch (error) {
  console.log('rollback:', error.code);
}
console.log('after rollback:', db.prepare('SELECT name FROM users ORDER BY id').all());
console.log('journal mode:', db.pragma('journal_mode', { simple: true }));
db.close();

The demo prints Ada’s row and then lists both users. A duplicate name triggers a constraint error, and the transaction removes the earlier Grace insert from the failed batch.

row: { id: 1, name: 'Ada', email: 'ada@example.com' }
all: [ { name: 'Ada' }, { name: 'Linus' } ]
rollback: SQLITE_CONSTRAINT_UNIQUE
after rollback: [ { name: 'Ada' }, { name: 'Linus' } ]
journal mode: memory
better-sqlite3 query results and transaction rollback output

The database uses :memory:, so SQLite reports memory as its journal mode. For a file-backed database, check the mode after setting it with db.pragma(‘journal_mode = WAL’).

The insert statement uses named parameters, so the object keys match the SQL names. Positional question marks work too, as in the SELECT call. In both forms, SQLite receives the values separately from the SQL text.

The insertMany function prepares no new SQL on each loop iteration. It reuses the same statement for every user, then the transaction wrapper commits after the loop finishes. When the duplicate Ada row throws, the earlier Grace insert in that batch is rolled back as well.

The in-memory database keeps each run independent, while a file path such as app.db preserves data after the connection closes.

The WAL check opens a file-backed database and reads back SQLite’s journal mode.

Step 3: Check the journal mode on a file database

import Database from 'better-sqlite3';

const db = new Database('wal-check.db');
console.log(db.pragma('journal_mode = WAL', { simple: true }));
db.close();
wal
better-sqlite3 SQLite journal mode output

SQLite returns the active mode as wal. WAL lets readers continue while a write is committed, but SQLite still permits only one writer at a time.

Transactions, WAL and synchronous query limits

A transaction groups related writes into one commit. better-sqlite3 commits when the callback returns and rolls back if it throws, so I keep this callback synchronous and limited to database work.

SituationWhat to checkResponse
Several statements must succeed togetherDoes the transaction callback throw on failure?Let the error escape so the wrapper rolls back.
A query takes noticeable timeDoes it block other JavaScript on the same thread?Move large or slow queries to a worker thread.
You enable WAL modeDoes db.pragma(‘journal_mode = WAL’, { simple: true }) return wal?Keep the database and its WAL files together. SQLite allows one writer at a time.

Do not make the transaction callback async. It returns a promise at the first await, which lets the wrapper commit before later asynchronous work finishes. SQLite’s WAL mode lets readers and a writer overlap, but it does not allow multiple simultaneous writers.

For modest synchronous queries, I keep the connection on the main thread. If a query is large or slow enough to delay request handling, the project documentation recommends worker threads rather than expecting the synchronous call to yield.

WAL changes how SQLite records committed writes. New pages are appended to a separate log, then a checkpoint copies them back into the database file.

SQLite checkpoints automatically, but a long-running read can hold its snapshot and delay that work. Keep the database, its WAL file and its shared-memory file together while connections are open.

Transactions also have an execution boundary: do not await network calls or other asynchronous work inside one. The wrapper commits as soon as its callback returns, and the library’s API documentation says transaction functions do not work with async functions. Do the external work before opening the transaction, then keep the database changes together in a short synchronous callback.

If the transaction fails, let the error escape unless you first check the connection state. SQLite can force a rollback for errors such as SQLITE_BUSY or SQLITE_FULL. The API docs warn that catching an error inside the callback does not guarantee later statements still belong to the same transaction.

Before upgrading better-sqlite3 to a new major version, review its release notes and the package’s upgrade guidance. The project warns that an upgrade can change the JavaScript API or the SQLite version used to open existing database files.

Conclusion: keep each database operation explicit

Use prepared statements to bind values. Group related writes in a transaction and keep synchronous work short. The Node.js SQLite guide compares the built-in module with better-sqlite3, and the Next.js SQLite tutorial shows the package in an application.

FAQ

These answers cover two common decisions when adding the package to a Node.js project.

How do I add TypeScript types for better-sqlite3?

Install the @types/better-sqlite3 package in your project to add the TypeScript declarations. Check that package’s current version and instructions before adding it.

Can a better-sqlite3 transaction callback use async and await?

No. The transaction wrapper commits when the callback returns, and an async callback returns a promise before awaited work completes. Keep the callback synchronous.

Does WAL allow multiple writes at the same time?

No. WAL allows readers and a writer to overlap, but SQLite still has one writer at a time.