TRENDING
Vintage silver stopwatch with a chain on a black background, its dial showing seconds from 5 to 60
October 28, 2026
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
A wooden Tower of Hanoi puzzle on a market table, with three pegs and stacked discs of different sizes
October 28, 2026
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
Two earthworms lying together on wet brown leaf litter, photographed close up
October 28, 2026
Tailscale Merged Fixes for Most of the 11 “Tailworm” Findings Before Shipping a Release, and the Forwarding Switch It Recommends Already Exists in the Shipped Client
Unpopped corn kernels mixed with popped popcorn on a white surface
October 28, 2026
How to Stop Decompression Bombs in Python With Hard Output Limits for gzip, Brotli, zstd, ZIP, and tar.gz
Ouroboros, a polished stainless steel sculpture of two interlocking loops with a pierced surface, standing beside a dark pool under trees
October 28, 2026
Anthropic’s S-1 Puts Safety Audits Inside the “Closing the Loop” Cycle It Warns About, and Says Its Evaluations Could Overstate Safety
28 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
A mountain gorilla looks at the camera from green forest undergrowth
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
October 28, 2026
A bronze-framed hourglass with white sand beside a small Ferris wheel ornament on a wooden table against a teal wall
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
October 28, 2026
Six weathered stone columns of the Temple of Apollo at Delphi, Greece, standing on a stone platform above a valley of olive groves
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
October 28, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 269 Posts
News 272 Posts
Learning Hub 242 Posts
Home/Learning Hub/How to Build a Write-Ahead Log in Python to Survive a Crash Without Losing Data
Learning Hub

How to Build a Write-Ahead Log in Python to Survive a Crash Without Losing Data

Build a write-ahead log from scratch in Python and see exactly how databases survive a crash without losing data, checksums, torn-write recovery, and a real checkpoint race condition bug included.

September 7, 2026 19 Min Read
159

A write-ahead log, usually shortened to WAL, is the reason a database can survive a crash without losing the transaction you just committed. It is the same idea behind a flight recorder: a small, append-only device built to keep a sequential record of exactly what happened, so that if the worst occurs, someone can replay it afterward and reconstruct the truth. In this tutorial you will build a real one from scratch in Python: a durable key-value store that writes every change to a checksummed, crash-safe log before it ever touches memory, survives a torn write mid-record, detects silent disk corruption, and checkpoints itself without losing data even if the process dies in the middle of checkpointing. Every command and every number in this post comes from code that was actually run, not paraphrased from documentation.

Table Of Content

  • What You Will Build, and Why It Matters
  • Prerequisites
  • Step 1: See the Problem, an In-Memory Store Loses Everything
  • Step 2: Design a Durable Record Format
  • Step 3: Write Records Safely with fsync
  • Step 4: Replay the Log to Recover State
  • Step 5: Build a Durable Key-Value Store and Prove It Survives a Crash
  • Step 6: Handle a Torn Write (Crash Mid-Record)
  • Step 7: Handle Silent Corruption (Bit Rot)
  • Step 8: Stop the Log From Growing Forever
  • The Naive Approach, and a Real Race Condition
  • The Fix: Track the WAL Offset the Snapshot Covers
  • Putting It Together
  • Step 9: Measure the Real Cost of fsync
  • Common Mistakes and Gotchas
  • How to Verify It All Works End to End
  • Next Steps

What You Will Build, and Why It Matters

Start with the core idea, stated plainly: before you change something in memory, you first write down, durably, exactly what you are about to do. Only after that write is confirmed safe on disk do you apply the change in memory. If the process crashes a millisecond later, you have not lost anything, because the intent was already recorded somewhere that survives the crash. On restart, you read that record back and replay it to rebuild the state you had before.

This is exactly how PostgreSQL, SQLite, Kafka, and most other systems that promise “your data is safe” actually keep that promise. As PostgreSQL’s own documentation puts it, the central concept is that changes to data files “must be written only after those changes have been logged, that is, after WAL records describing the changes have been flushed to permanent storage.” Wikipedia’s definition is the same idea in one sentence: “The changes are first recorded in the log, which must be written to stable storage, before the changes are written to the database.”

That single ordering rule, log first, apply second, is the entire trick. Everything else in this tutorial is about handling what goes wrong around the edges of that rule: what if the write to the log itself gets cut off partway through? What if a byte on disk silently flips? What if the log grows forever and takes longer and longer to replay? Those are the real engineering problems, and you are going to hit all three for real before this is over.

Prerequisites

  • Python 3.10 or newer (this tutorial was built and tested on Python 3.13.14, using only the standard library: no packages to install)
  • Basic familiarity with reading and writing files in Python
  • A rough idea of what a database transaction is: a change you want to either fully happen or not happen at all
  • A terminal and a working directory you can throw away afterward

Nothing here needs a database server, Docker, or any third-party library. That is deliberate: a write-ahead log is a technique, not a product, and the cleanest way to actually understand it is to build the whole thing out of nothing but open(), struct, and zlib.

Step 1: See the Problem, an In-Memory Store Loses Everything

Before building anything durable, look at what durability is protecting you from. Here is the simplest possible key-value store: a Python dictionary with a couple of methods wrapped around it.

class NaiveKVStore:
    def __init__(self):
        self._data = {}

    def put(self, key: str, value: str) -> None:
        self._data[key] = value

    def get(self, key: str) -> str | None:
        return self._data.get(key)

    def all_items(self) -> dict:
        return dict(self._data)

Run this, put a couple of values in, and simulate a crash by just starting a brand new instance in the same process, the same way a fresh process would start after a real crash:

store = NaiveKVStore()
store.put("user:42:plan", "pro")
store.put("user:42:credits", "500")
print("Before crash:", store.all_items())
print("Process now exits uncleanly (simulated crash, e.g. power loss or SIGKILL).")
fresh_store = NaiveKVStore()
print("After restart:", fresh_store.all_items())

The real output from running that:

Before crash: {'user:42:plan': 'pro', 'user:42:credits': '500'}
Process now exits uncleanly (simulated crash, e.g. power loss or SIGKILL).
Nothing was ever written to disk, so a fresh process starts empty:
After restart: {}

Everything is gone. This is not a bug: the store never wrote anything anywhere except RAM, and RAM does not survive a crash. Adding durability means adding a second, independent place the data lives, one that is written before the in-memory copy changes, so the two never have a chance to disagree about what happened.

Step 2: Design a Durable Record Format

The log itself is just a file you only ever append to. Never rewrite a byte that is already there; only add new bytes at the end. That single constraint is what makes a WAL fast (sequential writes are cheap on essentially every storage medium) and what makes it possible to reason about crash safety at all: a crash can only ever corrupt the very end of the file, never the middle.

Each record needs three things: how many bytes of actual data follow, a checksum of that data so you can detect corruption later, and the data itself. Here is the format, using Python’s struct module to pack two 4-byte big-endian unsigned integers as a fixed 8-byte header:

import struct

HEADER = struct.Struct(">II")  # length (uint32), crc32 of payload (uint32)

The > means big-endian byte order (not load-bearing for correctness here, but it makes the bytes on disk predictable if you ever want to inspect them by hand), and each I is one unsigned 32-bit integer, so HEADER.size is exactly 8 bytes. A record on disk looks like: 8-byte header, then that many bytes of JSON-encoded payload, then the next record’s header starts immediately after. JSON keeps this tutorial’s payloads human-readable if you open the file in a text editor; a production system would more likely use a fixed binary schema for speed, but the durability logic underneath is identical either way.

Step 3: Write Records Safely with fsync

Writing the bytes is not enough on its own. Python’s file.write() hands data to an internal buffer, and even after that buffer is flushed, the operating system itself is very likely still holding the bytes in its own page cache rather than the physical disk. If the machine loses power at that point, the data is gone regardless of what your Python code thinks it did. Python’s own documentation is explicit about the two-step fix: “If you’re starting with a buffered Python file object f, first do f.flush(), and then do os.fsync(f.fileno()), to ensure that all internal buffers associated with f are written to disk.”

Here is the append method that does exactly that, and returns the byte offset the record was written at so callers can reference it later:

import json
import os
import zlib

class WriteAheadLog:
    def __init__(self, path: str):
        self.path = path
        self._fh = open(path, "ab")

    def append(self, record: dict, sync: bool = True) -> int:
        payload = json.dumps(record).encode("utf-8")
        header = HEADER.pack(len(payload), zlib.crc32(payload))
        offset = self._fh.tell()
        self._fh.write(header + payload)
        self._fh.flush()
        if sync:
            os.fsync(self._fh.fileno())
        return offset

    def fsync(self) -> None:
        os.fsync(self._fh.fileno())

    def close(self) -> None:
        self._fh.close()

The sync parameter defaults to True on purpose: durable-by-default is the only safe default for something whose entire job is durability. You will use sync=False later, deliberately and explicitly, once you understand exactly what tradeoff you are making. One platform note worth keeping in mind if you ever port this off Windows: Python’s os.fsync() “calls the native fsync() function” on Unix, “on Windows, the MS _commit() function.” Same guarantee, different system call underneath.

Step 4: Replay the Log to Recover State

Writing is only half the picture. Recovery means reading every complete, valid record back out of the file in order and reapplying it. Critically, replay has to assume the file might end mid-record, because that is exactly what a real crash looks like on disk: whatever bytes made it to the physical device before power was cut, and nothing more. Add it as a @staticmethod on the same WriteAheadLog class from Step 3, right below close(). Here is the class in full, append(), fsync(), and close() exactly as they were, plus the new replay() method:

class WriteAheadLog:
    def __init__(self, path: str):
        self.path = path
        self._fh = open(path, "ab")

    def append(self, record: dict, sync: bool = True) -> int:
        payload = json.dumps(record).encode("utf-8")
        header = HEADER.pack(len(payload), zlib.crc32(payload))
        offset = self._fh.tell()
        self._fh.write(header + payload)
        self._fh.flush()
        if sync:
            os.fsync(self._fh.fileno())
        return offset

    def fsync(self) -> None:
        os.fsync(self._fh.fileno())

    def close(self) -> None:
        self._fh.close()

    @staticmethod
    def replay(path: str, start_offset: int = 0) -> list[dict]:
        records = []
        if not os.path.exists(path):
            return records
        with open(path, "rb") as fh:
            data = fh.read()
        pos = start_offset
        while True:
            if pos + HEADER.size > len(data):
                break  # not even a full header left: torn write, stop here
            length, stored_crc = HEADER.unpack_from(data, pos)
            payload_start = pos + HEADER.size
            payload_end = payload_start + length
            if payload_end > len(data):
                break  # header claims more payload than the file actually has
            payload = data[payload_start:payload_end]
            if zlib.crc32(payload) != stored_crc:
                break  # bytes are present but don't match their own checksum
            records.append(json.loads(payload))
            pos = payload_end
        return records

Notice replay never raises an exception on bad data. It stops and returns everything confirmed good up to that point. That is a deliberate design choice, not an oversight, and Steps 6 and 7 will prove why it is the right one.

Step 5: Build a Durable Key-Value Store and Prove It Survives a Crash

Now wire the log up to an actual store. The rule from the introduction becomes code directly: put() appends to the WAL first, and only updates the in-memory dictionary after that append call returns.

class DurableKVStore:
    def __init__(self, wal_path: str):
        self._data = {}
        for record in WriteAheadLog.replay(wal_path):
            self._apply(record)
        self._wal = WriteAheadLog(wal_path)

    def _apply(self, record: dict) -> None:
        if record["op"] == "put":
            self._data[record["key"]] = record["value"]
        elif record["op"] == "delete":
            self._data.pop(record["key"], None)

    def put(self, key: str, value: str) -> None:
        record = {"op": "put", "key": key, "value": value}
        self._wal.append(record)  # durable on disk before we touch memory
        self._apply(record)

    def delete(self, key: str) -> None:
        record = {"op": "delete", "key": key}
        self._wal.append(record)
        self._apply(record)

    def get(self, key: str) -> str | None:
        return self._data.get(key)

    def all_items(self) -> dict:
        return dict(self._data)

    def close(self) -> None:
        self._wal.close()

The constructor replays the log before opening it for new appends, which is exactly what a real restart needs to do: rebuild state from history first, then start accepting new writes. Test the full cycle:

store = DurableKVStore("durable.wal")
store.put("user:42:plan", "pro")
store.put("user:42:credits", "500")
store.delete("user:42:credits")
store.put("user:42:credits", "750")
print("Before crash:", store.all_items())
store.close()
print("Process exits uncleanly here (simulated crash).")

recovered_store = DurableKVStore("durable.wal")
print("After restart:", recovered_store.all_items())

Real output:

Before crash: {'user:42:plan': 'pro', 'user:42:credits': '750'}
Process exits uncleanly here (simulated crash).

A fresh process starts and opens the same WAL file...
After restart: {'user:42:plan': 'pro', 'user:42:credits': '750'}

Four operations, including a delete followed by a put on the same key, replayed in the exact order they happened and landed on the exact same final state. This is the payoff: the naive store from Step 1 lost everything, and this one loses nothing, for the cost of one fsync() call per write.

Step 6: Handle a Torn Write (Crash Mid-Record)

A “torn write” is what happens when a crash lands in the middle of writing a record: the disk ends up with a partial header, or a full header but a truncated payload. Simulate one by writing three real records, then manually truncating the file a few bytes into where the third record starts, the same way an actual power loss would leave the file:

log = WriteAheadLog("torn.wal")
log.append({"op": "put", "key": "order:1001:status", "value": "paid"})
log.append({"op": "put", "key": "order:1001:shipped", "value": "true"})
third_offset = log.append({"op": "put", "key": "order:1002:status", "value": "paid"})
log.close()

full_size = os.path.getsize("torn.wal")
print(f"Full, healthy log is {full_size} bytes, 3 records.")
print(f"The 3rd record starts at byte offset {third_offset}.")

torn_at = third_offset + 5
with open("torn.wal", "r+b") as fh:
    fh.truncate(torn_at)
print(f"Truncated the file to {torn_at} bytes, mid-way through record 3's header.")

records = WriteAheadLog.replay("torn.wal")
print(f"\nReplay recovered {len(records)} record(s):")
for r in records:
    print(" ", r)
print("The torn 3rd record was silently dropped instead of crashing replay,")
print("which is correct: it was never confirmed durable, so the writer's")
print("caller never got to treat it as committed either.")

Real output:

Full, healthy log is 199 bytes, 3 records.
The 3rd record starts at byte offset 133.
Truncated the file to 138 bytes, mid-way through record 3's header.

Replay recovered 2 record(s):
  {'op': 'put', 'key': 'order:1001:status', 'value': 'paid'}
  {'op': 'put', 'key': 'order:1001:shipped', 'value': 'true'}
The torn 3rd record was silently dropped instead of crashing replay,
which is correct: it was never confirmed durable, so the writer's
caller never got to treat it as committed either.

This is exactly correct behavior, not a limitation. Remember that append() only returns after its os.fsync() call succeeds. If the crash happened before that fsync returned, whatever code called append() never got confirmation the write succeeded either, so it is entirely correct for that record to vanish on replay. The log’s job is to make sure nothing it confirmed gets lost; it makes no promises about writes that were still in flight.

Step 7: Handle Silent Corruption (Bit Rot)

A torn write is a clean, obvious failure: the file just ends. A more insidious failure is when the file’s length is completely correct, but a byte somewhere in the middle silently changed, for example from a disk error that has nothing to do with a crash. Reproduce it by flipping a single bit inside a complete, correctly-sized record:

log = WriteAheadLog("corrupt.wal")
log.append({"op": "put", "key": "invoice:9001:total", "value": "199.99"})
second_offset = log.append({"op": "put", "key": "invoice:9001:paid", "value": "true"})
log.append({"op": "put", "key": "invoice:9002:total", "value": "50.00"})
log.close()

# Flip one byte inside record 2's payload, well past the end of its record
# so the file length is unchanged: this models a disk bit-flip, not a crash.
flip_at = second_offset + HEADER.size + 4
with open("corrupt.wal", "r+b") as fh:
    fh.seek(flip_at)
    original_byte = fh.read(1)
    fh.seek(flip_at)
    fh.write(bytes([original_byte[0] ^ 0xFF]))
print(f"Flipped 1 byte at offset {flip_at} inside record 2's payload.")
print("The file length and record boundaries are still exactly correct;")
print("only the payload content silently changed, so the length prefix")
print("still parses fine but the stored checksum no longer matches.")

records = WriteAheadLog.replay("corrupt.wal")
print(f"\nReplay recovered {len(records)} record(s):")
for r in records:
    print(" ", r)
print("\nRecord 1 survived (before the corruption). Records 2 and 3 were")
print("both dropped, even though record 3's own bytes were never touched,")
print("because replay has no way to know record 3 is still trustworthy")
print("once record 2 fails its checksum: a corrupt length field could")
print("just as easily point replay at the wrong offset for everything")
print("that follows, so it stops rather than guesses.")

Real output:

Flipped 1 byte at offset 81 inside record 2's payload.
The file length and record boundaries are still exactly correct;
only the payload content silently changed, so the length prefix
still parses fine but the stored checksum no longer matches.

Replay recovered 1 record(s):
  {'op': 'put', 'key': 'invoice:9001:total', 'value': '199.99'}

Record 1 survived (before the corruption). Records 2 and 3 were
both dropped, even though record 3's own bytes were never touched,
because replay has no way to know record 3 is still trustworthy
once record 2 fails its checksum: a corrupt length field could
just as easily point replay at the wrong offset for everything
that follows, so it stops rather than guesses.

Without the CRC32 checksum, this corruption would be invisible: the JSON would still parse, the field names would still be right, and the store would silently serve a wrong value forever. The checksum is what turns a silent, undetectable failure into a loud, obvious one, which is always the trade you want when the alternative is quietly wrong data.

Step 8: Stop the Log From Growing Forever

Every write so far has only ever appended. Left alone, the log grows without bound, and replay gets slower every single day since it has to re-read and re-apply the entire history from the beginning. The fix is checkpointing: periodically write the current, fully-computed state to a separate snapshot file, so replay only has to process whatever has happened since the last snapshot. Both PostgreSQL and SQLite work this way; SQLite’s documentation describes it plainly: “Moving the WAL file transactions back into the database is called a ‘checkpoint’,” and by default SQLite triggers one automatically once its WAL file crosses a size threshold.

The Naive Approach, and a Real Race Condition

The obvious first implementation is: write a snapshot of the current state, then delete or clear the WAL, since the snapshot now captures everything in it. This works fine for put and delete, because reapplying either of those twice produces the same result as applying it once. But add an operation that is not idempotent, an increment, and a real correctness bug appears. Picture the exact moment between “snapshot written” and “WAL cleared”: if the process crashes right there, the WAL still physically exists on disk, untouched.

os.makedirs("race_naive", exist_ok=True)

# checkpoint runs: append one incr, then snapshot the resulting state
log = WriteAheadLog("race_naive/current.wal")
log.append({"op": "incr", "key": "invoice:9001:payment_count", "delta": 1})
log.close()

data = {}
for r in WriteAheadLog.replay("race_naive/current.wal"):
    data[r["key"]] = data.get(r["key"], 0) + r["delta"]
json.dump(data, open("race_naive/snapshot.json", "w"))
print("Version A: wrote snapshot", data)
# ...and CRASHES here, before the WAL gets deleted.
print("Version A: simulated crash between snapshot write and WAL delete")
print("           (the WAL file at race_naive/current.wal was never cleared).")

# process restarts: load the snapshot, then still replay the (uncleared) WAL
recovered = dict(json.load(open("race_naive/snapshot.json")))
for r in WriteAheadLog.replay("race_naive/current.wal"):
    recovered[r["key"]] = recovered.get(r["key"], 0) + r["delta"]
print("Version A after restart:", recovered)

Real output:

Version A: wrote snapshot {'invoice:9001:payment_count': 1}
Version A: simulated crash between snapshot write and WAL delete
           (the WAL file at race_naive/current.wal was never cleared).
Version A after restart: {'invoice:9001:payment_count': 2}

The increment got applied twice: once while building the snapshot, and a second time on replay, because nothing on disk recorded the fact that the snapshot already accounted for that WAL record. This is a genuinely easy mistake to make, and it will not show up in casual testing, since put-only workloads hide it completely. It only surfaces with non-idempotent operations, under a specific, narrow crash timing window, which is exactly the kind of bug that is rare in testing and expensive in production.

The Fix: Track the WAL Offset the Snapshot Covers

The fix does not require ever deleting the WAL at all. Instead, the snapshot itself records the exact byte offset in the WAL that it already accounts for, an approach real systems call a log sequence number, or LSN. Replay then always starts from that recorded offset instead of from byte zero, so it can never re-apply a record the snapshot already covers, regardless of whether the WAL file was ever cleaned up:

os.makedirs("race_fixed", exist_ok=True)

log = WriteAheadLog("race_fixed/current.wal")
log.append({"op": "incr", "key": "invoice:9001:payment_count", "delta": 1})
log.close()

wal_offset_covered = os.path.getsize("race_fixed/current.wal")
data = {}
for r in WriteAheadLog.replay("race_fixed/current.wal"):
    data[r["key"]] = data.get(r["key"], 0) + r["delta"]
json.dump({"wal_offset": wal_offset_covered, "data": data}, open("race_fixed/snapshot.json", "w"))
print(f"Version B: wrote snapshot with wal_offset={wal_offset_covered}, data={data}")
# ...crashes here too, WAL still never cleared.
print("Version B: simulated crash between snapshot write and WAL delete")
print("           (the WAL file at race_fixed/current.wal was never cleared either).")

snap = json.load(open("race_fixed/snapshot.json"))
recovered = dict(snap["data"])
for r in WriteAheadLog.replay("race_fixed/current.wal", start_offset=snap["wal_offset"]):
    recovered[r["key"]] = recovered.get(r["key"], 0) + r["delta"]
print("Version B after restart:", recovered)

Real output:

Version B: wrote snapshot with wal_offset=71, data={'invoice:9001:payment_count': 1}
Version B: simulated crash between snapshot write and WAL delete
           (the WAL file at race_fixed/current.wal was never cleared either).
Version B after restart: {'invoice:9001:payment_count': 1}

Same crash timing, same non-idempotent operation, correct result. The insight worth keeping: checkpointing safety is not about deleting old data fast enough, it is about recording precisely what has already been captured, so replay can never double-count regardless of what does or does not get cleaned up afterward.

Putting It Together

Here is the full, offset-tracked store, extended with incr support and a checkpoint() method that writes the snapshot atomically via os.replace():

class CheckpointedKVStore:
    def __init__(self, dir_path: str):
        os.makedirs(dir_path, exist_ok=True)
        self._snapshot_path = os.path.join(dir_path, "snapshot.json")
        self._wal_path = os.path.join(dir_path, "current.wal")

        self._data = {}
        wal_offset = 0
        if os.path.exists(self._snapshot_path):
            with open(self._snapshot_path, "r", encoding="utf-8") as fh:
                snapshot = json.load(fh)
            self._data = snapshot["data"]
            wal_offset = snapshot["wal_offset"]

        for record in WriteAheadLog.replay(self._wal_path, start_offset=wal_offset):
            self._apply(record)
        self._wal = WriteAheadLog(self._wal_path)

    def _apply(self, record: dict) -> None:
        op = record["op"]
        if op == "put":
            self._data[record["key"]] = record["value"]
        elif op == "delete":
            self._data.pop(record["key"], None)
        elif op == "incr":
            self._data[record["key"]] = self._data.get(record["key"], 0) + record["delta"]

    def put(self, key: str, value: str) -> None:
        record = {"op": "put", "key": key, "value": value}
        self._wal.append(record)
        self._apply(record)

    def incr(self, key: str, delta: int = 1) -> None:
        record = {"op": "incr", "key": key, "delta": delta}
        self._wal.append(record)
        self._apply(record)

    def checkpoint(self) -> None:
        wal_offset_covered = self._wal_size()
        tmp_path = self._snapshot_path + ".tmp"
        with open(tmp_path, "w", encoding="utf-8") as fh:
            json.dump({"wal_offset": wal_offset_covered, "data": self._data}, fh)
            fh.flush()
            os.fsync(fh.fileno())
        os.replace(tmp_path, self._snapshot_path)  # atomic on POSIX and Windows
        # The WAL is intentionally left alone: correctness never depends on
        # clearing it. A real system would periodically archive/delete WAL
        # bytes before wal_offset once it is safe to reclaim disk space.

    def all_items(self) -> dict:
        return dict(self._data)

    def _wal_size(self) -> int:
        return os.path.getsize(self._wal_path) if os.path.exists(self._wal_path) else 0

    def close(self) -> None:
        self._wal.close()

Notice the snapshot is written to a .tmp file, fsync’d, and only then moved into place with os.replace(), which is atomic on both POSIX and Windows. That guards against a second failure mode: a crash while the snapshot file itself is only half-written would otherwise leave a corrupt snapshot behind. Test the full picture, 500 puts plus an increment, a checkpoint, two more writes, then a restart:

def wal_size():
    path = "ckpt_store/current.wal"
    return os.path.getsize(path) if os.path.exists(path) else 0

store = CheckpointedKVStore("ckpt_store")
for i in range(500):
    store.put(f"session:{i}", f"token-{i}")
store.incr("stats:total_logins", 1)
print(f"After 500 puts + 1 incr, WAL is {wal_size()} bytes (never checkpointed).")

store.checkpoint()
print(f"After checkpoint(), WAL is still {wal_size()} bytes on disk,")
print("but the snapshot now records the offset that already covers all of it.")

store.put("session:500", "token-500")
store.incr("stats:total_logins", 1)
print(f"After 1 more put + 1 more incr, WAL is {wal_size()} bytes total",
      "(the pre-checkpoint records are still physically there, just skipped).")
store.close()

print("\nSimulated crash. Fresh process reopens the same directory...")
recovered = CheckpointedKVStore("ckpt_store")
items = recovered.all_items()
print(f"Recovered {len([k for k in items if k.startswith('session:')])} session keys",
      f"and stats:total_logins = {items.get('stats:total_logins')}.")
print(f"  session:0   -> {items.get('session:0')}")
print(f"  session:499 -> {items.get('session:499')}")
print(f"  session:500 -> {items.get('session:500')}")

Real output:

After 500 puts + 1 incr, WAL is 32343 bytes (never checkpointed).
After checkpoint(), WAL is still 32343 bytes on disk,
but the snapshot now records the offset that already covers all of it.
After 1 more put + 1 more incr, WAL is 32471 bytes total (the pre-checkpoint records are still physically there, just skipped).

Simulated crash. Fresh process reopens the same directory...
Recovered 501 session keys and stats:total_logins = 2.
  session:0   -> token-0
  session:499 -> token-499
  session:500 -> token-500

total_logins is 2, not 3: the pre-checkpoint increment was captured once in the snapshot and correctly not re-applied from the WAL, exactly like the offset-tracked fix from the previous step.

All 501 keys recovered, and the counter landed on exactly 2 (one increment before the checkpoint, one after), not 3. The offset-tracking fix holds up under the same scenario at real scale, not just the two-record toy example that first exposed the bug.

Step 9: Measure the Real Cost of fsync

Durability is not free, and it is worth actually measuring what it costs instead of just assuming. Compare fsyncing after every single write against fsyncing once after a whole batch, a pattern real databases call group commit:

import time

N = 300

def time_it(label, path, sync_every_write):
    log = WriteAheadLog(path)
    start = time.perf_counter()
    for i in range(N):
        record = {"op": "put", "key": f"k{i}", "value": f"v{i}"}
        log.append(record, sync=sync_every_write)
    if not sync_every_write:
        log.fsync()  # one fsync for the whole batch
    elapsed = time.perf_counter() - start
    log.close()
    per_op_ms = (elapsed / N) * 1000
    print(f"{label}: {elapsed:.4f}s total for {N} writes, {per_op_ms:.4f} ms/write")
    return elapsed

per_write = time_it("fsync on every write     ", "cost_per_write.wal", True)
grouped = time_it("one fsync for all N writes", "cost_grouped.wal", False)
print(f"\nPer-write fsync was {per_write / grouped:.1f}x slower than group commit",
      f"for this {N}-write batch on this machine.")

Real output from this machine:

fsync on every write     : 0.2325s total for 300 writes, 0.7750 ms/write
one fsync for all N writes: 0.0041s total for 300 writes, 0.0136 ms/write

Per-write fsync was 57.0x slower than group commit for this 300-write batch on this machine.

Fsyncing every write was tens of times slower per write than batching 300 writes behind a single fsync call. Repeated runs on this machine ranged from roughly 30x to more than 75x slower depending on whatever else the disk was doing at that moment; wall-clock I/O timing is inherently noisy, especially in a virtualized environment, so treat the exact multiplier as illustrative rather than a benchmark result, and expect your own numbers to differ from run to run and from machine to machine. The direction and rough order of magnitude are the durable takeaway, not the precise digit. PostgreSQL’s own documentation explains exactly why: because the WAL is written sequentially, “when the server is processing many small concurrent transactions, one fsync of the WAL file may suffice to commit many transactions.” The tradeoff is real, though: group commit means a bigger batch of writes could be lost together if the machine loses power before that one batched fsync call returns. Fsync-per-write is the safe default for exactly that reason; group commit is an optimization you reach for once you understand precisely what durability window you are trading away, not something to flip on by default.

Common Mistakes and Gotchas

  • Calling flush() and thinking that is durability. flush() only pushes Python’s own internal buffer into the operating system; the OS can still be holding those bytes in a page cache that a power loss would wipe out. Durability requires os.fsync(fh.fileno()) after the flush, not instead of it.
  • Treating a checksum mismatch as something to skip past. It is tempting to skip a bad record and keep reading the rest of the file. Step 7 showed why that is dangerous: once one record’s stored length can’t be trusted, there is no reliable way to know where the next record even starts. Stopping is the safe choice.
  • Deleting the WAL immediately after writing a snapshot. Step 8 reproduced this exact bug with a real, doubled counter. Track what the snapshot covers (an offset, an LSN, whatever you want to call it) and let replay skip past it, rather than relying on deletion timing to be crash-safe.
  • Writing the snapshot file in place. A crash mid-write to snapshot.json directly would leave a half-written, unparseable file as your recovery data. Write to a temp file, fsync it, then atomically os.replace() it into place, exactly as checkpoint() does above.
  • Assuming fsync-per-write and group commit are interchangeable. They produce the same data on a clean shutdown. They do not produce the same guarantees on a crash. Pick fsync-per-write unless you have a specific, measured reason to accept a bigger loss window for more throughput.

How to Verify It All Works End to End

Beyond eyeballing printed output, write it down as tests so a future change to this code cannot silently break any of the guarantees above:

def test_replay_stops_at_torn_write(tmp_path):
    path = str(tmp_path / "t.wal")
    log = WriteAheadLog(path)
    log.append({"op": "put", "key": "a", "value": "1"})
    second_offset = log.append({"op": "put", "key": "b", "value": "2"})
    log.close()
    with open(path, "r+b") as fh:
        fh.truncate(second_offset + 3)
    assert WriteAheadLog.replay(path) == [{"op": "put", "key": "a", "value": "1"}]

def test_checkpoint_offset_prevents_double_apply_of_incr(tmp_path):
    d = str(tmp_path / "store")
    store = CheckpointedKVStore(d)
    store.incr("counter", 1)
    store.checkpoint()
    store.close()
    reopened = CheckpointedKVStore(d)  # WAL never cleared, on purpose
    assert reopened.all_items()["counter"] == 1
    reopened.close()

Running the full suite (seven tests covering ordered replay, torn writes, checksum corruption, a missing file, a full durable-store restart, the checkpoint race fix, and writes made after a checkpoint) against this exact code:

$ python -m pytest test_wal.py -v
test_wal.py::test_replay_returns_records_in_order PASSED
test_wal.py::test_replay_stops_at_torn_write PASSED
test_wal.py::test_replay_stops_at_checksum_mismatch PASSED
test_wal.py::test_replay_missing_file_returns_empty_list PASSED
test_wal.py::test_durable_kv_store_survives_restart PASSED
test_wal.py::test_checkpoint_offset_prevents_double_apply_of_incr PASSED
test_wal.py::test_checkpoint_then_new_writes_both_survive_restart PASSED

7 passed in 0.10s

Before you consider your own version done, confirm each of these by hand at least once, the same way this tutorial did: kill a process mid-write and check nothing after the last confirmed record survives; flip a byte in a saved log file and check replay stops cleanly instead of throwing or returning garbage; checkpoint, then keep writing, then restart, and check both the pre- and post-checkpoint data are present with no operation double-counted.

Next Steps

A write-ahead log is a building block, not a finished system, and it pairs naturally with a few other patterns already covered on this site. If you are applying WAL-style durability to outbound side effects like emails or webhooks rather than internal state, look at the transactional outbox pattern, which uses the same log-first ordering to avoid the classic dual-write bug. If your durable writes are calls to a flaky downstream service rather than a local file, exponential backoff and jitter and a circuit breaker cover what to do when the write on the other end keeps failing. And if you want to see this same “provide a durability guarantee, then break it on purpose to prove the guarantee holds” approach applied to preventing duplicate financial charges instead of preventing lost writes, the idempotency keys tutorial is the closest sibling to this one on the site.

From here, a natural extension is to make the WAL work correctly with concurrent writers (this tutorial’s version assumes a single writer, which real single-node embedded databases like SQLite also assume) or to look at how a real system streams WAL bytes to a replica for physical replication, which is the same append-only log this tutorial built, just read by a second process instead of only the one that wrote it.

Tags:

Data DurabilityDatabasesPythonReliability EngineeringWrite-Ahead Log

Share

The gilded chamber of the House of Lords at the Palace of Westminster, with red benches and the ceremonial canopy visible
Previous Post

The UK’s Cyber Security Bill Turns Executive Liability Into a Voluntary Code

Total solar eclipse showing a dark lunar disc surrounded by a bright white corona against a black night sky
Next Post

A Zero-Day Researcher Known as Nightmare Eclipse Turns From Microsoft to CrowdStrike, Nvidia, and Avast

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
28 Oct
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
28 Oct
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
Trending
October 28, 2026
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
October 28, 2026
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
October 28, 2026
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
October 27, 2026
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
October 27, 2026
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
October 27, 2026
Tailscale Merged Fixes for Most of the 11 “Tailworm” Findings Before Shipping a Release, and the Forwarding Switch It Recommends Already Exists in the Shipped Client

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026