SQLite in Production: When It's the Right Choice, WAL Mode, Backups, and Litestream Replication
SQLite has a reputation problem. Most developers learned it as the toy database — the thing you use in mobile apps, unit tests, and prototypes before graduating to a “real” database like PostgreSQL or MySQL. The assumption is that SQLite doesn’t belong in production.
That assumption is wrong in a specific and interesting way. SQLite is the most widely deployed database engine in the world — it runs in every Android phone, every iPhone, every browser, every macOS and Windows installation, and billions of embedded devices. The SQLite authors estimate it handles more database queries per day than all other database engines combined.
The question isn’t “is SQLite production-ready?” It is. The question is: “is SQLite the right choice for your production workload?” This guide gives you the framework to answer that, plus the operational knowledge to run it well when the answer is yes.
What SQLite Actually Is
SQLite is not a client-server database. There’s no daemon, no network socket, no connection pool, no authentication layer. It’s a library that reads and writes a single file on disk. Your application links against the library and talks to the file directly.
This architecture has profound implications:
What you gain:
- Zero operational overhead — no server to install, configure, monitor, or update separately
- Zero network latency — queries run in-process, not over a socket
- Zero connection setup cost — no TCP handshake, no authentication round-trip
- Atomic transactions on a single file — the database is just a file you can copy
- Trivially portable — copy the file, you’ve moved the database
What you give up:
- Multiple processes writing simultaneously (concurrent reads are fine, concurrent writes serialize)
- Network access from multiple hosts (the file must be local or on a reliable network mount)
- Fine-grained access control (file permissions are your only ACL)
- Some advanced SQL features (no window functions with frame exclusion, no RIGHT JOIN historically, limited ALTER TABLE)
The architecture that makes SQLite wrong for a replicated multi-region service is the same architecture that makes it perfect for a single-server application that wants zero database operational overhead.
When SQLite Is the Right Choice
The Read-Heavy Single-Server Application
The majority of web applications follow the same traffic pattern: many more reads than writes, a single primary server, and database queries that take microseconds. For these applications, SQLite is not just adequate — it’s often faster than PostgreSQL because queries run in-process rather than over a socket.
Benchmarks consistently show SQLite outperforming PostgreSQL for read-heavy in-process workloads. The absence of network round-trips is simply faster than even a local socket.
Embedded Tools and CLIs
Any tool that needs persistent state without imposing database installation on users is a natural fit. CLIs, desktop apps, developer tools, agents that store conversation history or job queues — SQLite is the obvious choice.
Low-to-Medium Write Throughput Applications
SQLite’s serialized writes are only a bottleneck if you actually have high concurrent write throughput. Most applications don’t. A blog, a personal finance tool, a documentation site, an internal dashboard, a webhook processor — these rarely push SQLite’s write limits.
The real-world threshold where SQLite’s write serialization becomes a bottleneck is higher than most people think. With WAL mode (covered below), a modern SSD, and well-structured transactions, SQLite handles hundreds to thousands of writes per second on realistic workloads.
When You Want the Database as a File
SQLite’s single-file format is an operational superpower. Want to take a point-in-time backup? Copy the file (with proper locking — more on this below). Want to move the database to a different server? scp the file. Want to diff two versions of the database? Tools like sqldiff can do it. Want to query production data locally? Copy the file to your laptop.
The “One App, One Server” Pattern
Applications that run on a single VPS or dedicated server with no multi-region requirement are perfect SQLite candidates. K3s on Raspberry Pi, personal tools, small SaaS applications in early stages, internal tools for a team of 50 — all of these can be served well by SQLite with a fraction of the operational complexity of a Postgres deployment.
When SQLite Is the Wrong Choice
- Multiple application servers writing to the same database — SQLite requires the file to be local; shared network filesystems (NFS, EFS) work for reads but are unreliable for writes
- Write throughput above a few thousand transactions per second — at this point you need PostgreSQL’s parallel write capability
- Complex multi-statement transactions across unrelated data — SQLite handles this but the serialization becomes more painful
- Strict compliance requirements — some compliance frameworks require server-based databases for audit trail reasons
- Team unfamiliarity — operational knowledge of SQLite in production is less common than PostgreSQL; if your team doesn’t know it, factor in the learning curve
WAL Mode: The Most Important Setting
By default, SQLite uses a “delete” journal mode: before modifying a page, it copies the original to a rollback journal file. This means readers and writers block each other — a write blocks all reads until it completes.
Write-Ahead Logging (WAL) mode inverts this: changes are first written to a separate WAL file, then applied to the database file during a “checkpoint.” Readers see a consistent snapshot of the database at the point their read began. Writers and readers no longer block each other.
Enable WAL mode:
|
|
This is a persistent setting — run it once and it sticks for the lifetime of the database file.
The Impact
| Mode | Concurrent readers | Readers block writers? | Writers block readers? |
|---|---|---|---|
| DELETE (default) | 1 (readers serialize) | Yes | Yes |
| WAL | Unlimited | No | No |
With WAL mode, you get true read concurrency: any number of readers can proceed simultaneously while a write is in progress. This resolves the most common SQLite performance complaint for web applications.
Full WAL Configuration
|
|
These pragmas need to be set on each connection open (except journal_mode which persists). In practice, set them in your connection initialization code:
|
|
|
|
The Connection Pool Gotcha
When using SQLite through a connection pool (most ORMs and database drivers use pools by default), you must configure the pool carefully:
- Writers: Set max connections to 1. Multiple goroutines/threads attempting concurrent writes will serialize at the SQLite level anyway, and a pool size > 1 adds overhead without benefit.
- Readers: Can use multiple connections. Consider a separate read-only connection pool.
The most common “SQLite is slow” complaint in Go and Java applications is traced to connection pool misconfiguration — multiple writer connections fighting over the write lock and triggering SQLITE_BUSY errors.
|
|
Schema Design for SQLite
SQLite is flexible with types but has some specifics worth knowing.
Type Affinity
SQLite uses type affinity rather than strict types. A column declared INTEGER will coerce values that look like integers but will also accept text. This is usually fine but can surprise developers from strict-typed databases.
For strict typing (SQLite 3.37+), use STRICT tables:
|
|
Storing Dates and Times
SQLite has no native datetime type. The common patterns:
|
|
Unix timestamps are generally preferable — they’re smaller, faster to sort and compare, and unambiguous.
JSON Support
SQLite has solid JSON support via json_extract, json_object, json_array, and the JSON5 dialect:
|
|
Backup Strategies
The Naive Approach (Dangerous)
|
|
Copying a live SQLite file without proper locking can produce a corrupt backup — pages mid-write may be copied in an inconsistent state.
The Right Approach: SQLite Online Backup API
SQLite’s built-in backup API is designed exactly for this: it takes a consistent snapshot of a running database without locking writers for the entire duration.
|
|
In Python:
|
|
Scripted Backup to S3 / Object Storage
|
|
As a systemd timer:
|
|
Litestream: Continuous Replication
Nightly backups are fine until something goes wrong at 2:59 AM. Litestream is an open-source tool that streams SQLite WAL changes to S3-compatible object storage in near real-time, giving you continuous replication with sub-second RPO.
Think of it as the logical equivalent of Postgres streaming replication, but for SQLite, outputting to S3 rather than a standby server.
How Litestream Works
Litestream runs as a sidecar process alongside your application. It monitors the SQLite WAL file and periodically (every second by default) uploads WAL frames to S3. On failure, you restore from the most recent snapshot plus all subsequent WAL frames — typically recovering to within seconds of the crash.
Installation
|
|
Configuration
|
|
Configure S3 credentials via environment variables:
|
|
Running as a Systemd Service
|
|
The ExecStartPre line is key: when the service starts on a fresh server (disaster recovery scenario), it first attempts to restore the database from the replica before starting replication. If the replica doesn’t exist (first run), it does nothing.
Enable and start:
|
|
Restoring from Litestream
|
|
Litestream in Docker
|
|
Or run Litestream and your app in a single container using the exec subcommand — Litestream wraps your process and handles replication:
|
|
With -exec, Litestream first restores the database if a replica exists, starts your application, and handles replication for the lifetime of the process. When the app exits, Litestream exits too. This is the cleanest pattern for containerized deployments.
Performance Tuning
Batch Writes with Explicit Transactions
SQLite defaults to auto-commit: each statement is its own transaction, including an fsync. This is the single most common reason people see SQLite as slow.
|
|
Batching 1000 individual inserts into a single transaction can speed up write throughput by 100–1000×.
ANALYZE and Query Planning
Like PostgreSQL, SQLite uses query statistics to plan queries. Refresh them periodically:
|
|
Covering Indexes
A covering index includes all columns a query needs, avoiding a table lookup:
|
|
VACUUM and Auto-Vacuum
SQLite doesn’t reclaim space from deletes by default. Configure auto-vacuum:
|
|
Monitoring and Introspection
|
|
SQLite vs. PostgreSQL: The Decision Framework
Is your application read-heavy (>90% reads)?
→ Yes: SQLite is likely faster due to in-process access
→ No: Continue...
Do multiple application servers need to write to the database?
→ Yes: Use PostgreSQL
→ No: Continue...
Do you need > 1,000 concurrent write transactions/second sustained?
→ Yes: Use PostgreSQL
→ No: SQLite can likely handle it
Do you need streaming replication to a hot standby?
→ Yes: PostgreSQL (or SQLite + Litestream for near-real-time RPO)
→ No: SQLite with scheduled backups may be sufficient
Do you value operational simplicity above all else?
→ Yes: SQLite — no server, no connection management, file is the database
→ Depends on other requirements
The honest summary: SQLite is a legitimate production choice for the majority of web applications and tools that run on a single server. It’s faster than developers expect, simpler than any server database, and with Litestream it has a compelling disaster recovery story. The applications that genuinely need PostgreSQL are those with high write concurrency, multi-server architectures, or complex requirements like logical replication and row-level security — not “we expect to grow someday.”
Start with SQLite. Migrate to PostgreSQL when you have a concrete reason to, not when you think you might eventually need to.
Comments