iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

SQLite (better-sqlite3)

SQLite is the right default database for tools, prototypes, sidecar caches, single-tenant apps, and anything that does not need a separate server process. Modern Node bundles a built-in SQLite (node:sqlite, v22+) and better-sqlite3 remains the fastest third-party option. Both speak parameterised SQL and respect WAL mode.

better-sqlite3 + WAL + prepared statements

EXAMPLE
// npm i better-sqlite3
import Database from 'better-sqlite3';

const db = new Database('shop.db');

// 1) Modern defaults — turn them on once at startup
db.pragma('journal_mode = WAL');             // concurrent reads + writes
db.pragma('synchronous = NORMAL');           // safe + fast on commits
db.pragma('foreign_keys = ON');              // enforce FK constraints
db.pragma('busy_timeout = 5000');            // retry on contention

// 2) Schema + migrations — keep them in source, run at boot
db.exec(\`
  CREATE TABLE IF NOT EXISTS customers (
    id          INTEGER PRIMARY KEY,
    email       TEXT NOT NULL UNIQUE,
    name        TEXT NOT NULL,
    created_at  TEXT NOT NULL DEFAULT (datetime('now'))
  );

  CREATE TABLE IF NOT EXISTS orders (
    id          INTEGER PRIMARY KEY,
    customer_id INTEGER NOT NULL,
    total_cents INTEGER NOT NULL,
    status      TEXT NOT NULL CHECK (status IN ('new','paid','shipped','cancelled')),
    created_at  TEXT NOT NULL DEFAULT (datetime('now')),
    FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE RESTRICT
  );

  CREATE INDEX IF NOT EXISTS ix_orders_customer_status ON orders (customer_id, status);
\`);

// 3) Prepared statements — compile ONCE, run many
const insertCustomer = db.prepare(
  'INSERT INTO customers (email, name) VALUES (?, ?) RETURNING id'
);
const insertOrder = db.prepare(
  'INSERT INTO orders (customer_id, total_cents, status) VALUES (?, ?, ?) RETURNING id'
);
const listOrders = db.prepare(
  'SELECT * FROM orders WHERE customer_id = ? ORDER BY created_at DESC LIMIT ?'
);
const getOrder = db.prepare('SELECT * FROM orders WHERE id = ?');

// 4) Use them
const { id: customerId } = insertCustomer.get('alice@example.com', 'Alice') as { id: number };
const { id: orderId    } = insertOrder.get(customerId, 4995, 'new')         as { id: number };

console.log(getOrder.get(orderId));
console.log(listOrders.all(customerId, 20));

// 5) Transactions — much faster than autocommit + atomic
const placeOrder = db.transaction((email: string, name: string, totalCents: number) => {
  const c = insertCustomer.get(email, name) as { id: number };
  const o = insertOrder.get(c.id, totalCents, 'new')      as { id: number };
  return o.id;
});

const id = placeOrder('bob@example.com', 'Bob', 9900);

// 6) Bulk insert — orders of magnitude faster than per-row commits
const insertOrders = db.transaction((rows: any[]) => {
  for (const r of rows) insertOrder.run(r.customerId, r.totalCents, r.status);
});
insertOrders([
  { customerId, totalCents: 1500, status: 'new' },
  { customerId, totalCents: 2500, status: 'new' },
]);

// 7) JSON columns (SQLite has json_extract, json_set, etc.)
db.exec(\`
  CREATE TABLE IF NOT EXISTS events (
    id          INTEGER PRIMARY KEY,
    type        TEXT NOT NULL,
    payload     TEXT NOT NULL,        -- JSON stored as TEXT
    created_at  TEXT NOT NULL DEFAULT (datetime('now'))
  );
\`);
db.prepare('INSERT INTO events (type, payload) VALUES (?, json(?))')
  .run('order.paid', JSON.stringify({ orderId, amount: 4995 }));

const recent = db.prepare(\`
  SELECT id, type, json_extract(payload, '$.orderId') AS order_id
  FROM events
  WHERE type = ? AND created_at > datetime('now', '-1 day')
\`).all('order.paid');

// 8) Backups while the app runs
db.backup('shop-' + new Date().toISOString().slice(0, 10) + '.db')
  .then(() => console.log('backup ok'));

// 9) The built-in node:sqlite (Node 22+)
// import { DatabaseSync } from 'node:sqlite';
// const db2 = new DatabaseSync('shop.db');
// const rows = db2.prepare('SELECT * FROM orders').all();
// Same parameterised-SQL discipline; smaller, no native build step.

// 10) When SQLite is the WRONG choice
// - Many concurrent writers from multiple processes / hosts
// - Need a network DB (microservices reaching it from many nodes)
// - Dataset > ~1 TB or hot-set > available RAM
// - Need PostgreSQL features (CTE, window functions, partial indexes are fine in SQLite though)

// 11) Pitfalls
// - String concat in SQL (use parameterised statements ALWAYS)
// - Forgetting WAL -> reads block during writes
// - Forgetting busy_timeout -> SQLITE_BUSY errors on contention
// - Long-running transactions -> WAL grows unbounded
// - Storing 1MB blobs without VACUUM -> file grows; pragma auto_vacuum

Why it matters

Turn on WAL, busy_timeout, and foreign_keys at startup; wrap bulk operations in `db.transaction(...)`. Those four lines make SQLite competitive with a server-side database for most single-host workloads — and they remove the most common "SQLite is slow" reports, which trace back to autocommit-per-row writes without WAL.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
import Database from 'better-sqlite3';
const db = new Database('app.db');
const rows = db.prepare('SELECT * FROM users WHERE active = ?').all(1);
Try it Yourself »

Discussion

Loading…