Cold Spare

MEASUREMENT

What SQLite's durability settings cost on a home server

Thirty-six configurations measured, and one flag that changes the answer by a factor of ninety.

If you run anything at home, you run SQLite. Home Assistant keeps its recorder history in it. Immich holds thumbnails and job state around it. Paperless, Miniflux, Vaultwarden, Gitea in its default setup, most of the smaller things on your box — all SQLite, and all of them chose journal_mode and synchronous on your behalf, usually without telling you.

Those two settings decide what happens to the last few minutes of your data when the power goes out. They also decide how fast the application writes, and the gap between the safe setting and the fast one is much wider than most defaults suggest. This is what each combination actually costs.

What the settings promise

SQLite's own documentation is unusually direct about the trade, so it is worth quoting rather than paraphrasing. On synchronous=NORMAL in write-ahead-log mode:

WAL mode is always consistent with synchronous=NORMAL, but WAL mode does
lose durability. A transaction committed in WAL mode with synchronous=NORMAL
might roll back following a power loss or system crash. Transactions are
durable across application crashes regardless of the synchronous setting
or journal mode.

That last sentence is the one people miss. If the application crashes — the container is killed, the process segfaults, you restart the stack — your committed data is safe at every setting. Durability only comes into question when the machine stops: a power cut, a kernel panic, a yanked plug. The pragma documentation gives the full matrix: with a rollback journal, FULL is "maybe not durable" and you need EXTRA; in WAL mode, FULL is fully ACID and NORMAL is "maybe not durable".

There is a third flag almost nobody sets, and on a Mac it is the one that matters most. PRAGMA fullfsync selects Apple's F_FULLFSYNC instead of a plain fsync(). The difference is that fsync() on macOS returns once the data has been handed to the drive, not once the drive has committed it to stable storage; F_FULLFSYNC asks the drive to actually flush. SQLite's documentation states the default plainly: "The default value of the fullfsync flag is off."

So on macOS, out of the box, synchronous=FULL does not mean what its name implies. The obvious question is what it would cost if it did.

How this was measured

A fresh database per configuration, one table shaped like sensor history — (id, sensor, ts, value, unit) with an index on (sensor, ts), which is roughly what Home Assistant's recorder writes — filled by explicit BEGIN/COMMIT transactions of a fixed size. Timing is wall clock around the insert loop only. After each run the WAL is checkpointed with TRUNCATE and the row count is asserted, so a configuration that silently lost writes would fail rather than post a good score.

Three transaction sizes (1, 100 and 1000 rows), two journal modes, three synchronous levels, fullfsync off and on: 36 configurations. The batch-of-one cases run 2,000 rows and the rest run 20,000, because one row per transaction with F_FULLFSYNC takes about fifteen milliseconds per commit and 20,000 of those is a five-minute wait. Every record in the raw output states the row count it was measured over; the rate is per second either way.

Machine: Apple M4, 10 cores, 16 GB, macOS 26.3.1, internal NVMe. SQLite 3.38.4 as bundled with Python 3.11. One run per configuration, not a median of several — the spread here is orders of magnitude, not percentages. Script: sqlite-durability-bench.py. Raw output: sqlite-results.jsonl.

Results

SQLite insert rate - 1 row per transaction SQLite insert rate - 1 row per transaction 100 1,000 10,000 100,000 rows per second (log scale) delete/full 4,781 68 delete/normal 5,592 99 delete/off 8,200 8,632 wal/full 19,161 211 wal/normal 53,969 22,844 wal/off 116,083 119,920 fullfsync OFF (the default) fullfsync ON
One row per transaction — the pattern a sensor logger or a job queue actually produces. The left bar is the default; the right bar is the same configuration with PRAGMA fullfsync=ON. Log scale.
Insert rate, rows per second. Higher is faster; the durability guarantee gets weaker as you go down each block.
journal_modesynchronousfullfsync1 row/txn100 rows/txn1000 rows/txn
deletefulloff4,781259,648608,145
deletefullON686,75466,188
deletenormaloff5,592237,517557,115
deletenormalON999,58285,392
deleteoffoff8,200505,6381,368,921
deleteoffON8,632500,4681,375,134
walfulloff19,161404,896602,096
walfullON21119,783160,043
walnormaloff53,969839,7501,399,572
walnormalON22,844540,7551,474,772
waloffoff116,0831,125,3161,525,660
waloffON119,9201,134,8081,517,436

Three things the numbers say

1. Transaction size beats every other setting, by a lot

The safest configuration measured here — rollback journal, synchronous=FULL, fullfsync=ON — does 68 rows per second one row at a time, and 66,188 rows per second in batches of a thousand. Same durability guarantee, same disk, nearly a thousand times the throughput. Every commit is a sync; batching a thousand rows means one sync instead of a thousand.

This is why an app that feels slow on a Raspberry Pi is usually not suffering from the SD card. It is committing per row. Nothing you change in synchronous will recover what an autocommit loop is throwing away.

2. WAL is worth turning on before anything else

At one row per transaction with the same synchronous=FULL promise, WAL does 19,161 rows per second against the rollback journal's 4,781 — four times faster for a strictly stronger guarantee, since FULL is fully ACID in WAL mode and only "maybe durable" with a rollback journal. The one real cost is the one SQLite lists first among WAL's disadvantages: it does not work over a network filesystem. If your self-hosted app's database lives on an NFS or SMB share — a common enough arrangement when someone points a container's data directory at the NAS — WAL is not available to you, and that is a reason to move the database onto local storage rather than a reason to leave WAL off.

3. On macOS, synchronous=FULL costs seventy times more than it appears to

This is the result worth the measurement. With the default fullfsync=OFF, rollback journal plus synchronous=FULL writes 4,781 rows per second. Turn on fullfsync — that is, ask the drive to genuinely flush, which is what a reader assumes FULL already means — and the same configuration writes 68. In WAL mode the gap is wider still: 19,161 down to 211, a factor of ninety.

The control case confirms the flag is doing what it claims rather than adding noise. At synchronous=OFF, where SQLite issues no syncs at all, turning fullfsync on changes nothing measurable: 8,200 against 8,632 rows per second on a rollback journal, 116,083 against 119,920 in WAL. The flag only costs something where a sync was going to happen anyway, which is exactly the behaviour the documentation describes.

The practical reading is not "turn fullfsync on". It is that on a Mac the default performance of synchronous=FULL is quietly bought with a weaker guarantee than the name suggests, and if you actually need the strong one, the price is a factor of seventy and you should be batching your writes before you pay it.

This is a macOS-specific mechanism. On Linux the equivalent question is whether the drive honours a cache flush and whether the filesystem was mounted with barriers, which is a different measurement and is not one this run makes any claim about.

What to set

For a self-hosted app on local storage, in order of how much it buys you:

What this does not tell you

The script and the full JSON output are on the raw data page. If it reproduces differently on your hardware, that result is more interesting than this one — send it over.