Skip to content

SQLite - The Database Everywhere

SQLite is the most deployed database engine in human history It's in your phone, your browser, your car's infotainment system, your TV, every Docker container that runs anything, every cloud function execution, and probably the smart toaster that your friend bought on a Black Friday whim and never configured properly - because SQLite is a self-contained, zero-configuration, serverless relational database that lives in a single file and doesn't require a database administrator, a dedicated server, or even a running process

When to use SQLite

SQLite is perfect for development and prototyping, embedded applications, mobile apps, and low-traffic websites where you don't want the operational overhead of a full PostgreSQL installation. It's also ideal for testing because you can create an in-memory database, run your tests, and discard it without ever touching disk

Perfect for: * Development databases - spin up without installing Postgres or configuring users * Embedded applications - desktop software , mobile apps (iOS/Android use SQLite internally) , IoT devices * Testing - in-memory databases for fast test suites that clean up automatically * Low-traffic websites - up to ~100K daily visits, SQLite handles it fine * Data analysis and reporting - import data, query with SQL, export results * Function-as-a-Service (serverless) - no persistent daemon to manage

Wrong for: * High-concurrency write workloads - SQLite locks the entire database for writes * Multi-server deployments - SQLite is a single file , not a network service * Large datasets (> 1TB) - SQLite has no sharding or distributed query support * User management and access control - SQLite has none of that * Anything requiring replication - no built-in replication mechanism

Better-SQLite3 (Node.js)

better-sqlite3 is the preferred SQLite driver for Node.js because it's synchronous (which is actually a feature for SQLite - synchronous queries are simpler and faster since SQLite doesn't support concurrent writes anyway) and significantly faster than the callback-based sqlite3 package

npm install better-sqlite3
const Database = require('better-sqlite3');

// Create or open a database file
const db = new Database('myapp.db');

// In-memory database (fast, no persistence - great for testing)
const testDb = new Database(':memory:');

// Enable WAL mode for better concurrent read performance
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');  // SQLite doesn't enforce FK by default

Creating tables and inserting data

// Create table
db.exec(`
    CREATE TABLE IF NOT EXISTS users (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        email TEXT NOT NULL UNIQUE,
        username TEXT NOT NULL,
        password_hash TEXT NOT NULL,
        role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('user', 'admin')),
        created_at TEXT NOT NULL DEFAULT (datetime('now'))
    )
`);

// Insert with prepared statement (parameterized - prevents SQL injection)
const insert = db.prepare(`
    INSERT INTO users (email, username, password_hash, role)
    VALUES (?, ?, ?, ?)
`);

// Single insert
insert.run('omar@example.com', 'omar_hacker', '$2b$12$hash...', 'user');

// Multiple inserts in a transaction (much faster than individual inserts)
const insertMany = db.transaction((users) => {
    for (const user of users) {
        insert.run(user.email, user.username, user.passwordHash, user.role);
    }
});

insertMany([
    { email: 'ali@example.com', username: 'ali_sec', passwordHash: 'hash1', role: 'admin' },
    { email: 'khaled@example.com', username: 'khaled_pwn', passwordHash: 'hash2', role: 'user' }
]);

Querying data

// Get a single row
const user = db.prepare('SELECT * FROM users WHERE email = ?').get('omar@example.com');
// Returns undefined if not found

// Get all rows
const allUsers = db.prepare('SELECT * FROM users ORDER BY created_at DESC').all();

// Get first row or null
const admin = db.prepare('SELECT * FROM users WHERE role = ?').get('admin');

// Iterator (memory efficient for large result sets)
const iterator = db.prepare('SELECT * FROM users').iterate();
for (const user of iterator) {
    console.log(user.email);
}

// Pagination
const page = db.prepare('SELECT * FROM users ORDER BY id LIMIT ? OFFSET ?').all(20, 0);

WAL mode - Write-Ahead Logging

SQLite's default journal mode (DELETE) locks the entire database file during writes, blocking all reads until the write completes. WAL mode changes this by writing changes to a separate WAL file, allowing concurrent reads during writes

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

// Check current mode
const mode = db.pragma('journal_mode');
// ['wal'] - if it worked

// WAL checkpoint (force WAL content back to main database)
db.pragma('wal_checkpoint(TRUNCATE)');

WAL mode is dramatic performance improvement for read-heavy workloads with occasional writes. Without it, a single write blocks all reads. With it, reads continue uninterrupted while writes are buffered in the WAL file

Concurrency limitations

SQLite's concurrency model is "one writer at a time, many concurrent readers." This is fine for 99% of applications but problematic under specific conditions:

// Multiple processes trying to write simultaneously:
// Process 1 starts a write transaction
// Process 2 tries to write - SQLITE_BUSY error

// Handling busy errors with retry
function withRetry(fn, maxRetries = 5) {
    for (let i = 0; i < maxRetries; i++) {
        try {
            return fn();
        } catch (err) {
            if (err.code === 'SQLITE_BUSY' && i < maxRetries - 1) {
                // Exponential backoff - wait longer between retries
                const waitMs = Math.min(10 * Math.pow(2, i), 1000);
                Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, waitMs);
                continue;
            }
            throw err;
        }
    }
}

// With better-sqlite3's WAL mode:
// Use explicit transactions for atomic multi-statement operations
const transfer = db.transaction((fromId, toId, amount) => {
    const from = db.prepare('SELECT balance FROM accounts WHERE id = ?').get(fromId);
    if (from.balance < amount) throw new Error('Insufficient funds');
    db.prepare('UPDATE accounts SET balance = balance - ? WHERE id = ?').run(amount, fromId);
    db.prepare('UPDATE accounts SET balance = balance + ? WHERE id = ?').run(amount, toId);
});

SQLite vs Postgres - the tradeoffs

Feature SQLite PostgreSQL
Setup Zero config - just a file Server install, users, config
Concurrency One writer Many concurrent writers
Replication None Streaming replication, logical replication
Network access File system only TCP/IP connections
Storage Single file Multiple files, tablespaces
Extensions Limited Rich ecosystem (PostGIS, pgcrypto, etc.)
JSON support JSON functions (limited) JSONB with full indexing
Full-text search FTS5 extension GIN + tsvector
Scale GBs, low concurrency TBs, high concurrency
Backup Copy the file pg_dump, WAL archiving

Security considerations

SQLite has no user authentication, no access control, no network encryption - because it's a file on disk, not a network service. Security is entirely the responsibility of the file system and application layer

// Security best practices for SQLite:

// 1. Prevent access via file permissions
// chmod 600 myapp.db (only owner can read/write)
// chown appuser:appgroup myapp.db

// 2. Parameterize ALL queries (SQL injection still applies)
const stmt = db.prepare('SELECT * FROM users WHERE email = ?');
stmt.get(userInput);  // Safe - parameterized

// 3. Don't use SQLite for sensitive production data
// SQLite has no encryption at rest (unless using SEE extension)
// Anyone who copies the .db file has all your data

// 4. Enable WAL mode for concurrent read safety
// 5. Use backup API for safe backups
db.backup('backup.db');  // Creates a consistent snapshot while database is running

// 6. Attach database caution
// ATTACH DATABASE 'other.db' AS other - avoid this with untrusted files

prerequisites

db_05_postgres_security.md - knowing Postgres patterns helps you understand where SQLite excels (simplicity, portability) and where it falls short (everything security-related). The SQL you use is the same, just without the server overhead


next → db_14_prisma_intro.md