How to Fix Slow SQLite Queries
Most "slow SQLite" reports aren't about queries. They're about PRAGMAs that should have been set the first time the database was opened. A SQLite database with default settings, hammered by a multi-threaded app, will spend most of its time blocked on SQLITE_BUSY or fsyncing every write to disk. The same database with journal_mode = WAL, synchronous = NORMAL, and a sensible cache size will fly. The difference is half a dozen lines of configuration that most teams discover after their first incident.
This is what makes SQLite performance different from server databases. SQLite isn't a process you tune. It's a library you embed, and most of the tuning happens in the connection setup code. Once you've done that part right, the query optimizer is straightforward, the concurrency model is predictable, and the file is a million times faster than its reputation suggests.
This guide is the long version of "what every SQLite-backed app should be doing": the PRAGMAs that matter, the concurrency model and why apps deadlock on it, EXPLAIN QUERY PLAN, the index types and patterns, FTS5 and JSON1 for text and document workloads, the application-side tools (better-sqlite3, sqlite-utils, Datasette, litestream), and the patterns that turn SQLite into a serious production database.
Why SQLite queries get slow
Four patterns drive almost every slow-SQLite report:
-
Wrong PRAGMAs. Default
journal_mode = DELETEserializes readers and writers. Defaultsynchronous = FULLfsyncs on every commit. Default 2MB cache forces every working-set query to hit disk. Fix these and most "slow SQLite" disappears. -
Concurrency model misunderstood. SQLite has one writer at a time, full stop. Long writes block every other writer. Apps that try to "scale concurrency" by opening more connections often make things worse.
-
Missing index, no statistics. Like Postgres or MySQL.
EXPLAIN QUERY PLANshowsSCAN, add the right index, gain three orders of magnitude. Don't forgetANALYZE. -
The query is doing something that prevents index use. Function on indexed column, leading wildcard
LIKE,ORacross different columns. The mechanical query-rewrite section of every database performance post.
This post leads with the PRAGMA section because that's the fix that helps the most apps. If you've ever shipped a SQLite-backed app to production without setting WAL mode, you've probably already met the failure mode.
Step 1: The PRAGMAs you should set first
These are the settings worth applying to every production SQLite database, in roughly priority order.
journal_mode = WAL
The single most important setting. The default journal_mode = DELETE writes a rollback journal that serializes readers and writers: while someone writes, nobody can read, and vice versa. WAL (Write-Ahead Logging) lets readers proceed during writes:
This is set per database file, not per connection. Set it once after creating the database. WAL persists across connections.
WAL changes the file layout: you'll see a db.wal sidecar file and a db.shm shared memory file. They're part of the database. Don't delete them. Some old SQLite tools don't understand them; verify before using third-party utilities.
There's a small downside: WAL mode requires the storage to support shared memory. On NFS or some restricted filesystems, you might need journal_mode = TRUNCATE instead. On any local disk or modern cloud volume, WAL is fine.
In WAL mode, periodically run PRAGMA wal_checkpoint(TRUNCATE) to flush the WAL back to the main file. SQLite does automatic checkpoints, but on bursty write workloads the WAL can grow before checkpoint catches up. Most apps don't need to do this manually; if the WAL is consistently large, set wal_autocheckpoint lower.
synchronous = NORMAL
The default synchronous = FULL fsyncs on every commit. Safe but slow. NORMAL is safe enough for most apps in combination with WAL and is 2-10x faster:
The difference: FULL survives any kind of crash, including OS-level. NORMAL survives application crashes and clean shutdowns but a power loss at exactly the wrong moment could lose the last committed transaction. For most application workloads, NORMAL is the right setting.
OFF removes fsync entirely. Dramatically faster, and corrupts the database if anything goes wrong. Use for ephemeral databases (test fixtures, scratch space) and never for anything you care about.
cache_size
The default page cache is 2000 pages (about 8MB with the default 4KB page size). For a database hot path, this is tiny. Bump it to something reasonable:
The negative value is in KB (so 200MB here). A positive value is in pages. The negative form is easier to reason about; the positive form depends on page_size. The cache is per-connection.
For an application database, 100-500MB per connection is reasonable. For an embedded database in a phone app, smaller (10-50MB). The cache should be big enough to hold the indexes and hot pages of frequently-accessed tables.
mmap_size
Memory-mapped I/O lets SQLite read pages directly from the OS page cache without copying into its own cache. For read-heavy workloads on large databases, this is a big win:
256MB shown. On 64-bit systems, you can set this to gigabytes without much downside; the OS only pages in what's needed.
mmap helps reads, not writes. Writes still go through the regular page cache.
temp_store = MEMORY
Forces temporary B-trees (used by ORDER BY without an index, GROUP BY, DISTINCT) into memory instead of a temp file:
For most workloads, the temp data is small and fits in RAM. For huge sorts you might want disk-backed temp; in that case, leave the default.
busy_timeout
When the database is locked (because another connection is writing), the default behavior is to fail immediately with SQLITE_BUSY. That's almost never what an application wants:
SQLite will retry for 5 seconds before giving up. This is the difference between an app that handles concurrency well and one that pelts the developer with SQLITE_BUSY errors under load. Set it on every connection.
foreign_keys = ON
Foreign keys are not enforced by default. They're declared in the schema but only enforced if you turn them on per connection:
This isn't a performance setting, but it's the one most people forget to set in production. Set it in your connection initialization.
Putting it together
A reasonable production setup, applied to every new connection:
Some of these (like journal_mode) are file-level and persist. Most (like cache_size, busy_timeout) are connection-level and need to be set every time. better-sqlite3 (Node), sqlite3 (Python), rusqlite (Rust), and most other drivers let you set them in a connection-init callback. Use it.
Step 2: Understand the concurrency model
The single most consequential thing about SQLite for production apps is the concurrency model. It is different from every server database in this comparison.
One writer at a time
SQLite serializes all writes. There's no way around this. Two connections cannot simultaneously execute write transactions. The second one waits. With busy_timeout, it waits politely; without, it errors immediately.
This is fine if your write transactions are short. It's catastrophic if they're long. A 10-second write transaction blocks every other write for 10 seconds.
Practical implications:
- Don't do network I/O inside a write transaction. A
BEGIN; ... external API call; COMMIT;is a recipe for serialization hell. - Batch writes. Many small writes scattered through application code become many short transactions. Group them.
- Move slow logic out of the transaction. Compute the value you want to write, then write it.
WAL allows concurrent reads
With WAL mode, multiple readers can read concurrently, even while a writer is writing. The reader sees the database as of the moment the read transaction started; the writer's changes aren't visible until commit.
This is the killer feature of WAL. The default journal_mode = DELETE serializes everything. WAL gives you Postgres-like multi-version concurrency for reads.
Transaction types: DEFERRED, IMMEDIATE, EXCLUSIVE
When you BEGIN, the default is DEFERRED. The transaction doesn't acquire a lock until the first read or write operation. This sounds nice but creates a deadlock risk: two BEGIN DEFERRED transactions both promote to writer simultaneously, both block, both eventually error.
For transactions that you know will write, use BEGIN IMMEDIATE:
IMMEDIATE acquires the write lock at BEGIN. If another writer is already going, this transaction waits at BEGIN instead of mid-transaction. Cleaner failure mode.
For most apps, the pattern is:
- Read-only transactions:
BEGIN DEFERRED(or the default). - Write transactions:
BEGIN IMMEDIATE.
If you don't separate them, you'll get SQLITE_BUSY errors mid-transaction that are hard to diagnose.
Connection pooling
SQLite doesn't need traditional connection pooling, because connections are cheap and there's only one writer slot. But you do need to think about how many connections you keep open.
Patterns that work:
- One reader pool, one dedicated writer. Reads scale; writes are serialized through one connection.
- Per-thread connection. Each application thread has its own connection, with appropriate locking around writes.
What doesn't work: opening hundreds of connections expecting them to all write concurrently. They won't.
For Node.js, better-sqlite3 is synchronous and single-threaded; it's a natural fit for the model. For Python, the standard sqlite3 library is async-unfriendly; use it carefully with threading.Lock or move to aiosqlite with discipline about transactions.
Long readers can block writes too
Less obvious gotcha: in WAL mode, a read transaction holds a snapshot. If a read takes an hour, the WAL can't be checkpointed during that hour (because the snapshot might still need old pages). The WAL grows; writes get slower as it does.
For long-running reads, either use a separate read replica (yes, you can), or chunk the work into shorter transactions.
Step 3: Diagnose with EXPLAIN QUERY PLAN
SQLite doesn't have pg_stat_statements or a slow query log built in. The application is the profiler. Most drivers have hooks for this.
Time queries from the CLI
For interactive debugging:
.timer on
SELECT * FROM orders WHERE customer_id = 12345;
-- Run Time: real 2.834 user 0.012 sys 0.085
real is wall-clock time, which is what you usually care about.
Profile from the application
For richer tracing, set a profiler callback. better-sqlite3 has db.function; Python's sqlite3 has set_trace_callback; rusqlite has Connection::trace. Use them in development; ship without them in production.
EXPLAIN QUERY PLAN
Once you have a slow query, run EXPLAIN QUERY PLAN:
QUERY PLAN
`--SCAN orders
SCAN means a full table scan. On 500,000 rows, that's the problem.
The other words to know:
| Phrase | Meaning |
|---|---|
SEARCH ... USING INDEX ... | Index lookup. Good. |
SEARCH ... USING COVERING INDEX ... | Index covers the query, no table fetch. Great. |
SEARCH ... USING INTEGER PRIMARY KEY | Rowid lookup. Fastest possible. |
SCAN | Full table scan. On a large table, this is the problem. |
USE TEMP B-TREE FOR ORDER BY | Sorting without an index. Add an index that matches the sort. |
USE TEMP B-TREE FOR DISTINCT | Dedup without an index. Add an index. |
USE TEMP B-TREE FOR GROUP BY | Grouping without an index. Add an index. |
A healthy plan is full of SEARCH ... USING INDEX lines. A single SCAN on a table larger than a few thousand rows is suspect.
ANALYZE and sqlite_stat1
SQLite's query planner uses statistics from the sqlite_stat1 table. ANALYZE populates these:
Or for a specific table:
Without statistics, SQLite uses heuristics. They're often wrong on large tables, leading to the wrong index choice. Run ANALYZE after a bulk load, after a migration, or on any table where data distribution has shifted.
Inspect what the optimizer knows:
orders idx_orders_customer 500000 50
That row says: the table has 500,000 rows, and the index averages 50 rows per distinct customer_id. If your real distribution is wildly different (one customer has 100,000 orders, the rest have a handful), the optimizer might pick the wrong index because it assumes a uniform distribution. The fix is more advanced statistics:
PRAGMA optimize (added in 3.18) lets SQLite update stats incrementally based on its tracking of which tables changed. Worth running periodically.
sqlite3_analyzer
For deeper investigation of how space is used:
You get a long report with per-table page counts, fragmentation, B-tree statistics, and more. Useful for understanding why a file is huge or why a query is doing more I/O than expected.
The .expert command
The sqlite3 CLI has an .expert mode that suggests indexes for a query:
sqlite> .expert
sqlite> SELECT * FROM orders WHERE customer_id = 12345 AND status = 'pending';
CREATE INDEX orders_idx_xxxxxxxx ON orders(customer_id, status);
(no records returned)
Treat as a starting point, not an answer. The suggestion doesn't know your overall workload or partial-index opportunities.
Step 4: Index design
SQLite is B-tree-only at the core (with FTS5 and R-Tree as extensions). The strategy is similar to other relational databases, but a few SQLite-specific notes matter.
Single-column indexes
Verify with EXPLAIN QUERY PLAN that the planner picks the new index. If it doesn't, run ANALYZE.
Composite indexes
For queries that filter on multiple columns:
Leftmost-column rule applies. The index helps:
But not:
Put the most selective equality column first.
Covering indexes
If the index contains every column the query reads, SQLite never touches the table:
EXPLAIN QUERY PLAN shows USING COVERING INDEX. For dashboard queries with a small projection, this is the fastest possible read.
Partial indexes
Index only the rows that match a predicate:
The query must include the predicate for the planner to use the partial index. Partial indexes are dramatically smaller and faster on workloads with skewed distributions (most orders are settled, only the pending ones are queried).
Expression indexes
For queries that wrap columns in functions:
This is also the standard pattern for indexing inside JSON columns (more below).
WITHOUT ROWID tables
SQLite tables normally have an internal rowid, a 64-bit integer that's the actual storage key. The primary key is a regular index on top.
For tables with a non-integer primary key, WITHOUT ROWID makes the primary key the B-tree itself, eliminating a level of indirection:
Wins: smaller disk usage, faster primary-key lookups. Losses: secondary indexes are a bit bigger because they have to store the full primary key as the rowid equivalent.
The rule of thumb: use WITHOUT ROWID when the primary key is small (under ~32 bytes) and you don't have many secondary indexes.
Indexing JSON columns
SQLite has good JSON support via the built-in JSON1 functions. Index specific paths with expression indexes:
The query must use the same json_extract expression as the index for the planner to use it.
For more sophisticated patterns (multiple JSON paths, generated columns), see our post on SQLite JSON virtual columns and indexing.
When not to add an index
Indexes aren't free. SQLite uses copy-on-write internally, so an index update on a write-heavy table adds I/O. Avoid indexing:
- Boolean flags or other low-cardinality columns.
- Tables under a few thousand rows. A scan is faster than two B-tree lookups.
- Columns you never filter or sort on.
To find unused indexes, you can't do it from SQL directly (no pg_stat_user_indexes equivalent). Profile your queries with EXPLAIN QUERY PLAN over a representative workload.
Concurrent index builds
SQLite's CREATE INDEX locks the database. There's no online concurrent build like Postgres has. For a large table on a production database, the lock duration matters.
The pragmatic approach: do schema changes during maintenance windows, use small batched migrations where possible, or copy the data to a new table with the new index and swap atomically.
Work With Your Databases
Like A Pro
Query, explore, and manage your databases with a beautiful desktop app, collaborative web platform, and built-in AI.
Download Now
Step 5: Rewrite the query
The same mechanical patterns as other databases.
Avoid SELECT *
Reads every column, including big ones, defeats covering indexes:
Keyset pagination over OFFSET
Functions on indexed columns
UNION ALL over OR across columns
When OR spans different columns, SQLite often falls back to a scan:
Avoid LIKE with leading wildcards
For substring search, use FTS5 (below). For "starts with" matches, regular indexes work:
Batch inserts in a transaction
SQLite auto-commits each statement by default. For bulk inserts, wrap them:
This can be 100x faster than auto-commit because each commit otherwise fsyncs (depending on synchronous setting).
For very-bulk inserts (millions of rows), use prepared statements with INSERT ... VALUES (?, ?, ?) and reuse the prepared statement.
Step 6: FTS5 for text search
The built-in LIKE '%foo%' is a sequential scan with no index help. For any substantive text search, use FTS5: SQLite's built-in full-text search extension.
FTS5 supports prefix matching (MATCH 'sql*'), phrase queries (MATCH '"slow queries"'), boolean operators, and column filters (MATCH 'title: sqlite').
For more depth, see our SQLite FTS5 guide.
A few production notes:
- Use the
contentlessmode (content='') when you don't need to retrieve the original text from FTS, only search it. Saves substantial disk. - The
external contentmode (content=posts) keeps the main table as the source of truth and only indexes it. Requires triggers to keep in sync. rebuildrebuilds the index from scratch:INSERT INTO posts_fts(posts_fts) VALUES('rebuild');- For ranked queries, FTS5's BM25 ranking is exposed via the
rankcolumn on selects.
Step 7: JSON1 for document workloads
For semi-structured data, SQLite's JSON1 extension is on by default in modern versions:
The pattern that scales:
- Store JSON as
TEXTwith aCHECK (json_valid(data))constraint. - Index specific paths with expression indexes.
- Use
JSON_TYPE,json_each,json_treefor traversal.
For complex patterns including generated columns and array indexing, our post on SQLite JSON virtual columns and indexing covers the patterns. The short version: a generated column plus an index on it is the cleanest way to index a JSON path.
SQLite 3.45 added JSONB (binary JSON) for faster operations, similar to Postgres jsonb. If you're on a recent version, it's worth using.
Step 8: Configuration beyond PRAGMAs
Most SQLite "configuration" is the PRAGMA block from Step 1. A few additional knobs.
page_size
Set once, at database creation time. Must be a power of 2 between 512 and 65536. Default is 4096, which matches most filesystems. Common reasons to override:
- 8192 or 16384 for SSDs and modern workloads. Slight performance win at the cost of small databases.
- Larger pages for read-mostly databases with big rows.
Once a database is created, changing page_size requires a VACUUM. Pick at create time.
auto_vacuum
Default is NONE: the file only grows. Deletions leave empty pages in the file. To reclaim, run VACUUM manually.
FULL: rebalances after every delete. Expensive.
INCREMENTAL: tracks free pages, lets you reclaim them on demand with PRAGMA incremental_vacuum. The middle ground.
For most apps, NONE is fine; databases grow but stay efficient. For apps with significant churn, INCREMENTAL with a periodic PRAGMA incremental_vacuum(N) is the right pattern.
wal_autocheckpoint
Defaults to 1000 pages. The WAL gets checkpointed automatically when it crosses this threshold. For workloads with sustained write throughput, you may want a larger threshold to amortize the checkpoint cost:
For workloads where you want to keep the WAL small (perhaps to reduce reader-blocked-on-writer scenarios), lower it.
Threading mode
SQLite has three threading modes:
- Single-thread: not safe at all to share connections.
- Multi-thread: each connection used by one thread at a time (default).
- Serialized: connections can be used by multiple threads (with internal locking).
Most drivers expose this as a compile-time or runtime option. The default multi-thread is right for most apps; each thread gets its own connection.
Step 9: Common slow query patterns
Writer contention without WAL
In default DELETE journal mode, every reader blocks every writer and vice versa. Symptom: writes occasionally take seconds for no clear reason. Fix: WAL.
Long-running writers
Even in WAL mode, only one writer runs at a time. A transaction that takes a second blocks every other write for that second. Keep write transactions short. Move slow logic out of the transaction boundary.
Missing index on foreign keys
SQLite doesn't auto-index foreign key columns. They're a common cause of slow joins:
This is so common that some app frameworks add it by default; if yours doesn't, add it explicitly.
Sorting without an index
USE TEMP B-TREE FOR ORDER BY in the plan. The sort runs in memory or temp file:
auto_vacuum surprises
auto_vacuum = FULL is sometimes set thinking it'll save disk space. It rebalances on every delete and is slow. Change to INCREMENTAL or NONE.
Schema changes locking the database
ALTER TABLE ADD COLUMN is fast in modern SQLite. ALTER TABLE DROP COLUMN (since 3.35) is fast for simple cases. But some DDL still rewrites the entire table, holding an exclusive lock:
- Changing a column type.
- Adding a
FOREIGN KEYconstraint. - Reordering columns.
For these, use the INSERT INTO new_table SELECT ... FROM old_table; DROP TABLE old_table; ALTER TABLE new_table RENAME TO old_table; pattern, or accept the maintenance window.
N+1 queries from ORMs
Same problem as in any other database. SQLite makes it worse in some ways (every query has prepare overhead even though it's in-process), better in others (no network round-trip). Use eager loading or batch with IN:
Big TEXT/BLOB columns
A row with one giant column is read in full unless you project around it:
For large blobs, consider storing them outside the database (in object storage) and keeping only the reference in SQLite.
Cross-database joins via ATTACH
ATTACH DATABASE 'other.sqlite' AS other lets you query across databases. Joins across attached databases work but with less optimization than within a single file. For high-traffic cross-database queries, denormalize into the primary database.
Stale statistics
After a bulk load or migration, ANALYZE may be stale. If a query that used to be fast turned slow after a data change, try ANALYZE before debugging deeper.
Step 10: When tuning isn't enough
SQLite handles surprisingly large workloads. The official docs note SQLite handles 281 TB databases. Most apps will hit operational limits long before they hit any technical SQLite limit. But here are the escape hatches when you need them.
Litestream for backup and replication
Litestream is a single-binary tool that continuously replicates SQLite to S3 (or any S3-compatible store). It works by tailing the WAL and shipping it to object storage in real-time:
Recovery is litestream restore. For production SQLite, this is the standard backup approach. Don't run a production SQLite database without it or an equivalent.
Read replicas
You can run "read replicas" by replicating with Litestream to another machine and restoring continuously. There's some lag, but for read-heavy workloads it gives you Postgres-like read scaling.
LiteFS (also from the maker of Litestream) takes this further: it presents a SQLite database as a distributed filesystem that replicates over the network. Worth investigating for serverless and edge use cases.
Embedded vs network access
SQLite is in-process. If you want it accessed over the network, you'll need a wrapper. The common patterns:
- rqlite: a distributed SQLite via Raft.
- Turso: edge-distributed managed SQLite (libSQL fork).
- Cloudflare D1: SQLite-backed serverless database.
These trade SQLite's "library, not a server" simplicity for network access. Each has a different set of trade-offs around durability, replication, and cost.
Sharding by file
For pure horizontal scale: many SQLite files, one per tenant or per shard. Many production apps run this way; one database file per customer, queried per request. Disk is cheap, SQLite is fast, the file boundary is a natural shard boundary.
The tools worth installing
| Tool | What it does | Cost |
|---|---|---|
sqlite3 CLI | Official command-line tool | Free, built in |
.expert mode | Index suggestions | Free, built in |
sqlite3_analyzer | File analysis report | Free, built in |
better-sqlite3 (Node) | Synchronous high-performance driver | Free |
sqlite-utils (Python) | Simon Willison's CLI/library for ops | Free |
| Datasette | Web UI and JSON API over SQLite | Free |
| DB Browser for SQLite | GUI client | Free |
| Litestream | Continuous backup to S3 | Free |
| LiteFS | Distributed SQLite via FUSE | Free |
| Beekeeper Studio / Tableplus | GUI clients | Paid |
| sqlpkg.org | Registry of SQLite extensions | Free |
For most projects, the right starting set is: better-sqlite3 (or your language's equivalent), Litestream for backups, sqlite-utils for ad-hoc manipulation, and Datasette for serving the database as a quick API. That covers 95% of real-world SQLite operations.
sqlite-utils
sqlite-utils deserves special mention. It's a CLI and Python library for working with SQLite databases. The CLI is especially good for:
- Importing CSV, JSON, or stdin into a table with auto-detected schema.
- Adding columns, indexes, foreign keys.
- Bulk operations.
- Quick analysis.
If you do any data work on SQLite, install it.
Datasette
Datasette is the "publish a SQLite database as a web API" tool. Point it at a file and get a UI, a JSON API, faceted browsing, full-text search, and visualizations:
Built by Simon Willison; widely used for journalism, internal tools, and rapid prototyping. Worth knowing even if you don't use it daily.
Quick checklist
When you encounter a slow SQLite query or workload:
- Set the basic PRAGMAs. WAL, synchronous=NORMAL, cache_size, mmap, busy_timeout. If you haven't, do this before anything else.
- Run EXPLAIN QUERY PLAN. Look for
SCANon a large table. Add the missing index. - Run ANALYZE. Stale statistics cause wrong plan choices.
- **Check for SELECT *** and
OFFSETpagination. Both have mechanical fixes. - Check write-transaction shape. Long transactions block every other writer.
- Use BEGIN IMMEDIATE for writes. Deferred transactions create subtle deadlocks.
- For text search, switch to FTS5.
LIKE '%foo%'doesn't scale. - For JSON data, expression-index the paths you query. JSON1 plus an index is fast.
- Confirm with timing. Reproduce the slow query, apply the fix, re-time.
Most slow SQLite issues come down to PRAGMA defaults, missing indexes, or a misunderstanding of the concurrency model. Once you've gotten those three right, SQLite is a remarkable production database. The reputation for "toy database" comes from the easy mistakes; the reality is one of the most production-ready embedded engines in the world.
Keep Reading
How to Fix Slow ClickHouse Queries
A practitioner's guide to ClickHouse performance. Primary key design, partition pruning, skip indexes, projections, PREWHERE, materialized views, the join strategies that scale, insert patterns, and the system tables worth knowing.
How to Fix Slow Redis Queries
A practitioner's guide to diagnosing and fixing slow Redis workloads. Why Redis slowness is fundamentally different from SQL, the big-key and hot-key problems, SLOWLOG and LATENCY, picking the right data structure, and the configuration that prevents outages.
How to Fix Slow MongoDB Queries
A practitioner's guide to diagnosing and fixing slow MongoDB queries. The diagnostic stack, reading explain output, indexing with the ESR rule, schema design that holds up at scale, aggregation pipeline optimization, and the tools worth knowing.