SQLite in Node.js: node:sqlite and better-sqlite3 (Tested Examples)

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:sqlite module. 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 sqlite3 npm 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:sqlitebetter-sqlite3sqlite3
InstallBuilt into Node.jsnpm install better-sqlite3 (native module)npm install sqlite3 (native module)
API styleSynchronousSynchronousCallbacks
TransactionsBEGIN/COMMIT yourselfdb.transaction(fn) helperBEGIN/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 versionnode:sqlite
Before 22.5.0Doesn’t exist. Use better-sqlite3
22.5.0 to 22.12Needs 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:sqlite fails. Check node --version. Before 22.13.0 (or 23.4.0) the module needed the --experimental-sqlite flag, and before 22.5.0 it didn’t exist. Upgrade, or use better-sqlite3.
  • Error codes differ between libraries. better-sqlite3 sets code to the SQLite error name (SQLITE_CONSTRAINT_UNIQUE). node:sqlite uses ERR_SQLITE_ERROR, with the numeric code in errcode. SQLite’s result code list defines 2067 as SQLITE_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

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.