Write-ahead logging, and the discovery that the experiment you would run to prove your WAL is durable cannot fail — it exercises the one buffer that was never at risk.
Python 3.15.0a8 (main, Apr 14 2026, 14:20:41) [Clang 22.1.3], stdlib only.
cd wal-kv
python3 demo.py # the aha (§6)
python3 test_wal_kv.py # 7 tests, including a real SIGKILL
This toy is a key-value store that survives crashes by appending every mutation to a log before touching its in-memory map, and recovering by replaying that log from byte zero. That's write-ahead logging, and it is the durability mechanism underneath Postgres, SQLite, etcd, Kafka and every LSM-tree storage engine.
But the toy exists to make one specific point, and it is not "the log replays." It is this: the experiment you would run to check that your WAL works cannot fail.
By the end you should be able to:
kill -9 is not a durability test, and what it does test;A store that keeps its data in memory is fast and loses everything when the process dies. A store that writes every update straight to its on-disk structure is durable and slow, because those updates land in scattered places and each one costs a seek.
Write-ahead logging is the trick that gets both. Before touching the real data structure, append a record describing what you're about to do to a single sequential file. Appends are the cheapest thing a disk does. Once that record is safe, the actual update can happen lazily, in memory, batched, out of order — because if the process dies, replaying the log rebuilds it.
The log inverts what counts as the truth. The in-memory map is no longer the database; it is a cache of the log, and it can be thrown away and rebuilt at any time. Postgres puts this plainly: only the WAL file needs flushing to commit a transaction, not every data file the transaction touched.
| Concept | Where it's used here | One source |
|---|---|---|
| Buffered I/O and the page cache | The whole aha — write() returning does not mean the disk has anything |
Files are hard |
fsync and what it does not promise |
_append, and the macOS caveat in §8 |
man 2 fsync |
| Checksums for corruption detection | The crc32 in the record frame; §5.4 |
CRC-32 |
| Signals: SIGKILL vs. SIGTERM | Why the demo can crash deterministically without cleanup running | signal(7) |
The two that carry the result are the first two. If you take one idea from this page, take the distinction between your process's buffer, the kernel's buffer, and the disk. Everything else here is consequence.
A put() does not write to disk. It starts a value on a journey through buffers, and each one is emptied by a different disaster.
kill -9 only empties the top box. It cannot reach the kernel's buffer, because the kernel is not the thing that died. So a kill -9 test tells you your process-crash story is fine and tells you nothing at all about power loss — which is the failure a WAL is fundamentally there to survive.
146 lines. Read them in this order.
_HEADER = struct.Struct(">II")
Every record is length | crc32 | payload. Two fields, two entirely different jobs, and it's worth being precise about which defends against what:
length makes the log parseable. Without it there's no way to know where one record ends and the next begins, so you couldn't even find record two.crc32 makes the log trustworthy. A crash can leave a record whose length field is intact and whose payload is garbage. Framing alone can't see that; the checksum can.The payload itself is op | keylen | key | value, which is why the key needs an explicit length and the value doesn't — the value is "whatever is left," so it costs zero bytes to delimit.
_append — the three lines that decide everything def _append(self, payload):
# Log first, mutate second. The reverse order would let a crash
# land between them and leave the map ahead of its own log.
self._f.write(_frame(payload))
if self.flush:
self._f.flush() # Python's buffer -> kernel page cache
if self.fsync:
os.fsync(self._f.fileno()) # kernel page cache -> disk
This is the toy. Three writes to three different places, and each if is a policy decision about which crash you're willing to lose data to.
Note what put does around this call: it appends, and only then assigns to self.data. That ordering is the "write-ahead" in the name. Flip it and the map can hold a value the log has never heard of — I flip it in §6.4 and watch them disagree.
flush is not optional when you asked for fsync self.flush = flush or fsync
One line, easy to read past, and it is a trap with teeth. os.fsync takes a file descriptor. It tells the kernel to push its own buffers for that descriptor to the drive. It knows nothing whatsoever about Python's buffer sitting above it — bytes still in userspace have not reached the kernel, so fsync cannot push what it cannot see.
Remove the or fsync and you get a store that calls fsync on every single write and loses everything anyway. I ran it (§6.3). It loses all five records, and the file is zero bytes.
_recover — stop at the first lie while off + _HEADER.size <= len(blob):
length, crc = _HEADER.unpack_from(blob, off)
start = off + _HEADER.size
payload = blob[start:start + length]
if len(payload) < length: # truncated mid-record
break
if zlib.crc32(payload) != crc: # framing intact, bytes rotten
break
self._apply(payload)
off = start + length
self.replayed += 1
Two guards, two different failures, and both are break, not raise and not continue.
Not raise, because a ragged tail is the normal state of a log after a crash — the process died mid-append, so of course the last record is half written. Refusing to start because of it would throw away a perfectly healthy database over the one record that was never acknowledged anyway.
Not continue, and this is the subtle one. Skipping a damaged record and carrying on recovers more data, which sounds strictly better and isn't. §6.4 shows the variant recovering alpha and gamma while dropping the corrupt beta in the middle — a state the database never actually was in. A log must replay to a prefix of history. break guarantees that; continue produces a plausible-looking store assembled from surviving fragments, which is a far worse thing to hand someone than an honest short read.
def delete(self, key):
# A delete is a record too -- a tombstone. Without one, replaying
# the log would faithfully resurrect every key you ever removed.
self._append(_encode(_DELETE, key))
self.data.pop(key, None)
Deletion has to be written down. The log is append-only, so the put that created the key is still sitting there and replay will faithfully re-apply it. If deleting only mutated memory, every key you ever removed would rise from the dead on the next restart.
A child process performs 5 puts and then SIGKILLs itself:
os.kill(os.getpid(), signal.SIGKILL)
A real SIGKILL — no handlers, no atexit, no flush on the way out — but at a fixed point, so the output is identical on every run. For each buffering policy the demo then asks the log two questions: what survives a process crash, and what survives a power cut.
python3 demo.py
Never leave a number underived. Each record is 8 bytes of header (two 4-byte big-endian ints) plus a payload of 1 op byte + 2 key-length bytes + the key + the value. The demo writes key00…key04 (5 bytes) and value00…value04 (7 bytes):
Row 1 shows 0 because those 115 bytes never left Python's buffer.
Look at the after SIGKILL column. fsync=off recovers 5 / 5. Not most — all of them. Turning fsync on changes the outcome not at all: same 115 bytes, same 5/5, same everything.
So if your durability test is "kill -9 the process and check the data came back," you cannot fail it, and you learn nothing. You'd conclude the store is crash-safe having exercised only the buffer that was never at risk from the disaster you're worried about.
Why: f.flush() hands the bytes to the kernel. The kernel is not the process you killed. SIGKILL removes a process; the page cache belongs to the operating system and doesn't notice. The data is sitting in RAM that outlives your program, and the next process to open the file reads it back happily.
The after power loss column is where the two rows finally separate — 0/5 against 5/5, total loss against no loss. That's the entire value of fsync, and it is invisible to the test everyone runs.
fsync and losing everything anywayflush or fsync (§5.3) looks like a convenience. Remove it, so fsync=True no longer implies flushing, and run the same crash:
Zero bytes. Every put called os.fsync and every put was lost, because the bytes were still in userspace where fsync cannot see them. (85 rather than 115 because this variant uses shorter keys: 8 + 1 + 2 + 3 + 3 = 17, times 5.)
Drop the checksum and corruption stops being an error and becomes an answer:
b'twX'. Not a crash, not an exception — a wrong value, returned confidently as though it were real. The length field was intact so the framing parsed fine; only the checksum knew. Silent corruption is strictly worse than a short read, which is why the crc costs 4 bytes on every record forever.
Skip damaged records instead of stopping, and recovery invents a state that never existed:
The corrupt record is beta, in the middle. continue recovers more rows and produces a database containing alpha and gamma but not beta — a combination that was never true at any instant. break stops at alpha, which is a real historical state.
Flip the write-ahead ordering so the map is updated before the log, and make the disk fill up mid-put:
With the ordering as written, a failed append means the write simply didn't happen — memory and log agree. Reversed, the store now serves a value from memory that has no record behind it: reads succeed until the moment of a restart, and then the data is gone. The word "ahead" is the whole design.
SIGKILL, SIGSEGV, an uncaught exception, an OOM kill, or a docker stop — anything that kills the process on a running machine — then fsync buys you nothing at all, and the difference this whole toy is about does not exist for you. Row 2 and row 3 are genuinely identical.
fsync starts to matter exactly when the kernel stops running: power loss, a kernel panic, a yanked cable, a hypervisor dying, a cloud instance being hard-stopped. If your deployment cannot experience those, you are paying for insurance against a fire that cannot start. Most real deployments can.
You cannot stage a power cut from inside a program, so simulate_power_loss(path, fsynced_bytes) truncates the log to what fsync had already pushed and calls that the disk's contents. Everything else in this commentary is measured; this one column is a simulation, and it would be dishonest not to say so in the same breath as the result.
What justifies it: a power cut empties the page cache and keeps what reached the drive. With fsync off, exactly zero bytes ever reached the drive, so the durable prefix is zero. The truncation isn't approximating that — it's the definition of it.
The alternative was to drop the column and ship a toy about only the two tiers I can crash for real. That's fully measured and much less useful: the reader would leave knowing kill -9 proves nothing, without ever seeing what the thing it fails to prove actually looks like.
os.kill(os.getpid(), signal.SIGKILL) rather than a parent killing the child after a delay. A parent-side sleep then kill would crash at a nondeterministic point — sometimes 3 records in, sometimes 5 — and the demo's byte counts would change every run. Self-killing at a fixed point keeps the signal completely real (SIGKILL cannot be caught, so nothing gets a chance to flush) while making the crash reproducible.
Recovery reads the log from byte zero every single time, and nothing ever removes records. A key written a million times replays a million times. Real systems fix this with checkpointing: periodically write the current state somewhere durable, record that the log is safe up to offset N, and start replay from there. That is genuinely the next mechanism to learn — and it's deliberately absent, because it is a second idea and this toy is about the first.
O_APPEND and raw os.write?Using a raw file descriptor would remove Python's buffer entirely and make flush meaningless. Which would delete the most interesting failure in the toy — §6.3 only exists because there is a userspace buffer to forget about. Real systems do often use raw descriptors, and correspondingly have no equivalent of that particular trap.
os.fsync does not reach the platter on macOS. This is not a nitpick; it undercuts the toy's third tier on the machine it was verified on. From man 2 fsync here: fsync flushes "from the host to the drive," but "the drive itself may not physically write the data to the platters for quite some time" and — the man page's own words — "this is not a theoretical edge case." macOS provides fcntl(F_FULLFSYNC) for real durability, which SQLite and Postgres use and this toy does not. So the ladder in §4 really has a fourth rung, and os.fsync only climbs to the third.put is its own record; there is no way to say "these three writes commit together or not at all." Real WALs carry transaction ids and commit records, and replay discards records belonging to transactions that never committed.fsync=True pays a full sync. Real systems batch concurrent commits into one sync — Postgres notes that a single WAL flush can commit many transactions, which is most of why WAL is fast rather than merely safe.fsync can fail, and the error may be unreportable. The toy ignores the return value. In the real world a failed writeback can mark pages clean and discard them, so a later fsync returns success having lost data — the 2018 "fsyncgate" problem in Postgres. See §10.Answer before expanding. Each answer is derivable from the source.
You run the store with fsync=True and kill -9 it mid-write. Then you run it with fsync=False and kill -9 it the same way. What differs?
Nothing observable. Both recover every record, and the log is byte-identical (115 bytes in the demo). That's rows 2 and 3 of §6.2, and it's the whole point: kill -9 exercises only Python's buffer, which flush already emptied. The fsync call did real work — it just protected against a disaster you didn't stage.
A colleague "optimises" the store by removing self._f.flush() and keeping os.fsync(), reasoning that fsync is the stronger of the two and therefore sufficient. How much data do they lose to a kill -9?
All of it. os.fsync operates on a file descriptor and cannot see bytes still sitting in Python's userspace buffer, so there is nothing in the kernel for it to push. Measured in §6.3:
0 bytes on disk, 0/5 recovered, despite an fsync call on every put. fsync isn't "stronger" than flush — they move data between two different pairs of places, and skipping the first makes the second a no-op.
Recovery hits a corrupt record in the middle of the log with 500 good records after it. Stopping loses those 500. Why is stopping still right?
Because a database must be a state it actually passed through. Skipping the bad record yields the state "everything except record 300," which was never true at any instant — §6.4 shows exactly this, recovering alpha and gamma while beta silently vanishes. Handing someone a store assembled from surviving fragments is worse than handing them an honest short read, because the fragments look completely fine.
Real systems avoid the dilemma rather than resolving it: checkpoints bound how much a single corruption can cost, and replicas provide another copy of the tail.
Why does delete write a record at all? Deleting from a dict is free.
Because the log is append-only and the original put is still in it. Replay walks the whole file and would re-apply that put, resurrecting the key on the next restart. The tombstone is a record whose only job is to be replayed after the put and undo it — which is why order matters, and why test_delete_is_a_tombstone_that_survives_replay asserts replayed == 2 for a single surviving absence.
You deploy this on a cloud VM. Ops assures you the instance is never hard-stopped and always drains gracefully. Do you still need fsync?
By the argument in §6.5, if the kernel genuinely never stops abruptly then fsync buys nothing — every crash mode left is a process crash, which the page cache survives.
The catch is that the claim is nearly always false. Hypervisors fail, hosts get rebooted for security patches, spot instances are reclaimed with seconds of notice, and "graceful drain" describes intent rather than the power supply. The honest version is that you're choosing a probability, not avoiding a mode — and §8 adds that on macOS even os.fsync doesn't fully buy you out of it.
Every link below was fetched and confirmed live when this was written.
fsync returns success having lost your data. The §8 caveat, as a real incident.man 2 fsync — on macOS, worth reading in full for the F_FULLFSYNC discussion. It is unusually blunt about its own guarantees.