A SQLite file open in Pilotbase, with a query that counts how many values in an INTEGER column are really stored as text

SQLite is a fine production database for a lot of apps: one server, a few hundred writes a second, reads that are mostly local. Our SQLite to Postgres piece is about the day you leave it. This one is about staying, and about the handful of settings that decide whether SQLite feels solid or throws database is locked at your users.

SQLite's defaults are built for compatibility, not for a web app with several workers. Out of the box you get a rollback journal, no busy timeout, foreign keys that are parsed but not enforced, and columns that take whatever you put in them. We measured what each default costs and what fixing it buys, on a Mac mini with an Apple M4: SQLite 3.53.4 through Python's sqlite3 module, and the 3.54.0 command-line shell.

Four writers, six settings

The test: four processes write to one file at the same time while a fifth reads. Each write is the kind of transaction apps actually run, a read followed by a write: look up an account balance, update it, insert a ledger row, commit. Between transactions each writer spends 1 ms on other work, the way a request handler would. Each setting ran for 5 seconds on a fresh file with 10,000 accounts and 200,000 ledger rows.

text
4 writers + 1 reader, 5 s           committed   failed         write p99   reader

default (rollback journal)            8,534      8,741 (51%)    0.39 ms     148 of 346 reads failed
+ busy_timeout 5000                   5,070        172 (3%)    36.7 ms     p99 140 ms, max 156 ms
WAL                                  13,608      5,332 (28%)    0.14 ms     never blocked, max 2.7 ms
WAL + busy_timeout 5000              13,605      5,345 (28%)    0.14 ms     never blocked
WAL + busy_timeout + BEGIN IMMEDIATE  17,142          0          0.32 ms     never blocked
  ... + synchronous = NORMAL         18,134          0          1.36 ms     never blocked

Every failure was the same error, database is locked (SQLITE_BUSY). Each run ended with a check that the account balances added up to the number of committed writes, and they always did: nothing was corrupted or half-written. The failures are writes your app has to retry, or more likely shows to a user as an error.

The default: half the writes fail, and readers too

With the default rollback journal, a writer locks the whole file while it commits, and SQLite's default busy timeout is zero, so anyone who finds the file locked gets an error straight away. Half the writes failed, and so did 148 of the reader's 346 queries. A plain SELECT failing with database is locked is the classic first symptom.

Setting a busy timeout makes a connection wait for the lock instead of failing. Failures dropped from 51% to 3%, but the reader now waited up to 156 ms behind the writers, and the 172 failures that remained are deadlocks no timeout can fix: two transactions both read, both want to write, and one of them has to give up. Many drivers set a timeout for you (Python's sqlite3 uses 5 seconds unless you pass timeout=; we set it to 0 and used the pragma so the test controls it). Check what yours does.

WAL: readers stop waiting

Write-ahead logging changes how commits work. New pages go to a separate -wal file, and readers keep reading the version of the database that existed when they started. Readers and the writer no longer block each other: the reader never failed and its slowest query took 2.7 ms. Commits also got cheaper (p99 0.39 ms to 0.14 ms), because a commit is one append to the log instead of a journal write plus the database write.

sql
PRAGMA journal_mode = WAL;   -- once; it is stored in the file and stays on

There is still only one writer at a time. WAL helps everybody else wait less for it.

Why busy_timeout didn't help, and BEGIN IMMEDIATE did

The surprise is in the fourth row. Adding a 5-second busy timeout to WAL changed nothing: still 28% failed, and they failed instantly. The timeout was there; SQLite just didn't use it.

The reason is the read-then-write shape. A plain BEGIN is deferred: the transaction starts as a reader, and only asks for the write lock at the UPDATE. If another connection has committed since this one started reading, its snapshot is out of date, and waiting can't fix that: whatever it read may already be wrong. So SQLite returns SQLITE_BUSY at once instead of waiting (some errors even say so: 501 of them here were labelled SQLITE_BUSY_SNAPSHOT, and 48,563 in a run without the 1 ms pause). The SQLite documentation is explicit that the busy handler is skipped when waiting could deadlock.

The fix is to ask for the write lock first:

sql
BEGIN IMMEDIATE;
SELECT balance FROM accounts WHERE id = ?;
UPDATE accounts SET balance = balance + 1 WHERE id = ?;
INSERT INTO ledger (account_id, amount, at) VALUES (?, 1, ?);
COMMIT;

BEGIN IMMEDIATE takes the write lock at the start, so the transaction waits (using the busy timeout) before it reads anything, and it can never be overtaken halfway. Zero failures, and more commits than any setting above it, because no work was thrown away. Use it for every transaction that might write, and keep plain BEGIN for read-only ones. Most ORMs and drivers have a setting for it; in Python, sqlite3.connect(..., isolation_level=None) and send the BEGIN IMMEDIATE yourself.

One more thing from the run without the 1 ms of app work between transactions: there, with every writer hammering the lock in a tight loop, the busy timeout turned out not to be fair. One writer made 87,020 commits in 5 seconds and the other three made 1, 1 and 1,969, with a worst wait of 5.1 s. SQLite's busy handler sleeps and retries rather than queueing, so whoever is awake when the lock frees gets it. Real apps rarely write in a tight loop, but a long batch job next to request handlers can look just like this. Commit big jobs in small batches so others get a turn. (On this Mac every short sleep, SQLite's included, lasted about 7 ms because the test ran as a background process, so waits here run longer than on a typical Linux server.)

synchronous: what a commit really costs

synchronous controls how often SQLite waits for the disk. One writer, one small insert per transaction, 3 seconds each:

text
journal      synchronous   fullfsync   commits/s   p50

rollback     FULL          off            4,542    0.214 ms
WAL          FULL          off           20,299    0.049 ms
WAL          NORMAL        off          127,844    0.006 ms
WAL          OFF           off          181,293    0.005 ms
WAL          FULL          on               257    3.987 ms
WAL          NORMAL        on            43,041    0.009 ms

In WAL mode, NORMAL syncs the log at checkpoints instead of at every commit. The database can't be corrupted by a crash or a power cut either way; with NORMAL the last few commits before a power cut can be lost (a crash of your app alone loses nothing, the operating system still has the data). For most web apps that's the right trade, and it's the setting the SQLite documentation suggests for WAL.

The last two rows are a warning about laptops and benchmarks. On macOS, a plain fsync() doesn't make the drive flush its own cache, so the 20,299 commits a second for FULL are not what durability costs. PRAGMA fullfsync = ON asks for a real flush, and then FULL drops to 257 commits a second, about 4 ms each, while NORMAL stays at 43,041. On a Linux server fsync() is a real flush, so expect FULL to cost something closer to the fullfsync row, depending on the disk. Measure on the machine you deploy to.

Backups: don't cp a live database

A SQLite database is a file, so copying the file looks like a backup. We tried it on a 291 MB WAL-mode database while two writers kept inserting thousands of rows a second between them (it grew past 2 million rows during the test), and checked every copy with PRAGMA integrity_check and its newest row:

text
method (while writing)                 time      result

cp app.db                              85-93 ms  1 of 5 corrupt ("database disk image is malformed"),
                                                 2 of 5 missing rows committed before the copy began
cp app.db, then cp app.db-wal          86-102 ms 5 of 5 fine here, but it is a race
backup API, all pages in one step      349-361 ms 3 of 3 fine, nothing missing
backup API, 100 pages a step           > 60 s    never finished
sqlite3 app.db ".backup ..."           > 59 s    never finished (3 tries)
VACUUM INTO                            819-834 ms 3 of 3 fine, nothing missing

Copying only the database file is the backup that looks fine until you need it. In WAL mode the newest commits live in the -wal file until a checkpoint moves them into the database, so a copy of the main file alone is missing them; and if a checkpoint runs while cp is reading, the copy is a mix of before and after, which is what the corrupt one was. Copying the -wal file as well came out fine five times, but two separate copies of two changing files are not a snapshot, and nothing guarantees the next one.

The online backup API is the supported way, and how you call it matters. Copied in one step it took 0.35 s, writers carried on (it only needs a read transaction in WAL mode), and every copy was complete. Copied 100 pages at a time, which is what the sqlite3 shell's .backup command does, it starts over whenever another connection writes to the database, and under steady writes it never got to the end. It only finished once we slowed the writers to about two inserts a second. At 40 a second it still restarted for a minute straight.

VACUUM INTO writes a fresh, compacted copy of the database to a new file inside one read transaction:

sql
VACUUM INTO '/backups/app-2026-10-13.db';

It took about 0.8 s, writers carried on, and every copy passed the integrity check with nothing missing. It's plain SQL, so it works from any driver or from cron with the sqlite3 shell, and the result is a single ordinary file with no -wal beside it. For continuous backups to object storage, tools like Litestream ship the WAL as it's written; whichever you use, open a restored copy and run PRAGMA integrity_check on it now and then.

Foreign keys are off until you turn them on

SQLite parses REFERENCES and then, by default, ignores it. Foreign key enforcement is a per-connection setting that starts off, for compatibility with old databases:

text
PRAGMA foreign_keys;                                  -- 0
INSERT INTO posts VALUES (2, 999, 'orphan');          -- accepted, there is no user 999
DELETE FROM users WHERE id = 1;                       -- ON DELETE CASCADE does nothing

PRAGMA foreign_keys = ON;
INSERT INTO posts VALUES (3, 998, 'orphan');          -- Error: FOREIGN KEY constraint failed
DELETE FROM users WHERE id = 2;                       -- cascades, the user's posts are gone

It's not stored in the file, so every connection has to set it, which in practice means in your driver's connect hook. If the database has been running without it, PRAGMA foreign_key_check; lists the rows that already break a foreign key. Our test file had 20 orders pointing at customers that didn't exist.

STRICT tables: an INTEGER column that holds 'two'

A normal SQLite column has a type affinity, not a type. It converts what it can and stores the rest as given. We imported 20,000 order lines from a CSV into a column declared INTEGER and asked Pilotbase what was actually in it:

Pilotbase with shop.db open: a query on order_lines.qty, declared INTEGER, shows 19,888 rows stored as integer and 112 stored as text, including '1,000', an empty string and 'two'

'2' and '3.0' were quietly turned into integers. 'two', '' and '1,000' couldn't be, so 112 rows hold text in an INTEGER column, and sum(qty) counted '1,000' as 1 and 'two' as 0 without a word. max(qty) on a mixed column returns 'two', because in SQLite every text value sorts after every number.

Since 3.37, a table declared STRICT refuses values that don't fit:

text
CREATE TABLE order_lines (id INTEGER PRIMARY KEY, qty INTEGER, price REAL, created TEXT) STRICT;

INSERT INTO order_lines (qty, price, created) VALUES ('12', '9.50', '2026-10-13');  -- stored as 12, 9.5
INSERT INTO order_lines (qty, price, created) VALUES ('twelve', 'cheap', 20261013);
-- Error: cannot store TEXT value in INTEGER column order_lines.qty

Strict columns still convert text that is a clean number, so most code keeps working. The catch is the allowed types: only INTEGER, REAL, TEXT, BLOB and ANY. A DATETIME or VARCHAR(255) column fails with unknown datatype, so an ORM's generated schema may need adjusting, and an existing table has to be rebuilt to become strict. New tables can just have the word at the end.

The settings, together

Run these on every new connection (journal mode only needs setting once, but it's harmless to repeat):

sql
PRAGMA journal_mode = WAL;
PRAGMA busy_timeout = 5000;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;

Then: BEGIN IMMEDIATE for any transaction that writes, STRICT on new tables, short write transactions, and backups with VACUUM INTO (or the backup API in one step), never cp. In our test, those settings took four busy writers from 28% failures to none and readers from waiting behind writers to never blocking.

Open your SQLite file in Pilotbase and look at what's really in it: check typeof() on your columns, run PRAGMA foreign_key_check, and query the copy you restored. Desktop for Windows, macOS and Linux.

Get Pilotbase

Sources: SQLite documentation, Write-Ahead Logging, PRAGMA statements (journal_mode, busy_timeout, synchronous, fullfsync, foreign_keys, foreign_key_check), sqlite3_busy_handler (when the handler is skipped), BEGIN TRANSACTION (DEFERRED, IMMEDIATE), Online Backup API, VACUUM INTO, Foreign Key Support, STRICT Tables, Datatypes In SQLite, How To Corrupt An SQLite Database File; Python's sqlite3 module (the default timeout). Measured on an Apple M4 Mac mini with SQLite 3.53.4 (Python) and 3.54.0 (shell).