Published on

Node.js Built-in SQLite — Embedded Database Without Dependencies

Authors

Introduction

Node.js 22.5 introduced native SQLite support via the node:sqlite module. No better-sqlite3. No sql.js. No external processes. Just synchronous SQL queries backed by SQLite, built directly into Node.js.

node:sqlite Module Basics

The node:sqlite module provides DatabaseSync for synchronous database operations. Create a database file in seconds:

import { DatabaseSync } from 'node:sqlite';

const db = new DatabaseSync(':memory:');

// Or persist to disk
const db = new DatabaseSync('app.db');

// Create table
db.exec(`
  CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL,
    email TEXT UNIQUE,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
  )
`);

db.close();

DatabaseSync is synchronous only; use for CLI tools, background jobs, and edge functions where blocking is acceptable.

Prepared Statements

Always use prepared statements to prevent SQL injection and improve performance.

import { DatabaseSync } from 'node:sqlite';

const db = new DatabaseSync('app.db');

// Prepare statement once, execute many times
const insert = db.prepare(
  'INSERT INTO users (name, email) VALUES (?, ?)'
);

const select = db.prepare(
  'SELECT * FROM users WHERE id = ?'
);

const update = db.prepare(
  'UPDATE users SET name = ? WHERE id = ?'
);

// Execute insert
insert.run('Alice', 'alice@example.com');
insert.run('Bob', 'bob@example.com');

// Fetch single row
const user = select.get(1);
console.log(user); // { id: 1, name: 'Alice', email: 'alice@example.com', created_at: '...' }

// Update row
update.run('Alicia', 1);

// Get all rows
const allUsers = db.prepare('SELECT * FROM users').all();
console.log(allUsers); // [{ id: 1, name: 'Alicia', ... }, { id: 2, name: 'Bob', ... }]

db.close();

Prepared statements compile once and execute efficiently, even in tight loops.

Transactions

Batch operations in transactions for atomicity and performance.

import { DatabaseSync } from 'node:sqlite';

const db = new DatabaseSync('app.db');

function transferFunds(fromId: number, toId: number, amount: number) {
  const selectBalance = db.prepare(
    'SELECT balance FROM accounts WHERE id = ?'
  );

  const updateBalance = db.prepare(
    'UPDATE accounts SET balance = balance + ? WHERE id = ?'
  );

  // Start transaction
  const transaction = db.transaction(() => {
    const from = selectBalance.get(fromId) as any;
    if (from.balance < amount) {
      throw new Error('Insufficient funds');
    }

    // Debit source, credit destination
    updateBalance.run(-amount, fromId);
    updateBalance.run(amount, toId);

    return { success: true, newBalance: from.balance - amount };
  });

  try {
    return transaction(); // Auto-commits on success
  } catch (err) {
    console.error('Transfer failed, rolled back:', err);
    throw err;
  }
}

transferFunds(1, 2, 100);
db.close();

Transactions are critical for multi-step operations; SQLite rolls back automatically on errors.

Type Mapping (JS Numbers, Strings, BigInts)

SQLite type system maps cleanly to JavaScript:

import { DatabaseSync } from 'node:sqlite';

const db = new DatabaseSync(':memory:');

db.exec(`
  CREATE TABLE products (
    id INTEGER PRIMARY KEY,
    name TEXT,
    price REAL,
    stock INTEGER,
    large_id INTEGER
  )
`);

const insert = db.prepare(
  'INSERT INTO products (name, price, stock, large_id) VALUES (?, ?, ?, ?)'
);

// JavaScript types map directly
insert.run('Widget', 19.99, 100, 9007199254740991n); // BigInt for large integers

const select = db.prepare('SELECT * FROM products WHERE id = ?');
const product = select.get(1) as {
  id: number;
  name: string;
  price: number;
  stock: number;
  large_id: bigint;
};

console.log(typeof product.price); // 'number'
console.log(typeof product.large_id); // 'bigint'

SQLite handles:

  • INTEGERnumber or bigint
  • REALnumber
  • TEXTstring
  • BLOBUint8Array
  • NULLnull

BLOB Handling

Store binary data directly in SQLite:

import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'fs';

const db = new DatabaseSync('app.db');

db.exec(`
  CREATE TABLE files (
    id INTEGER PRIMARY KEY,
    name TEXT,
    content BLOB,
    uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP
  )
`);

const insertFile = db.prepare(
  'INSERT INTO files (name, content) VALUES (?, ?)'
);

// Store binary data
const fileContent = readFileSync('document.pdf');
insertFile.run('document.pdf', fileContent);

// Retrieve binary data
const getFile = db.prepare('SELECT * FROM files WHERE name = ?');
const file = getFile.get('document.pdf') as { content: Uint8Array };

console.log(file.content instanceof Uint8Array); // true
console.log(file.content.length); // bytes

BLOBs are returned as Uint8Array for efficient binary handling.

WAL Mode for Performance

Write-Ahead Logging (WAL) improves concurrent read/write performance significantly.

import { DatabaseSync } from 'node:sqlite';

const db = new DatabaseSync('app.db');

// Enable WAL mode
db.exec('PRAGMA journal_mode = WAL');

// Optional: Tune WAL behavior
db.exec('PRAGMA synchronous = NORMAL'); // Faster, still safe
db.exec('PRAGMA cache_size = -64000'); // 64MB cache

// Queries benefit from improved concurrency
const insert = db.prepare('INSERT INTO logs (message) VALUES (?)');

for (let i = 0; i < 10000; i++) {
  insert.run(`Log entry ${i}`);
}

db.close();

WAL enables multiple readers while writes are in progress, essential for concurrent workloads.

Use Cases: When to Embed SQLite

SQLite is perfect for:

  • CLI tools: netlify-cli, prisma-cli use SQLite internally
  • Local-first apps: Tauri + SQLite provides offline-capable desktops
  • Test databases: Eliminates dependency on external test servers
  • Edge functions: Cloudflare Workers, Vercel Edge Functions (with WAasm)
  • Configuration storage: Replace JSON files with queryable structure
// Example: CLI tool with local database
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'fs';

class TodoCLI {
  private db: DatabaseSync;

  constructor(dbPath: string) {
    this.db = new DatabaseSync(dbPath);
    this.migrate();
  }

  private migrate() {
    this.db.exec(`
      CREATE TABLE IF NOT EXISTS todos (
        id INTEGER PRIMARY KEY,
        title TEXT NOT NULL,
        completed BOOLEAN DEFAULT 0,
        created_at DATETIME DEFAULT CURRENT_TIMESTAMP
      )
    `);
  }

  add(title: string) {
    this.db.prepare('INSERT INTO todos (title) VALUES (?)').run(title);
  }

  list() {
    return this.db.prepare('SELECT * FROM todos WHERE completed = 0').all();
  }

  complete(id: number) {
    this.db.prepare('UPDATE todos SET completed = 1 WHERE id = ?').run(id);
  }

  close() {
    this.db.close();
  }
}

const cli = new TodoCLI('~/.todos.db');
cli.add('Deploy API');
cli.add('Write tests');
console.log(cli.list());
cli.complete(1);
cli.close();

Comparing to better-sqlite3

Featurenode:sqlitebetter-sqlite3
Dependencies0Native binding, requires C++ compiler
SetupBuilt-innpm install, build time
PerformanceFastSlightly faster (native optimizations)
AsyncNoNo (both synchronous)
StabilityStable (Node 22+)Mature (5+ years)
Node versions22.5+ only18+
LicenseNode.js LicenseMIT

Use node:sqlite for new projects targeting Node 22+. Use better-sqlite3 for legacy codebases or Node <22.

Limitations

  • Synchronous only (no async support)
  • Node.js 22.5+ required
  • No built-in backup/restore
  • No full-text search (but SQLite FTS5 available)
  • No replication

For async database access, stay with Prisma/Drizzle backed by PostgreSQL/MySQL.

Edge Functions and Cloudflare Workers

SQLite in edge functions requires WebAssembly; use sql.js or Wasmer instead of native node:sqlite.

// Cloudflare Worker with sql.js (not node:sqlite)
import initSqlJs from 'sql.js';

export default {
  async fetch(request: Request) {
    const SQL = await initSqlJs();
    const db = new SQL.Database();

    db.run('CREATE TABLE users (id INT, name TEXT)');
    db.run('INSERT INTO users VALUES (1, ?)', ['Alice']);

    const results = db.exec('SELECT * FROM users');
    return new Response(JSON.stringify(results), {
      headers: { 'Content-Type': 'application/json' },
    });
  },
};

Native node:sqlite runs on Vercel Functions and other serverless Node.js runtimes where SQLite files persist.

Checklist

  • Update to Node.js 22.5 LTS or later
  • Replace external SQLite libraries with node:sqlite where applicable
  • Enable WAL mode: PRAGMA journal_mode = WAL
  • Always use prepared statements
  • Wrap multi-step operations in transactions
  • Test BLOB handling with binary files
  • Configure cache size for your workload
  • Monitor database file size (checkpoint periodically)
  • Use TypeScript for type-safe queries

Conclusion

Node.js 22.5's built-in SQLite eliminates dependency management for CLI tools, edge functions, and local-first applications. With zero setup and zero external binaries, SQLite is now the fastest way to add persistence to Node.js projects. For backend teams building microservices, embedded SQLite accelerates development and simplifies deployment.