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