Node.js has more than one way to use SQLite: a built-in node:sqlite module and npm packages like better-sqlite3, while some older packages are deprecated. In this article I’ll walk you through the same small program with node:sqlite and with better-sqlite3, so you can pick one and get going.
TLDR
- On Node.js 22.13 or later, use the built-in
node:sqlitemodule. There’s nothing to install. - Use better-sqlite3 (
npm install better-sqlite3) on older Node.js versions, or when you want its transaction helper. - Both are synchronous: you
prepare()a statement, then run it and read the rows. - The
sqlite3npm package still works, but its README marks it as deprecated and unmaintained.
What is SQLite in Node.js, and what will we build?
SQLite in Node.js means a library that opens an SQLite database file from your JavaScript code, with no database server involved. There are three you’ll come across:
node:sqlite | better-sqlite3 | sqlite3 | |
|---|---|---|---|
| Install | Built into Node.js | npm install better-sqlite3 (native module) | npm install sqlite3 (native module) |
| API style | Synchronous | Synchronous | Callbacks |
| Transactions | BEGIN/COMMIT yourself | db.transaction(fn) helper | BEGIN/COMMIT yourself |
| Status | “release candidate” since Node.js 25.7.0 (node:sqlite docs) | Version 13.0.3, tested here (README) | “[DEPRECATED]” and “currently unmaintained” (its README) |
To show you how this works, I will build one small program that stores two users and reads them back. I’ll write it once with node:sqlite and once with better-sqlite3.
Prerequisites
You need Node.js. The version decides whether node:sqlite is available:
| Node.js version | node:sqlite |
|---|---|
| Before 22.5.0 | Doesn’t exist. Use better-sqlite3 |
| 22.5.0 to 22.12 | Needs the --experimental-sqlite flag |
| 22.13.0 or later (and 23.4.0 or later) | Works with no flag |
These versions come from the Node.js node:sqlite documentation. I ran everything on this page with these versions:
v24.18.0
node:sqlite SQLite 3.53.4
better-sqlite3 13.0.3 SQLite 3.53.4
sqlite3 6.0.1 SQLite 3.52.0
Building a users database in Node.js
Each step below is a complete .mjs file you can run with node. Run them in an empty folder.
Step 1: Create the database with node:sqlite
new DatabaseSync("app.db") opens the file, and creates it if it doesn’t exist:
import { DatabaseSync } from "node:sqlite";
const db = new DatabaseSync("app.db"); // creates the file if it doesn't exist
db.exec(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
email TEXT
)`);
const insert = db.prepare("INSERT INTO users (name, email) VALUES (?, ?)");
console.log(insert.run("ada", "ada@example.com"));
console.log(insert.run("linus", null));
console.log(db.prepare("SELECT id, name, email FROM users ORDER BY id").all());
console.log(db.prepare("SELECT name FROM users WHERE id = ?").get(2));
db.close();
$ node node-sqlite-quickstart.mjs
{ changes: 1, lastInsertRowid: 1 }
{ changes: 1, lastInsertRowid: 2 }
[
[Object: null prototype] {
id: 1,
name: 'ada',
email: 'ada@example.com'
},
[Object: null prototype] { id: 2, name: 'linus', email: null }
]
[Object: null prototype] { name: 'linus' }
run() returns the number of changed rows and the new row’s ID. all() returns every row, and get() returns the first one. Rows are objects with a null prototype, which is why Node prints [Object: null prototype]. You read them the same way, as row.name.
Step 2: Write the same program with better-sqlite3
Install it with npm install better-sqlite3, and delete the app.db from Step 1 first, because it already holds these two users. This version uses named parameters (@name), and turns on WAL mode, which the README recommends “for performance reasons” (better-sqlite3 README):
import Database from "better-sqlite3";
const db = new Database("app.db");
db.pragma("journal_mode = WAL");
db.exec(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
email TEXT
)`);
const insert = db.prepare("INSERT INTO users (name, email) VALUES (@name, @email)");
console.log(insert.run({ name: "ada", email: "ada@example.com" }));
console.log(insert.run({ name: "linus", email: null }));
console.log(db.prepare("SELECT id, name, email FROM users ORDER BY id").all());
console.log(db.prepare("SELECT name FROM users WHERE id = ?").get(2));
db.close();
$ node better-sqlite3-quickstart.mjs
{ changes: 1, lastInsertRowid: 1 }
{ changes: 1, lastInsertRowid: 2 }
[
{ id: 1, name: 'ada', email: 'ada@example.com' },
{ id: 2, name: 'linus', email: null }
]
{ name: 'linus' }
The rows are plain objects here. The calls are otherwise the same.
Step 3: Group writes in a transaction
better-sqlite3’s db.transaction(fn) returns a function that “will begin a new transaction. When the function returns, the transaction will be committed. If an exception is thrown, the transaction will be rolled back” (better-sqlite3 API documentation). Here the second batch fails on a duplicate name, so none of its rows are saved:
import Database from "better-sqlite3";
const db = new Database(":memory:");
db.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE)");
const insert = db.prepare("INSERT INTO users (name) VALUES (?)");
// Commits when the function returns, rolls back if it throws
const insertAll = db.transaction((names) => {
for (const name of names) insert.run(name);
});
insertAll(["ada", "linus"]);
try {
insertAll(["grace", "ada"]); // "ada" already exists, so "grace" is rolled back too
} catch (e) {
console.log(e.code, "-", e.message);
}
console.log(db.prepare("SELECT name FROM users ORDER BY id").all());
db.close();
$ node better-sqlite3-transaction.mjs
SQLITE_CONSTRAINT_UNIQUE - UNIQUE constraint failed: users.name
[ { name: 'ada' }, { name: 'linus' } ]
With node:sqlite, write the transaction yourself in SQL:
import { DatabaseSync } from "node:sqlite";
const db = new DatabaseSync(":memory:");
db.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE)");
const insert = db.prepare("INSERT INTO users (name) VALUES (?)");
function insertAll(names) {
db.exec("BEGIN");
try {
for (const name of names) insert.run(name);
db.exec("COMMIT");
} catch (e) {
db.exec("ROLLBACK");
throw e;
}
}
insertAll(["ada", "linus"]);
try {
insertAll(["grace", "ada"]);
} catch (e) {
console.log(e.code, e.errcode, "-", e.message);
}
console.log(db.prepare("SELECT name FROM users ORDER BY id").all());
db.close();
$ node node-sqlite-transaction.mjs
ERR_SQLITE_ERROR 2067 - UNIQUE constraint failed: users.name
[
[Object: null prototype] { name: 'ada' },
[Object: null prototype] { name: 'linus' }
]
Step 4: Use it from TypeScript
Node.js runs TypeScript files directly: “By default Node.js will execute TypeScript files that contains only erasable TypeScript syntax”, with no type checking (Node.js TypeScript docs). A .mts file always runs as an ES module, so import works without changing package.json:
import { DatabaseSync } from "node:sqlite";
interface User { id: number; name: string }
const db = new DatabaseSync(":memory:");
db.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)");
db.prepare("INSERT INTO users (name) VALUES (?)").run("Ada");
const users = db.prepare("SELECT id, name FROM users").all() as unknown as User[];
console.log(users[0].name);
$ node users.mts
Ada
Running it
The better-sqlite3 program turned on WAL mode, so while it runs, app.db has -wal and -shm files next to it. SQLite removes them when the last connection closes, which is why only app.db is left once the program ends:
$ ls app.db*
app.db
See SQLite WAL mode for what they are and why you keep them with the database.
Common problems
These are the errors and surprises you’re likely to hit first:
- Importing
node:sqlitefails. Checknode --version. Before 22.13.0 (or 23.4.0) the module needed the--experimental-sqliteflag, and before 22.5.0 it didn’t exist. Upgrade, or use better-sqlite3. - Error codes differ between libraries. better-sqlite3 sets
codeto the SQLite error name (SQLITE_CONSTRAINT_UNIQUE).node:sqliteusesERR_SQLITE_ERROR, with the numeric code inerrcode. SQLite’s result code list defines 2067 asSQLITE_CONSTRAINT_UNIQUE. SQLITE_BUSY/ “database is locked”. Another connection is writing. Keep write transactions short, and see SQLite database is locked.- Slow queries block your app. Both libraries run queries on the calling thread. better-sqlite3 lists worker thread support “for large/slow queries” (README).
Why to avoid the sqlite3 npm package in new code
The sqlite3 package still installs and runs. Its API is callback-based, so results arrive after the surrounding code has moved on:
import sqlite3 from "sqlite3";
const db = new sqlite3.Database(":memory:");
db.serialize(() => {
db.run("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)");
db.run("INSERT INTO users (name) VALUES (?)", ["Ada"], function (err) {
if (err) throw err;
console.log("inserted id", this.lastID); // callback API: results arrive later
});
db.all("SELECT id, name FROM users", (err, rows) => {
if (err) throw err;
console.log(rows);
db.close();
});
});
console.log("this line prints first");
$ node sqlite3-package.mjs
this line prints first
inserted id 1
[ { id: 1, name: 'Ada' } ]
Its README for version 6.0.1 opens with “[DEPRECATED] node-sqlite3” and “This repository is currently unmaintained. We will not update any of its issues or pull requests.” For new code, use node:sqlite or better-sqlite3.
Conclusion
For SQLite in Node.js, start with the built-in node:sqlite on Node.js 22.13 or later. Pick better-sqlite3 for older Node versions or for its transaction helper. Both use the same synchronous prepare-then-run pattern, and node:sqlite runs from a TypeScript file without a build step.
Further reading
- SQLite show tables (list tables from code)
- Next.js with SQLite
- Prisma with SQLite
- Expo SQLite
- Python SQLite3 tutorial
- Node.js docs: node:sqlite
- better-sqlite3: API documentation
Tested with Node.js 24.18.0 (node:sqlite, SQLite 3.53.4), better-sqlite3 13.0.3 and sqlite3 6.0.1 on 7 October 2026.
