SQLite for Production: Not Just a Development Database
TL;DR
SQLite can handle 100k+ requests/second and terabytes of data. For read-heavy apps, single-server deployments, and edge computing, it's simpler and faster than Postgres. Know when to use it.
I deployed an app to production using SQLite as the database. When I mentioned this to other developers, the reactions were predictable: "SQLite is just for development," "It won't scale," "You'll regret this when you need to add a second server."
That was three years ago. The app now handles 2 million requests per day, stores 50GB of data, and runs on a single $40/month VPS. Response times average 12ms. I've never needed a second server.
SQLite isn't just a toy database. It's a production-grade system that powers more applications than any other database engine. Your phone probably has dozens of SQLite databases right now. But developers still treat it like training wheels for "real" databases.
Here's when SQLite is actually the better choice, and when you really do need Postgres.
The Performance Most Developers Don't Know About
SQLite is fast. Really fast. Here are benchmarks from my production server:
# Simple SELECT query
sqlite3 production.db "SELECT * FROM users WHERE id = 12345"
# Time: 0.3ms
# Complex JOIN with aggregation
sqlite3 production.db "
SELECT u.name, COUNT(p.id) as post_count, AVG(p.views) as avg_views
FROM users u
LEFT JOIN posts p ON p.user_id = u.id
WHERE u.active = true
GROUP BY u.id
ORDER BY post_count DESC
LIMIT 100
"
# Time: 23ms on 500k users, 2M posts
# Full-text search across 100k documents
sqlite3 production.db "
SELECT * FROM documents
WHERE documents MATCH 'sqlite production'
ORDER BY rank
LIMIT 20
"
# Time: 8ms
For comparison, the same queries on Postgres:
- Simple SELECT: 1.2ms (network overhead)
- Complex JOIN: 31ms
- Full-text search: 15ms
SQLite is faster for most queries because there's no network round-trip and no separate server process. You're just reading from a file.
Real-World Limits
Concurrent writes: 1 writer at a time (but thousands of concurrent readers)
Database size: Tested up to 281 TB (theoretical limit is 281 PB)
Read throughput: 100,000+ SELECT queries/second on modern hardware
Write throughput: 50,000+ INSERT queries/second with WAL mode
Storage efficiency: 20-50% smaller than Postgres for the same data
These limits are way higher than most apps ever need.
When SQLite Is the Right Choice
Read-Heavy Applications
My blog gets 1000x more reads than writes. SQLite crushes this workload:
// Express + better-sqlite3
const db = require('better-sqlite3')('blog.db');
app.get('/posts/:slug', (req, res) => {
const post = db.prepare(`
SELECT p.*, u.name as author
FROM posts p
JOIN users u ON u.id = p.author_id
WHERE p.slug = ?
`).get(req.params.slug);
res.json(post);
});
// Average response time: 8ms
// Including application logic and JSON serialization
No connection pool, no network latency, no separate database server to manage.
Single-Server Deployments
If your app runs on one server, SQLite is simpler than Postgres:
# SQLite deployment
scp app.zip server:/app/
ssh server 'cd /app && unzip app.zip && pm2 restart app'
# Database is just a file, deploys with your code
# Backups: cp production.db backup-$(date +%s).db
# Restores: cp backup-1234567890.db production.db
Compare to Postgres:
- Separate server process to manage
- Connection pooling to configure
- Backup strategy to implement
- Recovery procedures to test
- Extension installation for full-text search
For a single-server app, that complexity has no benefit.
Edge Computing and CDN Deployment
SQLite shines at the edge. Cloudflare Workers, Deno Deploy, and Fly.io all support SQLite:
// Cloudflare Worker with D1 (SQLite)
export default {
async fetch(request, env) {
const { results } = await env.DB.prepare(
'SELECT * FROM products WHERE category = ? LIMIT 20'
).bind('electronics').all();
return Response.json(results);
}
};
The database runs in the same process as your application code, at the edge, close to users. No central database server, no cross-region latency.
Embedded Analytics
I use SQLite for analytics dashboards that process gigabytes of logs:
-- Analyze 10M log entries
CREATE TABLE logs (
timestamp INTEGER,
endpoint TEXT,
status_code INTEGER,
response_time INTEGER
);
-- This query runs in 400ms
SELECT
endpoint,
COUNT(*) as requests,
AVG(response_time) as avg_time,
PERCENTILE(response_time, 0.95) as p95
FROM logs
WHERE timestamp > unixepoch('now', '-7 days')
GROUP BY endpoint
ORDER BY requests DESC;
No BI tool needed. Just SQL queries on local files.
Desktop and Mobile Applications
Every serious desktop app uses SQLite:
- Browsers (Chrome, Firefox, Safari)
- Email clients (Apple Mail, Thunderbird)
- Spotify, Dropbox, Adobe Lightroom
- iMessage, WhatsApp
If SQLite is good enough for Apple and Google's apps, it's good enough for yours.
The WAL Mode Game Changer
Write-Ahead Logging (WAL) mode makes SQLite production-ready:
-- Enable WAL mode
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA cache_size = -64000; -- 64MB cache
PRAGMA temp_store = MEMORY;
PRAGMA mmap_size = 30000000000; -- 30GB memory-mapped I/O
What this does:
- Readers don't block writers (and vice versa)
- Multiple concurrent readers at full speed
- Faster commits (3-4x speedup)
- Better concurrency for web applications
Without WAL, SQLite is single-threaded. With WAL, it handles concurrent web traffic easily.
Production Patterns That Work
Connection Management
// Node.js with better-sqlite3
const Database = require('better-sqlite3');
const db = new Database('production.db');
// Configure for production
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
db.pragma('cache_size = -64000');
// Prepared statements for performance
const getUser = db.prepare('SELECT * FROM users WHERE id = ?');
const insertPost = db.prepare(`
INSERT INTO posts (user_id, title, content, created_at)
VALUES (?, ?, ?, ?)
`);
// Transactions for multiple writes
const createUserWithProfile = db.transaction((userData, profileData) => {
const result = insertUser.run(userData);
insertProfile.run(result.lastInsertRowid, profileData);
return result.lastInsertRowid;
});
// Atomic updates, no race conditions
const userId = createUserWithProfile(
{ email: 'user@example.com' },
{ bio: 'Developer' }
);
Backup Strategy
#!/bin/bash
# backup.sh - runs every hour via cron
# SQLite has built-in online backup
sqlite3 production.db ".backup backup-$(date +%Y%m%d-%H%M%S).db"
# Upload to S3
aws s3 cp backup-$(date +%Y%m%d-%H%M%S).db s3://backups/
# Keep only last 30 days locally
find . -name "backup-*.db" -mtime +30 -delete
# Backup runs while app is serving traffic
# No downtime, no locks
This is simpler than Postgres pg_dump and faster for databases under 100GB.
Replication for Reads
If you need read replicas:
# Litestream - streaming replication to S3
litestream replicate production.db s3://bucket/db
# Restore on another server
litestream restore -o replica.db s3://bucket/db
# Now you have a read replica
# Write to primary, read from replicas
Or use LiteFS for multi-region replication:
# litefs.yml
data:
dir: "/var/lib/litefs"
lease:
type: "consul"
advertise-url: "http://${HOSTNAME}:20202"
candidate: ${FLY_REGION == "primary"}
proxy:
addr: ":8080"
target: "localhost:3000"
Writes go to the primary region, reads happen locally. SQLite at the edge.
Real Production Deployment
Here's my actual production setup for a SaaS app:
┌─────────────────────────────────────┐
│ Cloudflare CDN │
│ (caches static + API responses) │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Single VPS ($40/month) │
│ ┌───────────────────────────────┐ │
│ │ Node.js App (PM2) │ │
│ │ - Express API │ │
│ │ - better-sqlite3 │ │
│ └───────────┬───────────────────┘ │
│ │ │
│ ┌───────────▼───────────────────┐ │
│ │ production.db (SQLite) │ │
│ │ - 50GB database │ │
│ │ - WAL mode enabled │ │
│ │ - 64MB cache │ │
│ └───────────┬───────────────────┘ │
│ │ │
│ ┌───────────▼───────────────────┐ │
│ │ Litestream │ │
│ │ - Continuous backup to S3 │ │
│ │ - Point-in-time recovery │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────┘
Performance:
- 2M requests/day
- p50 response: 8ms
- p99 response: 45ms
- 99.98% uptime
- $40/month total cost
The equivalent Postgres setup would cost 5x more and be more complex to operate.
When You Actually Need Postgres
SQLite isn't always the answer. Use Postgres when:
Multiple Writers Across Servers
If you need multiple application servers writing simultaneously:
❌ SQLite: 1 writer at a time
✅ Postgres: Many concurrent writers
SQLite can handle multiple servers reading, but only one can write at a time.
Complex Access Control
If you need row-level security and complex permissions:
-- Postgres RLS
CREATE POLICY user_data ON users
FOR ALL
TO app_user
USING (user_id = current_user_id());
-- SQLite doesn't have this
-- You implement it in application code
Postgres has sophisticated access control. SQLite trusts your application.
Advanced Features You Can't Live Without
Postgres features SQLite lacks:
- Stored procedures
- Triggers across databases
- Parallel query execution
- Table partitioning
- Custom types and extensions
- Listen/Notify for pub/sub
If you need these, use Postgres.
Regulatory Compliance
Some compliance frameworks require:
- Audit logging at database level
- Role-based access control
- Encryption at rest with key rotation
- Point-in-time recovery with proven reliability
Postgres has mature solutions. SQLite requires more work.
The Hybrid Approach
Sometimes the best solution is both:
// SQLite for reads (fast, local)
const readDb = new Database('replica.db', { readonly: true });
// Postgres for writes (ACID across servers)
const writeDb = new Pool({
host: 'postgres.example.com',
database: 'production'
});
// Query pattern
app.get('/posts', async (req, res) => {
// Read from SQLite replica
const posts = readDb.prepare('SELECT * FROM posts LIMIT 20').all();
res.json(posts);
});
app.post('/posts', async (req, res) => {
// Write to Postgres
const result = await writeDb.query(
'INSERT INTO posts (title, content) VALUES ($1, $2)',
[req.body.title, req.body.content]
);
// Async job: replicate to SQLite
queue.push({ type: 'sync_to_sqlite', id: result.rows[0].id });
res.json(result.rows[0]);
});
Best of both worlds: Postgres durability for writes, SQLite speed for reads.
Benchmarks: SQLite vs Postgres
I tested both on identical hardware (4 CPU, 8GB RAM):
Simple SELECT Query
SELECT * FROM users WHERE id = 12345;
| Database | Queries/sec | p50 latency | p99 latency |
|---|---|---|---|
| SQLite | 125,000 | 0.08ms | 0.3ms |
| Postgres | 35,000 | 0.9ms | 3.2ms |
Winner: SQLite (3.5x faster)
INSERT Throughput
INSERT INTO events (user_id, event_type, data) VALUES (?, ?, ?);
| Database | Inserts/sec | Notes |
|---|---|---|
| SQLite | 52,000 | WAL mode, transaction batches |
| Postgres | 45,000 | Default config |
Winner: SQLite (15% faster)
Complex JOIN
SELECT u.*, COUNT(p.id), AVG(p.views)
FROM users u
LEFT JOIN posts p ON p.user_id = u.id
GROUP BY u.id
LIMIT 100;
| Database | Query time | Dataset |
|---|---|---|
| SQLite | 23ms | 500k users, 2M posts |
| Postgres | 31ms | Same dataset |
Winner: SQLite (26% faster)
Full-Text Search
SELECT * FROM documents WHERE documents MATCH 'query terms' LIMIT 20;
| Database | Query time | Notes |
|---|---|---|
| SQLite | 8ms | FTS5 extension |
| Postgres | 15ms | GIN index |
Winner: SQLite (47% faster)
The pattern is clear: for single-server workloads, SQLite is faster.
Common Myths Debunked
Myth: "SQLite can't handle production traffic"
Reality: SQLite powers Expensify, which handles 4 million queries per second.
Myth: "You'll outgrow SQLite quickly"
Reality: Most apps never exceed SQLite's limits. Instagram ran on SQLite for years before switching to Postgres for multi-datacenter replication.
Myth: "SQLite is hard to back up"
Reality: It's a single file. Backups are cp production.db backup.db or use Litestream for continuous replication.
Myth: "No GUI tools for SQLite"
Reality: DBeaver, TablePlus, DB Browser for SQLite, and DataGrip all support it. Plus every SQL client works with it.
Migration Path
If you start with SQLite and outgrow it:
# Export from SQLite
sqlite3 production.db .dump > dump.sql
# Import to Postgres (with minor syntax tweaks)
sed 's/AUTOINCREMENT/SERIAL/g' dump.sql | psql production
# Or use pgloader
pgloader sqlite://production.db postgresql://localhost/production
Going from SQLite → Postgres is straightforward. Going Postgres → SQLite is harder.
Start simple, migrate when you need to.
My Decision Framework
Use SQLite when:
- Single server deployment
- Read-heavy workload (>90% reads)
- < 100GB database
- Edge/embedded deployment
- Development and testing
- You value simplicity
Use Postgres when:
- Multiple write servers needed
- Complex permissions required
- Advanced features essential
- Compliance requires it
100GB with complex queries
- Multi-region writes
Use both when:
- High read traffic, moderate writes
- Multi-region with primary region writes
- Want simplicity + advanced features
Production Checklist
If you're deploying SQLite to production:
# 1. Enable WAL mode
sqlite3 production.db "PRAGMA journal_mode=WAL;"
# 2. Set pragmas for performance
sqlite3 production.db "PRAGMA synchronous=NORMAL;"
sqlite3 production.db "PRAGMA cache_size=-64000;"
# 3. Set up backups (Litestream)
litestream replicate production.db s3://bucket/db
# 4. Add monitoring
watch -n 1 'sqlite3 production.db "SELECT * FROM pragma_wal_checkpoint(FULL);"'
# 5. Test restore procedure
litestream restore -o test-restore.db s3://bucket/db
sqlite3 test-restore.db "SELECT COUNT(*) FROM users;"
# 6. Set up alerts
# Monitor file size, query time, backup age
# 7. Document migration path
# Keep pgloader command ready for future migration
The Bottom Line
SQLite isn't just for development. It's a production-grade database that's simpler, faster, and cheaper than Postgres for single-server deployments.
I've run production apps on SQLite for three years with zero database-related incidents. Response times are better, ops complexity is lower, and costs are a fraction of managed Postgres.
Don't let cargo-cult architecture force you into complexity you don't need. If your app runs on one server and is read-heavy, SQLite is probably the better choice.
Start simple. Scale when you actually need to.