◈ python mapVol 2 · Ch 16/30
Volume 2 Python, from the metal up · chapter 16

16Durability — flush, fsync, and the page cache

In Chapter 15 we opened a file, handed it bytes, and closed it. Python let us walk away believing the data was safe on disk. It wasn't. In this chapter we chase those bytes all the way down to the metal. We find the exact moment "saved" becomes true, because there's a lie hiding in write() that most tutorials never make you look at. Here's the plan. First we catch write() returning a number before a single byte has touched the platter. Then we peel apart the three volatile buffers stacked between your string and the disk — Python's own, the kernel's, and the drive's. And we meet os.fsync, the one syscall that forces bytes past all of them. The whole way down we keep asking the one thing that actually matters: if the power cut right now, which of my writes would survive? By the end we've built the atomic write-temp-then-rename save that never leaves a half-written file. We've also watched how a database pays fsync's brutal price just once for a thousand records. "I saved it" stops being a hope and becomes a claim you can prove.

iolinked · chapter 16 — the checkpoints6 steps
$ sections covered in Durability — flush, fsync, and the page cache
01write() lies: the page cache and write-back
02Python's buffer sits above the kernel's
03fsync is the only real durability guarantee
04The two crash windows, side by side
05Write-temp-then-rename: the atomic save
06Durability costs throughput — how databases cheat

01write() lies: the page cache and write-back

Let's start by watching the lie happen. You wrote the code. f.write(record) returned 128, the length of your line, right there in the return value. The next statement, print("saved!"), ran, and you watched it print. Then the building lost power for one second. The machine rebooted, you reopened the file, and the record is gone. Not garbled. Not half-there. Gone, as though write() had never been called. Every beginner carries the same unspoken assumption: the function returned, so the data is on the disk. And it is flat wrong. The gap between "write() returned" and "the platter holds my bytes" is exactly where real, customer-losing data loss lives. So before we can defend against it, we have to see it plainly. A successful return from write() is a promise the kernel has not yet kept.

So watch what physically happens on that call. write() is a syscall. You cross the border out of your process and into the kernel. (Volume 2's recurring boundary: what crosses is bytes, never live objects.) But the kernel does not turn around and speak to the storage device. It copies your bytes out of your process's memory into pages of the page cache — kernel-owned RAM that mirrors file contents. It flips those pages to dirty (the in-kernel flag is literally PG_dirty), bumps the file's in-memory size, and returns the byte count. That is the entire fast-path syscall: a memcpy plus a little bookkeeping. No seek, no platter, no flash — zero device I/O. Your "saved" record is sitting in volatile RAM that merely happens to belong to the kernel instead of to you.

The dirty pages reach the device later, asynchronously, driven by kernel writeback threads. You may know the old names pdflush/bdflush. Modern kernels use per-backing-device kworker writeback. "Later" is not vague. It is governed by three independent triggers, and knowing them tells you precisely how long your data hangs in the wind. First, a timer. A page that has been dirty longer than vm.dirty_expire_centisecs (about 30 seconds) becomes eligible, and a sweep runs every vm.dirty_writeback_centisecs (about 5 seconds) to flush the eligible ones. Second, memory pressure. Once dirty pages exceed vm.dirty_background_ratio the kernel wakes writeback in the background. If they climb to vm.dirty_ratio, your own next write() is forced to block until pages drain — the one moment write-back stops feeling free. Third, an explicit sync/fsync, which is the whole subject of this chapter.

THE ONE IDEA TO CARRY FORWARD
This is write-back caching, and it is the single reason disk I/O feels instant: you are almost always writing to RAM. The price is a durability window — from the instant write() returns until writeback commits the page, the only copy of your data is volatile kernel memory. A power cut in that window loses it, and your program never gets told, because from its point of view the write already succeeded.

Write-back is a deliberate trade, not an accident, and it helps to see its two siblings. Write-through caching would push each write to the device before returning. That's durable immediately, but every write() pays a device round-trip, so it is agonizingly slow. O_DIRECT goes the other way and bypasses the page cache entirely, handing I/O straight to the device. Databases use it to manage their own caching. Ordinary buffered I/O, what you get by default, chose write-back on purpose: speed now, durability on demand. The demand is the syscall you'll meet two sections from now.

You can watch the lie with a stopwatch. A hundred writes should touch the disk a hundred times and cost real milliseconds. Unless they don't touch the disk at all:

write_is_free.pypython
import time

with open("set.log", "wb") as f:
    start = time.perf_counter()
    for i in range(100):
        n = f.write(b"track played\n")   # returns 13 — bytes copied into cache
    elapsed = time.perf_counter() - start

print(elapsed)     # ~0.0001 s — 100 "writes", not one disk seek

A hundred microseconds for a hundred writes. That number is impossible if the platter were involved. A single seek on spinning rust is milliseconds, tens of thousands of times longer. The speed is the tell: write() returned the byte count the moment it finished the memcpy into cache, and nothing has left RAM. The return value 13 means "13 bytes copied," not "13 bytes persisted." Read that sentence again, because the rest of the chapter is nothing but the machinery for closing the gap between those two claims.

the durability windowWHAT YOUR PROGRAM SEESwrite(buf)returns immediatelyt = 0.00001 sWHAT PHYSICALLY HAPPENEDPage Cache (RAM, volatile)DIRTYwriteback thread, up to ~30 s laterplatterpersistentpower cut here — dirty page gone
Fig — write() returns the instant bytes reach volatile RAM; they are not durable until writeback reaches the platter — the gap between is the window a power cut can erase.
Wait — if the bytes are only in kernel RAM, how come another program (or cat) can read them back immediately, before any writeback? Because reads are served from the same page cache. The kernel hands every reader the up-to-date dirty page, so the file looks perfectly saved to any process on this machine. That is the cruelest part of the illusion: it survives everything except the one event — power loss — that empties RAM.

So write() only reaches the kernel's cache. The responsible move is to call f.flush() — but that closes a different gap than you think, because there is a second buffer you didn't know you had, living inside Python itself. →

02Python's buffer sits above the kernel's

You've learned that write() only reaches the page cache, so you reach for f.flush(), feeling diligent. Here is the trap that catches even experienced programmers. They believe flush() means "now it's on disk." It does not. It means "now it's in the kernel." There is a second buffer between your string and the platter, one that lives inside Python, in your own process. And flush() empties only that one. Until you can see both buffers with your own eyes, you cannot reason about how far any given call moves your data. So let's make them visible.

When you open() a file in the normal, buffered mode, Python does not hand you a bare file descriptor. It hands you a tower of wrappers. At the bottom is a FileIO object holding the raw OS file descriptor. Over it sits an io.BufferedWriter — an in-process bytearray, sized to io.DEFAULT_BUFFER_SIZE (8192 bytes, or the filesystem's block size). When you open in text mode there's a third floor on top: a TextIOWrapper that encodes your str into bytes (UTF-8, per Chapter 15) with its own buffering. You can pull the tower apart by hand:

the tower.pypython
>>> f = open("set.log", "w")
>>> type(f)
<class '_io.TextIOWrapper'>      # floor 3: str -> bytes
>>> type(f.buffer)
<class '_io.BufferedWriter'>     # floor 2: the bytearray in YOUR heap
>>> type(f.buffer.raw)
<class '_io.FileIO'>             # floor 1: the raw OS file descriptor
>>> f.buffer.raw.fileno()
3                            # the integer the kernel knows you by

Now the crucial mechanics. When you call f.write(s) in buffered mode, Python appends the bytes to that BufferedWriter bytearray, and no syscall happens. None. The data has not left your process. The kernel has never heard of it. Only three things push those bytes onward: the bytearray fills to 8192 bytes, or you call f.flush(), or you close(). Any of those makes Python issue the actual write() syscall(s) that push the bytes down into the kernel's page cache. So there are two distinct, stacked buffers between your string and the medium. First, (1) Python's BufferedWriter, in your process's heap. Second, (2) the kernel page cache, in kernel RAM. Two owners, two chunks of volatile memory, one border between them.

And f.flush() operates on exactly one of those borders. It drains Python's bytearray by issuing the write() syscall, pushing the bytes down into the kernel page cache. And it stops there. It issues no fsync. It triggers no device I/O. Nothing reaches the platter. After flush(), your data has simply moved from one volatile buffer to a different volatile buffer. You closed the near gap and left the more dangerous one wide open — the one that survives power loss. You can prove the whole story with os.stat, which reports the file size the kernel knows, not the bytes trapped in Python:

stuck_in_python.pypython
import os

p = "buf.log"
with open(p, "wb") as f:
    f.write(b"x" * 10)
    print(os.stat(p).st_size)   # 0  -> bytes still in Python's bytearray
    f.flush()
    print(os.stat(p).st_size)   # 10 -> flush() crossed into the kernel

Zero, then ten. Between those two prints your bytes physically travelled from your heap to the kernel's, and not one inch further. That jump from 0 to 10 is the Python→kernel boundary being crossed, rendered as an integer you can watch change.

SYNTAX · f.flush() — the call that moves your bytes exactly one floorand buffering= decides who holds them until it fires: 8192 bytes, one newline, or nobody at all
f.flush() — drain PYTHON's buffer into the kernel. That is all. open(path, "w") — the default: block-buffered, 8192 bytes open(path, "w", buffering=1) — line-buffered: one flush per "\n". TEXT ONLY. open(path, "wb", buffering=0) — unbuffered: one syscall per write. BINARY ONLY. open(path, "w", buffering=65536) — or name your own size print(msg, flush=True) — the same idea, spelled on the print side sys.stdout.flush() — the same call, on a stream you never opened f.buffer f.buffer.raw f.fileno() — one floor down, two down, the fd
f.flush()Issues the write() syscall that empties Python's BufferedWriter into the kernel page cache. It ends there. No device I/O happens, and nothing reaches the medium.
buffering=-1The default you get by saying nothing: block-buffered at io.DEFAULT_BUFFER_SIZE, which is 8192 bytes. Nothing crosses into the kernel until the bytearray fills, you flush, or you close.
buffering=1Line-buffered, and text mode only. Each "\n" you write triggers a flush, so a log tails live instead of arriving in 8 KB lumps. You pay one syscall per line for that.
buffering=0No Python buffer at all, and binary mode only. Every f.write() is its own syscall, straight to the kernel. Correct for a device or a pipe you must not batch; expensive everywhere else.
print(…, flush=True)sys.stdout is a buffered stream like any other. It is line-buffered on a terminal and block-buffered into a pipe, which is why piped output arrives late and out of order until you say flush=True.
f.buffer / f.buffer.rawThe tower, reachable by name. f is the TextIOWrapper, f.buffer the BufferedWriter holding the bytearray, f.buffer.raw the FileIO wrapped around chapter 15's file descriptor.
what it does not promiseNot durability. After flush() your bytes sit in volatile kernel RAM, safe from your process dying and helpless against a power cut. That second gap needs os.fsync, two sections from here.
you type
# buffering.py -- who is holding your bytes, and what makes them move
import io, os
from pathlib import Path

P = Path("buf.log")
print("io.DEFAULT_BUFFER_SIZE:", io.DEFAULT_BUFFER_SIZE, "bytes")

# buffering=-1 (the default): block-buffered. Bytes sit in YOUR heap.
with open(P, "wb") as f:
    f.write(b"x" * 10)
    print("block-buffered  after write :", os.stat(P).st_size, "bytes in the kernel")
    f.flush()
    print("block-buffered  after flush :", os.stat(P).st_size, "bytes in the kernel")

# buffering=1: line-buffered. The newline is the trigger. TEXT MODE ONLY.
with open(P, "w", encoding="utf-8", newline="\n", buffering=1) as f:
    f.write("Blinding Lights")
    print("line-buffered   no newline  :", os.stat(P).st_size, "bytes in the kernel")
    f.write("\n")
    print("line-buffered   newline sent:", os.stat(P).st_size, "bytes in the kernel")

# buffering=0: unbuffered. Every write() is its own syscall. BINARY MODE ONLY.
with open(P, "wb", buffering=0) as f:
    f.write(b"Titanium")
    print("unbuffered      after write :", os.stat(P).st_size, "bytes  (no flush called)")

try:
    open(P, "w", encoding="utf-8", buffering=0)
except ValueError as e:
    print("text + buffering=0          :", type(e).__name__ + ":", e)

with open(P, "w", encoding="utf-8") as f:      # the tower, by name
    print("floors                      :", type(f).__name__, "->",
          type(f.buffer).__name__, "->", type(f.buffer.raw).__name__, "| fd", f.fileno())
you see
io.DEFAULT_BUFFER_SIZE: 8192 bytes
block-buffered  after write : 0 bytes in the kernel
block-buffered  after flush : 10 bytes in the kernel
line-buffered   no newline  : 0 bytes in the kernel
line-buffered   newline sent: 16 bytes in the kernel
unbuffered      after write : 8 bytes  (no flush called)
text + buffering=0          : ValueError: can't have unbuffered text I/O
floors                      : TextIOWrapper -> BufferedWriter -> FileIO | fd 3
where beginners trip
  • flush() is not fsync(). It moves your data from one volatile buffer into a different volatile buffer, and stops.
  • buffering=0 in text mode raises ValueError: can't have unbuffered text I/O — the codec needs a buffer under it to work on.
  • buffering=1 in binary mode is not an error. Python warns RuntimeWarning: line buffering (buffering=1) isn't supported in binary mode and silently uses 8192 instead.
  • os.stat(path).st_size reports what the kernel holds, never what Python is still holding. That is exactly why it makes such a good probe.
  • Closing flushes, always — the with block's exit included. Closing never syncs. A closed file can still be a lost file.
  • Line buffering counts your newlines, not the disk's. Write "a" then "b" then "\n" and one flush carries all three.
A BYTE'S JOURNEY DOWN4d 79 20 ...Python BufferedWriterbytearray, your heapfloor 1Kernel page cacheRAMfloor 2Drive write cacheon the devicefloor 3Platter / flashPERSISTENTfloor 4volatile — lost on power cutstops heref.flush()os.fsync()preview
Fig — Two operations named "flush" reach two very different depths: f.flush() only empties Python's buffer into kernel RAM, while os.fsync() forces the bytes onto persistent media.
⚠ MOST BEGINNERS THINK…two witnesses agree — and both of them are blind
Fine — flush() only reaches the kernel, I accept that. So I will flush, and then prove where the bytes ended up: os.stat reports the full size, and reopening the file from scratch reads the line back byte for byte. Two independent witnesses, both saying saved. That settles it.
TYPE THIS — 10 SECONDS
>>> import os, time
>>> f = open("paid.log", "wb"); f.write(b"order 1041 paid\n")
>>> t = time.perf_counter(); f.flush(); round((time.perf_counter() - t) * 1000, 3)
>>> os.stat("paid.log").st_size, open("paid.log", "rb").read()
>>> t = time.perf_counter(); os.fsync(f.fileno()); round((time.perf_counter() - t) * 1000, 3)
>>> os.stat("paid.log").st_size, open("paid.log", "rb").read()
16
0.071
(16, b'order 1041 paid\n')
6.577
(16, b'order 1041 paid\n')
Read the last line: same size, same bytes, character for character the answer you already had — printed on the far side of the one call that genuinely reached the drive. Your two witnesses are standing on the floor flush() just delivered them to, which is exactly why neither can report on the floor below; that is not a flaw in how you checked, it is the only kind of answer os.stat and a fresh open() are built to give. The one reading that did move is the clock, and here the clock is the only instrument you own that reaches past the page cache. Seventy-one microseconds is not enough time to ask a storage device for anything; that is a memcpy and a return, so flush() demonstrably never spoke to the drive at all. Six and a half milliseconds is a real trip out to the controller and back, ninety times longer — and what it bought on the last line is nothing you can observe. Two labelled refinements, so this is not a half-truth. First, the clock is a tell, not a verdict: a fast call proves that no device wait happened during that call, not that the bytes are doomed — left alone, a writeback thread carries them down on its own, on the timer section 01 already named. What fsync buys is not eventually; it is now, before I tell the customer their order is paid. Second, the digits are one machine's, and not even steady on that one: charge the same barrier five hundred times in a row, as section 03 is about to, and the cost per barrier falls to a fraction of a millisecond — the one-shot figure above is the expensive end of a range, not a constant. What survives every machine is the direction, never the digits: a buffer copy is a memcpy, a barrier is a trip off the chip, and no amount of measuring makes the second one free.
flush() IS A LATERAL MOVE, NOT A DESCENT
Burn this in: flush() crosses the Python→kernel boundary; it does not cross the kernel→disk boundary. It takes your data from one volatile buffer to another. Everything that made write() a lie in the last section is still true after flush() — a power cut still loses your data. All you've bought is safety against your Python process dying, which is a real but much smaller prize.

✗ The myth

"There is one buffer — 'the file buffer' — and flush() empties it to disk."

✓ The reality

There are (at least) two: Python's BufferedWriter in your heap, and the kernel page cache. flush() empties only the first, into the second. A third — the drive's own write cache — is still below both, and nothing you've called yet has touched it.

Two buffers down, and both are volatile RAM. So what actually forces bytes onto the physical medium — past the kernel cache and the drive's own cache? There is exactly one syscall for it. →

TYPE THIS · save it, then run itbuffer_watch.py — ask a second process what it can see while you hold the bytes
# buffer_watch.py -- ask a SECOND process what it can see while the first one holds the bytes
import os, subprocess, sys
from pathlib import Path

LOG = Path("plays.log")
LOG.unlink(missing_ok=True)

READER = (
    "import sys, pathlib;"
    "p = pathlib.Path(sys.argv[1]);"
    "print('        another process reads:', repr(p.read_text(encoding='utf-8')))"
)

# our OWN stdout is buffered too, so we flush=True to keep this transcript in order
def ask_another_process():                       # a brand-new interpreter, its own memory
    subprocess.run([sys.executable, "-c", READER, str(LOG)], check=True)

with open(LOG, "w", encoding="utf-8", newline="\n") as f:
    f.write("Blinding Lights\n")                 # -> Python's BufferedWriter, in THIS heap
    print("after write() : os.stat says", os.stat(LOG).st_size, "bytes", flush=True)
    ask_another_process()

    f.flush()                                    # -> the write() syscall, into the kernel
    print("after flush() : os.stat says", os.stat(LOG).st_size, "bytes", flush=True)
    ask_another_process()

print("after close() : os.stat says", os.stat(LOG).st_size, "bytes")
print("the second process never saw the buffer -- it is not shared memory, it is YOUR heap")
after write() : os.stat says 0 bytes
        another process reads: ''
after flush() : os.stat says 16 bytes
        another process reads: 'Blinding Lights\n'
after close() : os.stat says 16 bytes
the second process never saw the buffer -- it is not shared memory, it is YOUR heap
Save it as buffer_watch.py and run it. The argument of this section is that Python holds your bytes in your own heap, and here we go and check with a witness. subprocess.run starts a brand-new interpreter, a separate process with its own memory, and asks it to read the file. Before the flush it reads ''. Not a truncated line, not a partial record: nothing at all, because there is nothing to read. Sixteen bytes exist in this program and nowhere else in the machine. os.stat agrees, reporting 0, since the size it reports is the size the kernel knows about. Then one call to f.flush() issues the write() syscall, and both witnesses change their story in the same instant. The file is 16 bytes and the other process reads the line back in full. Notice what we had to do to our own output to see this cleanly. print writes to sys.stdout, which is a buffered stream with exactly the same behaviour, so piping this program without flush=True reorders the transcript — the child's lines land before the parent's. The lesson bit us while we were writing the demonstration of the lesson. Two things to try. Delete both flush=True arguments, run it as python buffer_watch.py | cat, and watch the four lines shuffle. Then open the file with buffering=0 in binary mode and see the second process read the bytes with no flush at all — because there was no Python buffer standing in the way.

03fsync is the only real durability guarantee

Two buffers down, one to go — and it's the one that survives the power cut. You now know flush() lands your bytes in the kernel page cache, still volatile. So what actually drives them onto the physical medium? There is precisely one syscall for the job, and if you never call it, no amount of flushing will save your data from a crash. This is the section that hands you the only real durability primitive there is: os.fsync.

os.fsync(fd) tells the kernel: take every dirty page belonging to this file, schedule them to the device now, and do not return control to my process until the storage controller confirms they are physically persisted. That last clause is the whole difference. Where write() and flush() return after a memcpy, fsync performs real device I/O and blocks until it completes. That is why it costs orders of magnitude more: single-digit milliseconds on flash, tens of milliseconds on a spinning disk, versus microseconds for a buffer copy. When fsync returns, and only then, your bytes are on the medium.

There's a subtlety worth stating exactly, because it upgrades the mental model you've been building. On a correctly implemented stack, fsync doesn't only flush the kernel page cache. It also issues a cache-flush / FUA barrier to the drive's own volatile write cache, forcing bytes past that final buffer onto the platter or NAND. So the honest picture has three volatile stages, not two: Python's bytearray, the kernel page cache, and the drive cache. flush() defeats stage 1. fsync() defeats stages 2 and 3 together. This is exactly why serious databases care about drives with "power-loss protection." Some consumer SSDs acknowledge the barrier without honoring it, lying about durability to win benchmarks.

THE DURABLE-SAVE INCANTATION
In Python the durable write is a fixed two-step, and the order is not optional: f.flush() first — drain Python's bytearray into the kernel — then os.fsync(f.fileno()) — drive the kernel's dirty pages to the medium. Skip the flush and fsync may miss bytes still stranded in Python's buffer, which the kernel never received and therefore cannot sync.
durable.pypython
import os

def durable_write(path, data):
    with open(path, "wb") as f:
        f.write(data)          # -> Python's BufferedWriter (your heap)
        f.flush()              # -> kernel page cache (still volatile)
        os.fsync(f.fileno())   # -> the physical medium (durable at last)

Three lines, three floors of the tower, one call per floor. Notice what close() does and does not do. (It's the implicit exit of the with block.) It flushes Python's buffer to the kernel, so closing never strands bytes in your heap. But it does not fsync. A closed file can still be a lost file. Durability is never automatic. You ask for it by name.

SYNTAX · os.fsync(f.fileno()) — the two-step that survives a power cutthe order is a law, not a style: flush drains Python, fsync drains the kernel, and only the second one blocks
f.flush() — step 1: your heap -> the kernel. ALWAYS FIRST. os.fsync(f.fileno()) — step 2: the kernel -> the medium. BLOCKS. with open(path, "wb") as f: — the durable save, in full f.write(data) f.flush() os.fsync(f.fileno()) — close() will flush. close() will NOT sync. os.fdatasync(fd) — the data, no metadata barrier (POSIX only) os.sync() — every dirty page on the system. Almost never right. os.fsync(dirfd) — a directory has dirty pages too. See section 05.
os.fsync(fd)The only durability primitive there is. It schedules this file's dirty pages to the device and blocks your process until the controller confirms them. When it returns, a power cut can no longer take those bytes.
f.fileno()Chapter 15's integer, the kernel's receipt for this open file. fsync works at the descriptor level, so this is how a Python file object names itself to a syscall.
the order lawfsync can only push what the kernel already has. Sync before you flush and the bytes still stranded in Python's bytearray are not part of the deal — you paid the full price of a barrier and persisted nothing.
os.fdatasync(fd)Same data guarantee, one fewer metadata barrier: it may skip persisting mtime and friends. POSIX only, so guard it with hasattr(os, "fdatasync") if your code must also run on Windows.
os.sync()Flushes every dirty page belonging to every file on the system. It is the sledgehammer, it punishes every other process on the box, and it is almost never the right call in application code.
the priceA real device round-trip, and it is charged per call, not per byte. Syncing one record and syncing a thousand cost nearly the same, which is the whole loophole section 06 walks through.
close() is not enoughLeaving a with block flushes Python's buffer and hands the fd back. It issues no fsync. Durability is never automatic; you ask for it by name, on the line where you want it.
you type
# fsync_demo.py -- the only call that reaches the medium, and what it costs
import os, time
from pathlib import Path

P = Path("durable.log")

# ---- the order law: fsync can only push what the kernel already holds ----
with open(P, "wb") as f:
    f.write(b"Blinding Lights\n")
    os.fsync(f.fileno())                    # wrong order: nothing crossed yet
    print("fsync, no flush first :", os.stat(P).st_size, "bytes reached the kernel")
    f.flush()                               # step 1: heap  -> kernel
    os.fsync(f.fileno())                    # step 2: kernel -> medium
    print("flush THEN fsync      :", os.stat(P).st_size, "bytes, now on the medium")

# ---- the price, measured on this machine ----
ROWS = [b"track played\n"] * 500

def bench(rows, durable):
    with open(P, "wb") as f:
        start = time.perf_counter()
        for r in rows:
            f.write(r)
            f.flush()                       # heap -> kernel every time
            if durable:
                os.fsync(f.fileno())        # kernel -> medium every time
        return time.perf_counter() - start

fast = bench(ROWS, durable=False)
slow = bench(ROWS, durable=True)
print(f"500 records, flush only  : {fast * 1000:8.2f} ms")
print(f"500 records, flush+fsync : {slow * 1000:8.2f} ms")
print(f"one barrier costs        : {(slow - fast) / 500 * 1000:8.2f} ms")
print(f"durability is            : {slow / fast:8.0f}x slower, and it is not optional")
you see
fsync, no flush first : 0 bytes reached the kernel
flush THEN fsync      : 16 bytes, now on the medium
500 records, flush only  :     1.43 ms
500 records, flush+fsync :   141.40 ms
one barrier costs        :     0.28 ms
durability is            :       99x slower, and it is not optional
where beginners trip
  • The first line of that output is the order law, felt once: fsync before flush reached the kernel with zero bytes to sync, and reported success.
  • os.fsync(f) does work — CPython calls fileno() for you. Write os.fsync(f.fileno()) anyway, because the explicit form says which floor of the tower you are standing on.
  • Syncing a file does not persist its name. A brand-new file needs its directory synced too, which is why the atomic save in section 05 has a fourth step.
  • The timings above come from one laptop's NVMe SSD. On a spinning disk the barrier costs tens of milliseconds instead of tenths, and the ratio grows with it.
  • os.fdatasync simply does not exist on Windows, so an unguarded call is an AttributeError on someone else's machine, not a slow path.
  • Wrapping every write in flush + fsync is not diligence, it is a 100× tax. Sync where the data must survive, and section 06 shows how to batch the rest.

And you can feel its price with a stopwatch. Run ten thousand tiny records two ways: flush only, versus flush-plus-fsync. The gap is not subtle:

price_of_durability.pypython
import os, time

def bench(use_fsync):
    with open("t.log", "wb") as f:
        start = time.perf_counter()
        for _ in range(10_000):
            f.write(b"row\n")
            f.flush()
            if use_fsync:
                os.fsync(f.fileno())
        return time.perf_counter() - start

print(bench(False))   # ~0.02 s   — all buffer copies
print(bench(True))    # ~20-30 s  — 1000x, each ms a device round-trip

A thousand-fold. That entire difference is device physics. The flush-only run never left RAM, while the fsync run waited on the storage controller ten thousand separate times. This is the wow that reframes the chapter. Durability has a fixed, unavoidable, per-call price measured in device round-trips. And every storage system ever built is, at heart, a scheme for paying that price as few times as possible. (You'll see the scheme in the final section.) If your file's mtime and size don't matter for readback, os.fdatasync is the cheaper cousin. It persists the file data but skips the extra metadata barrier: same data durability, one fewer barrier per call.

SAME TOWER, DRAWN TO TIME SCALEPython BufferedWriterfloor 1Kernel page cache (RAM)floor 2Drive write cachefloor 3Platter / flash (persistent)floor 4FLUSH / FUA barrierf.flush()~microseconds(memcpy)os.fsync()~5–10 msblocks onthe device
Fig — The guarantee and the wall-clock cost live in the same place: os.fsync() blocks for milliseconds driving a flush barrier through the drive cache to persistent media — f.flush() is a free microsecond memcpy that guarantees nothing.
Wait — if fsync is what makes data durable, why doesn't Python just call it inside write() for me? Because it would make every write a thousand times slower for a guarantee most writes don't need — logs, caches, temp files, scratch data. The kernel and Python default to fast and let you pay for durable only on the bytes that must survive a power cut. Durability isn't the default; it's a deliberate, priced request.

Now you hold all three primitives — write, flush, fsync — and each leaves your data in a different buffer. The payoff is being able to point at any line and name exactly what a crash there would destroy. →

NOW WRITE IT YOURSELFmake the log durable — chapter 15's append_log, hardened, and the bill that comes with it
In chapter 15 you wrote append_log.py: open plays.log in mode "a", write one line per track played, close. You ran it twice and watched the file grow across two processes, and it felt safe. It is not, and you can now say exactly why: every one of those lines died in a buffer if the power went. Harden it. First, place the two calls. Rewrite the function so the batch is flushed and then fsynced before the handle is released. The placement is the exercise: both calls belong inside the with block, after the last write, because the block's exit will flush for you and will never sync for you. Second, choose your granularity and measure it. Write three versions — flush only, one fsync per batch, one fsync per line — and time a couple of hundred batches of each with time.perf_counter(). Print milliseconds per batch, not totals, so the numbers stay comparable. Predict the ordering before you run it, then check whether the per-line version costs twice the per-batch one or merely a little more. Third, write the durability claim in words. For each version, one line: which faults it survives, which it does not, and what a reader would find in the file afterwards. "Flush only" survives a killed process and loses everything to a power cut; say the other two yourself. One hint and no more: os.fsync needs a live descriptor, so calling it after the with block has closed the file raises ValueError: I/O operation on closed file. That error is the language refusing to let you sync something you no longer hold.
show the solution
# durable_log.py -- chapter 15's append_log, hardened, and the bill for it
import os, time
from datetime import datetime
from pathlib import Path

LOG = Path("plays.log")
PLAYED = ["Blinding Lights", "Titanium"]

def append_plays(path, titles, mode):
    stamp = datetime.now().strftime("%H:%M:%S")
    with open(path, "a", encoding="utf-8", newline="\n") as f:   # 'a' from chapter 15
        for title in titles:
            f.write(f"{stamp}  played  {title}\n")
            if mode == "per-line":
                f.flush()
                os.fsync(f.fileno())          # a barrier for every single line
        f.flush()                             # heap -> kernel, always
        if mode == "per-batch":
            os.fsync(f.fileno())              # one barrier for the whole batch

def bench(mode, batches=200):
    LOG.unlink(missing_ok=True)
    start = time.perf_counter()
    for _ in range(batches):
        append_plays(LOG, PLAYED, mode)
    return (time.perf_counter() - start) / batches * 1000

flush_only = bench("flush-only")
per_batch  = bench("per-batch")
per_line   = bench("per-line")

print(f"flush only          : {flush_only:6.2f} ms per batch   survives a crash, not a power cut")
print(f"fsync per batch     : {per_batch:6.2f} ms per batch   survives both, 1 barrier")
print(f"fsync per line      : {per_line:6.2f} ms per batch   survives both, 2 barriers")
print(f"the barrier costs   : {(per_batch - flush_only):6.2f} ms - and it is per CALL, not per byte")
print("lines in the log    :", len(LOG.read_text(encoding="utf-8").splitlines()))

# ------------- what it prints -------------
flush only          :   0.23 ms per batch   survives a crash, not a power cut
fsync per batch     :   0.73 ms per batch   survives both, 1 barrier
fsync per line      :   0.99 ms per batch   survives both, 2 barriers
the barrier costs   :   0.49 ms - and it is per CALL, not per byte
lines in the log    : 400

04The two crash windows, side by side

You have the three primitives. Here is the payoff: you can look at a specific line of your save routine and say, with certainty, what a crash on that line would destroy. Vague dread — "I might lose data" — is useless. A real durability argument names the exact window and the exact bytes at risk. This section turns your buffer knowledge into a crash-survival map you can read off any code that touches a file. And it hinges on a distinction most programmers never make. A process crash and a power cut are not the same event, and they claim different victims.

The method is simple. Lay a crash on a timeline of your three calls and ask one question: which buffers currently hold the only copy of the data? Because each buffer has a different owner and a different lifetime, the answer changes as your bytes descend the tower. There are two named windows, each defined by which call has not yet run.

Window A — after write(), before flush(). The bytes live only in Python's user-space BufferedWriter, inside your process's heap. The kernel has never seen them. A crash here loses them, even a clean kill -9 with the power cable untouched. A killed process's heap simply evaporates, and nothing flushes a dead process's buffer. Note the asymmetry that makes this window uniquely dangerous: it is lost on a mere process crash, not just power loss. An unhandled exception, a segfault in a C extension, an OOM kill — any of these, in Window A, and the bytes are gone.

Window B — after flush(), before fsync(). Now the bytes are out of Python and in the kernel page cache. Here the asymmetry flips in your favor: a process crash is now survivable. The kernel owns those pages, and it will write them back on its own schedule whether or not your program still exists. So kill -9, an exception, an interpreter crash — none of them lose the data in Window B, because the data no longer depends on your process being alive. But a power cut or a kernel panic in Window B still loses everything, because the page cache is volatile RAM that writeback hadn't yet drained. Only when fsync returns are you clear of both windows: the bytes are on the medium and survive power loss.

THE CRUX OF DURABILITY REASONING
"I called write" is not an argument. "I called flush, so I survive a process crash" is a modest one. "I called fsync and it returned, so I am past Window B" is the only claim that survives a power cut. Durability is a property you prove by naming the last call you made — not a feeling.

You can make the abstract windows concrete. Build a save routine that crashes on command, plus a reader that reports what a fresh process would recover:

crash_windows.pypython
import os

def save(f, data, crash_at=None):
    f.write(data)
    if crash_at == "window_A":      # bytes only in Python's heap
        raise SystemExit            # process dies: data LOST
    f.flush()
    if crash_at == "window_B":      # bytes in kernel page cache
        raise SystemExit            # process dies: data SURVIVES
    os.fsync(f.fileno())        # past both windows: durable

Crash in window_A, reopen the file in a brand-new process, and the record is missing. The kernel was never told. Crash in window_B and the same reopen finds the record intact, because the kernel finished the job your dead process couldn't. That single behavioral difference is the two-window model made real: same crash type, opposite outcome, decided entirely by whether flush() had run. To model the power cut you need a simulator, because a real power cut also wipes the page cache. Picture a three-band model where power_cut() clears both python_buf and page_cache but keeps medium. Run it at each stage, and it prints exactly which records survive.

DURABILITY OVER TIME — three gateswrite()flush()fsync()in Python heap onlyin kernel page cacheDURABLE= process crash= power cut
Fig — Each gate widens what survives: after flush() a process crash is harmless but a power cut still wins; only after fsync() are the bytes durable against both.
THE BUG THAT PASSES EVERY TEST
Code that flushes religiously but never fsyncs is perfectly safe against application crashes and completely defenseless against power loss. Your test suite kills processes and asserts recovery — and it always passes, because Window B survives process kills. Then a datacenter loses power once a year and the same code silently drops a customer's data. Process crashes are common and get tested; power cuts are rare and don't. That mismatch is why "it works in testing" is worthless as a durability claim.

You can now protect a single write. But real programs face a nastier failure — a crash halfway through overwriting a file that was already good — and there fsync alone isn't enough. →

TYPE THIS · save it, then run itcrash_the_process.py — kill a real process in each window and read the wreckage
# crash_the_process.py -- kill a real process in each window, then read the file from a live one
import os, subprocess, sys
from pathlib import Path

LOG = Path("windows.log")

CHILD = r'''
import os, sys
stage = sys.argv[1]
with open("windows.log", "wb") as f:
    f.write(b"order 1041 confirmed\n")   # -> Python's BufferedWriter, in the CHILD's heap
    if stage == "A":
        os._exit(1)                      # Window A: die with the bytes still in the heap
    f.flush()                            # -> the kernel page cache
    if stage == "B":
        os._exit(1)                      # Window B: die with the kernel holding them
    os.fsync(f.fileno())                 # -> the medium
    os._exit(1)                          # past both windows
'''

STAGES = [("A", "die after write()  "),
          ("B", "die after flush()  "),
          ("C", "die after fsync()  ")]

for stage, label in STAGES:
    LOG.unlink(missing_ok=True)
    subprocess.run([sys.executable, "-c", CHILD, stage])      # a real process, really killed
    print(label, "-> this process now reads:", repr(LOG.read_bytes()))

print()
print("os._exit() skips every cleanup CPython would do -- no flush, no close, no atexit.")
print("Window B survives because the kernel, not the dead child, owns those pages now.")
print("What no test on one machine can show you: a power cut also empties the kernel's.")
die after write()   -> this process now reads: b''
die after flush()   -> this process now reads: b'order 1041 confirmed\n'
die after fsync()   -> this process now reads: b'order 1041 confirmed\n'

os._exit() skips every cleanup CPython would do -- no flush, no close, no atexit.
Window B survives because the kernel, not the dead child, owns those pages now.
What no test on one machine can show you: a power cut also empties the kernel's.
Save it as crash_the_process.py and run it. Nothing here is simulated. Each child is a real interpreter that really dies, and os._exit(1) is the way to kill it honestly: it ends the process immediately, with no flush, no close, no atexit, exactly like a segfault or an OOM kill. The three lines of output are the two-window model, measured. Die in Window A, after write(), and the parent reads b'': the bytes were in the child's heap and the heap went with it. Die in Window B, after flush(), and the record is there in full — not because the child recovered, but because it no longer mattered. The kernel owns those pages, and it will write them back on its own schedule whether or not the process that produced them still exists. That is the whole asymmetry: flush() buys you survival of your program, and nothing more. Now read the last line of the program, because it is the honest one. A power cut also empties the kernel's cache, and no test you can run on one healthy machine will show you that. This is why the flush-only bug ships. Your test suite kills processes, the code passes, and the failure it is actually vulnerable to is the one you cannot write a test for. Two things to try. Swap os._exit(1) for sys.exit(1) in stage A and watch the data survive after all, because a normal exit unwinds the interpreter and flushes the buffer — you have accidentally tested nothing. Then add a fourth stage that dies between flush() and fsync(), and notice you cannot tell it apart from stage B here. Only the power cut can, and that is precisely the point.

05Write-temp-then-rename: the atomic save

fsync makes a write durable, but it does nothing about a nastier, more common disaster: a crash halfway through overwriting an existing good file. Picture updating config.json. You open it for writing, and before you've written a single new byte, you have neither the old complete version nor a complete new one. You have a truncated husk, and the good data you were replacing is gone forever. Every config editor, every save-game, every document app must guarantee one invariant: a reader always sees SOME valid file, never a blend of old and new. This section teaches the filesystem trick that makes a half-written file impossible to observe: the atomic rename.

First, see the trap precisely. open(target, "wb") truncates the existing file to zero length as part of opening it — before your first write(). So the destruction happens up front, and the window of vulnerability is the entire duration of your write. A crash anywhere in it leaves target corrupt, and the old contents are already destroyed. You can reproduce it in two lines: open for writing, raise an exception before writing anything, and os.stat(target).st_size is now 0. The good file is gone and you never wrote the new one.

The fix exploits a POSIX guarantee that is one of the quiet load-bearing facts of all of computing. rename(2), which is Python's os.replace, is atomic within a single filesystem. A rename doesn't move bytes. It flips a directory entry from pointing at the old inode to pointing at the new inode, in one indivisible metadata operation. No observer, and no crash, can catch it half-done. Any reader that looks sees either the complete old file or the complete new file, never a partial one. So instead of editing the target in place, you build the new version off to the side. Then you swing the name over to it in a single atomic instant.

THE ATOMIC-SAVE RITUAL — FOUR STEPS, NONE OPTIONAL
(1) Write the full new content to a temp file in the same directory as the target. (2) flush() then os.fsync the temp file's descriptor, so its data is durably on the medium before the swap. (3) os.replace(tmp, target) — the atomic pointer flip. (4) fsync the directory, because the rename is itself a metadata change that also lives in the page cache. Each step defends a specific failure; drop any one and the invariant breaks.
atomic_write.pypython
import os, tempfile

def atomic_write(path, data):
    d = os.path.dirname(path) or "."
    fd, tmp = tempfile.mkstemp(dir=d)        # same dir -> same filesystem
    try:
        with os.fdopen(fd, "wb") as f:
            f.write(data)
            f.flush()
            os.fsync(f.fileno())            # step 2: temp's DATA is durable
        os.replace(tmp, path)                # step 3: atomic name flip
        dirfd = os.open(d, os.O_RDONLY)      # step 4: persist the rename itself
        try:
            os.fsync(dirfd)
        finally:
            os.close(dirfd)
    except BaseException:
        os.unlink(tmp)                        # never leave a stray temp behind
        raise

Walk the failure modes, because each line is defending one. If you skip step 1 and write in place, you already saw the truncated-husk disaster. If you put the temp in /tmp instead of the target's directory, you may cross a filesystem boundary, since /tmp is often a separate mount. Then os.replace silently degrades from an atomic rename into a non-atomic copy-then-delete, reopening exactly the window you were trying to close. If you skip step 2, you can atomically rename an empty or partial temp file over your good one. A crash leaves target pointing at durable garbage: durability without the data, the worst of both worlds. And if you skip step 4, the rename is durable-in-name-only. The directory entry change sat in the page cache, and a power cut right after replace can un-happen the swap even though the temp file's data was safely persisted. Do all four and the invariant holds through any crash: target always names a complete, fsync'd file.

PATTERN · write a temp file, then os.replace() — the save no crash can corruptfour steps, none optional; the shape you will type for every config, every save-game, every document
import os, tempfile def atomic_write(path, data): d = os.path.dirname(os.path.abspath(path)) fd, tmp = tempfile.mkstemp(dir=d) — 1. same directory = same filesystem try: with os.fdopen(fd, "wb") as f: f.write(data) f.flush() os.fsync(f.fileno()) — 2. the temp file's DATA is durable os.replace(tmp, path) — 3. one atomic name flip fsync_dir(d) — 4. persist the rename itself except BaseException: os.unlink(tmp); raise — leave no litter behind os.replace(src, dst) — overwrites dst silently. THE one to use. os.rename(src, dst) — same call on POSIX; raises if dst exists on Windows. shutil.move(src, dst) — copies across filesystems. Convenient, and NOT atomic.
tempfile.mkstemp(dir=…)Creates the temp file and hands back a raw descriptor plus its path, with no race in between. The dir= argument is load-bearing: it is what keeps the temp on the same filesystem as the target.
os.fdopen(fd, "wb")Wraps that bare descriptor in the file object you already know, so you get write, flush and the with block back. Chapter 15's tower, built on a descriptor you were handed rather than one open() made.
os.replace(tmp, path)The atomic step. It moves no bytes; it flips a directory entry from the old inode to the new one, indivisibly. Every reader sees the complete old file or the complete new one, never a blend.
os.rename vs shutil.moveos.rename is the same syscall but refuses an existing target on Windows. shutil.move will happily copy across a filesystem boundary, which silently trades your atomicity for convenience.
fsync_dir(d)A directory is a file of names, and its pages get dirty like any other. Open it read-only and fsync that descriptor. POSIX only: Windows will not let you open a directory, so real code branches on os.name.
the except / unlinkThe one step that protects nothing but your tidiness. If anything raises before the flip, delete the temp and re-raise. Without it a failing save leaves a new stray file on every attempt.
the invariant it buysNot "my write succeeded". Something stronger: the target name always points at a complete, fsync'd file, at every instant, through any crash. That property is what a config file, a save-game and a database page all need.
you type
# atomic.py -- the four-step ritual, and the disaster it exists to prevent
import os, tempfile
from pathlib import Path

TARGET = Path("config.json")
GOOD = b'{"volume": 7, "shuffle": true}\n'
NEW  = b'{"volume": 9, "shuffle": false}\n'

def fsync_dir(d):                                  # step 4 -- POSIX only, no-op on Windows
    if os.name == "nt":
        return
    fd = os.open(d, os.O_RDONLY)
    try:
        os.fsync(fd)
    finally:
        os.close(fd)

def atomic_write(path, data, crash=False):
    d = os.path.dirname(os.path.abspath(path))
    fd, tmp = tempfile.mkstemp(dir=d)              # step 1: same dir -> same filesystem
    try:
        with os.fdopen(fd, "wb") as f:
            f.write(data)
            if crash:
                raise RuntimeError("crash, mid-write")
            f.flush()
            os.fsync(f.fileno())                   # step 2: the temp file's data is durable
        os.replace(tmp, path)                      # step 3: one atomic name flip
        fsync_dir(d)                               # step 4: persist the rename itself
    except BaseException:
        os.unlink(tmp)                             # never leave a stray temp behind
        raise

# --- the disaster: overwrite in place, crash before writing one byte ---
TARGET.write_bytes(GOOD)
try:
    with open(TARGET, "wb") as f:                  # "wb" truncates AT OPEN TIME
        raise RuntimeError("crash, mid-write")
except RuntimeError:
    pass
print("in place, crashed  :", os.stat(TARGET).st_size, "bytes ->", TARGET.read_bytes())

# --- the ritual: the same crash, at the same moment ---
TARGET.write_bytes(GOOD)
try:
    atomic_write(TARGET, NEW, crash=True)
except RuntimeError:
    pass
print("atomic,   crashed  :", os.stat(TARGET).st_size, "bytes ->", TARGET.read_bytes())
print("temp files left    :", sorted(p.name for p in Path(".").glob("tmp*")))

atomic_write(TARGET, NEW)                          # and now let it finish
print("atomic,   completed:", os.stat(TARGET).st_size, "bytes ->", TARGET.read_bytes())
you see
in place, crashed  : 0 bytes -> b''
atomic,   crashed  : 31 bytes -> b'{"volume": 7, "shuffle": true}\n'
temp files left    : []
atomic,   completed: 32 bytes -> b'{"volume": 9, "shuffle": false}\n'
where beginners trip
  • Line one of that output is the disaster in full: the in-place version crashed before writing a single byte and the good file was already gone, because "wb" truncates at open time.
  • Putting the temp in /tmp feels tidy and breaks everything. /tmp is often a separate mount, and os.replace across filesystems raises OSError: [Errno 18] Invalid cross-device link.
  • Path.write_text and Path.write_bytes are not atomic. They are open("w") in one line, truncation included.
  • Skip step 2 and you can atomically install a half-written file over a good one. Durable garbage is worse than no durability, because it survives.
  • The ritual protects the target, not the directory listing. A crash after the temp is created still leaves a stray file, so sweep your own temps at startup.
  • On Windows os.replace can fail with PermissionError if another process holds the target open. POSIX simply swings the name and lets the old reader keep reading.
OVERWRITE IN PLACE — unsafesong.txt (has data)open('wb')truncates to 0empty husk — 0 bytescrash mid-writehalf-written fileold data GONE, new data partialTEMP + RENAME — atomicsong.txtold — untouched.song.txt.tmpwritten + fsync'ddirectory entry"song.txt" →inode 41 oldinode 88 newos.replace (atomic)a crash anywhere still leaves a whole file
Fig — Truncating the target first bets your only copy on the write finishing; writing a temp then os.replace() swaps a single directory pointer, so a reader sees either the whole old file or the whole new one — never a fragment.
Wait — what happens to the old inode after the flip? Its directory name is gone, but if another process still has it open, the bytes live on — Unix keeps an inode alive until both its link count hits zero and no open descriptor references it. That reader keeps seeing the complete old file to its last byte, while every new opener gets the new file. Two consistent versions coexisting, no lock, no blend — that's the deep elegance the atomic rename buys you for free.

Now you can save one file durably and atomically. But do it in a loop — one fsync per record — and your program crawls. Databases commit tens of thousands of durable writes a second on the same disk. Here's the trick. →

TYPE THIS · save it, then run itdurable_save.py — chapter 15's playlist save, now unkillable
# durable_save.py -- chapter 15's playlist save, hardened: flush, fsync, atomic replace
import os, subprocess, sys, tempfile
from pathlib import Path

PLAYLIST = Path("playlist.txt")
TRACKS = ["Blinding Lights", "Titanium", "Levels", "bad guy"]

def fsync_dir(d):                                    # step 4 -- POSIX only
    if os.name == "nt":                              # Windows cannot open a directory
        return
    fd = os.open(d, os.O_RDONLY)
    try:
        os.fsync(fd)
    finally:
        os.close(fd)

def durable_save(path, tracks, crash=False):
    d = os.path.dirname(os.path.abspath(path))
    fd, tmp = tempfile.mkstemp(dir=d, prefix=".playlist-", suffix=".tmp")   # step 1
    try:
        with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
            for title in tracks:
                f.write(title + "\n")
            f.flush()                                # heap   -> kernel
            os.fsync(f.fileno())                     # step 2: kernel -> medium
        if crash:
            os._exit(1)                              # killed between fsync and replace
        os.replace(tmp, path)                        # step 3: the atomic name flip
        fsync_dir(d)                                 # step 4: persist the rename itself
    except BaseException:
        os.unlink(tmp)
        raise

def load(path):
    return path.read_text(encoding="utf-8").splitlines() if path.exists() else []

if __name__ == "__main__":
    if "--crash" in sys.argv:                        # the doomed child process
        durable_save(PLAYLIST, ["JUNK", "HALF WRITTEN"], crash=True)

    durable_save(PLAYLIST, TRACKS)
    print("saved            :", load(PLAYLIST))

    subprocess.run([sys.executable, __file__, "--crash"])     # a real process, really killed
    print("after that crash :", load(PLAYLIST))
    strays = sorted(Path(".").glob(".playlist-*.tmp"))
    print("stray temp files :", len(strays), "- the target was never in danger, but litter is real")
    for s in strays:
        s.unlink()                                   # sweep at startup, every real program does

    durable_save(PLAYLIST, load(PLAYLIST) + ["Don't Start Now"])
    print("saved            :", load(PLAYLIST))
saved            : ['Blinding Lights', 'Titanium', 'Levels', 'bad guy']
after that crash : ['Blinding Lights', 'Titanium', 'Levels', 'bad guy']
stray temp files : 1 - the target was never in danger, but litter is real
saved            : ['Blinding Lights', 'Titanium', 'Levels', 'bad guy', "Don't Start Now"]
Save it as durable_save.py and run it. This is the same playlist.txt you wrote in chapter 15 with write_then_read.py, carrying the same four tracks, and every line that has been added since is durability. The save now writes to a temp file beside the target, flushes, fsyncs, and only then swings the name across with os.replace. Then it runs itself again as a child process and kills that child at the cruellest possible instant: after the new content is durably on the medium, before the rename. Read the second line of output. The playlist is exactly what it was — four tracks, in order, complete. Not repaired, not recovered. It was never in danger, because the name never pointed anywhere except at a finished file. That is the invariant, demonstrated against a real process death rather than argued for. Then read the third line, which is the part most tutorials leave out. One stray temp file is sitting in the directory, because os._exit gave the child no chance to clean up. The ritual guarantees your target is never corrupt; it guarantees nothing about litter, which is why the prefix is chosen to be sweepable and the program sweeps it. Two things to try. Comment out the os.fsync in step 2 and the program still prints the same thing, because a process crash was never the threat that step defends against — it is there for the power cut you cannot stage. Then move mkstemp to dir=tempfile.gettempdir() and run it on a machine where /tmp is its own mount: os.replace raises OSError, and the same-filesystem rule stops being a footnote.

06Durability costs throughput — how databases cheat

You now know the honest, safe save: fsync every record. Put it in a loop and watch your program crawl. You get a few hundred durable writes per second, because each fsync blocks on slow physical I/O and you're paying that price once per record. Yet real databases commit tens of thousands of durable transactions per second on the very same hardware. They are not cheating on durability. Every one of those transactions genuinely survives a power cut. They are amortizing durability's cost with one specific, beautiful trick. It is the design pattern this whole chapter has been building toward: the write-ahead log.

Start from the physics you measured two sections ago. fsync's guarantee comes from waiting on the device, and that wait is a fixed cost per call. It's roughly one device round-trip plus a cache-flush barrier, almost independent of how many bytes you're syncing. Persisting one record and persisting a thousand records in a single fsync cost nearly the same, because the barrier dominates and the extra bytes ride along for free. Read that again, because it's the entire insight: fsync's cost is per-call, not per-byte. If you call it once per record, throughput is capped at about 1/latency. That's a few hundred to a few thousand ops per second, forever, no matter how fast your CPU. The only way up is to make one fsync do the work of many.

A write-ahead log does exactly that, by combining two moves. First, instead of updating scattered data structures in place — random I/O, an fsync for each — you append every change as a record to the end of a single log file. Appends are sequential writes: no seeks, cache-friendly, the fastest thing a storage device does. Second, you fsync the log once for a whole batch of appended records, making them all durable together with a single barrier. Many small durable writes collapse into one. Here are the two strategies side by side, and the second is the entire ballgame:

wal_vs_naive.pypython
import os

def commit_each(records):        # one barrier PER record — slow
    with open("wal.log", "ab") as f:
        for r in records:
            f.write(r + b"\n")
            f.flush()
            os.fsync(f.fileno())   # N records -> N device round-trips

def commit_group(records):       # ONE barrier for the whole batch — fast
    with open("wal.log", "ab") as f:
        for r in records:
            f.write(r + b"\n")   # sequential appends, all in cache
        f.flush()
        os.fsync(f.fileno())       # N records -> 1 device round-trip

Feed both a thousand records. commit_each pays a thousand barriers, commit_group pays one, and the throughput gap is roughly a thousand-fold. It's the same ratio you measured in section 3, now working for you instead of against you. This is group commit, and a real database doesn't even need the records to arrive in one call. It accumulates the commit records of many concurrent transactions arriving within a small time window (a millisecond or two). It fsyncs once, then acknowledges them all together. Each transaction pays a tiny added latency, waiting a moment for the group to form. In exchange it gets a massive throughput gain and full durability. Latency up a hair, throughput up 100×, durability uncompromised.

WHY IT'S CALLED "WRITE-AHEAD"
The log record is fsync'd to the medium before the real data files are touched. So the log is the source of truth and the data files are allowed to lag. After a crash, the engine replays the durable log — redoing every committed change that hadn't yet reached the data files. The data can be behind because the log already caught it. Recovery is just reading the log forward.
replay.pypython
def replay(path):                  # a database's crash recovery, in miniature
    state = {}
    with open(path, "rb") as f:
        for line in f:
            key, val = line.rstrip(b"\n").split(b"=", 1)
            state[key] = val          # redo every durable change, in order
    return state

That is the core of a storage engine in about twenty lines: append changes to a durable log, replay it to rebuild state after a crash. Everything else — B-trees, MVCC, checkpoints that truncate the log once the data files catch up — is optimization on top of this spine. And notice the reframe of the whole chapter. fsync is the expensive, irreducible primitive, and the write-ahead log is the design pattern that makes durability affordable. It is literally the temp-file and fsync ideas of the last two sections, scaled from one file to a production database: write it down safely first, make the durable copy the source of truth. You didn't learn six disconnected tricks. You learned one law (durability costs a device round-trip) and the single pattern the entire industry uses to pay it as rarely as possible.

ERROR CLINICyou will meet these — decoded
Traceback (most recent call last):
  File "append_log.py", line 8, in <module>
    append_play("plays.log", "Blinding Lights")
  File "append_log.py", line 6, in append_play
    os.fsync(f.fileno())            # now make it survive a power cut
             ^^^^^^^^^^
ValueError: I/O operation on closed file
You did everything this chapter asked for, one line too late. The with block ended above, and its exit did exactly two things: it flushed Python's buffer into the kernel, and it handed the descriptor back. f is still a perfectly good name for a file object; what it no longer holds is a number the kernel will answer to, and that number is the only thing fsync takes. It is really saying: the door you want to sync through is already shut, and shutting it synced nothing. Notice what is not broken here, because it is the two-window model compressed into one error — the line did reach the page cache, so a process crash would not lose it. The only thing you failed to buy is the power cut.
durability is asked for inside the block that owns the descriptor — f.flush() then os.fsync(f.fileno()) as the last two lines under the with
Traceback (most recent call last):
  File "save_config.py", line 6, in <module>
    os.fsync(fd)                        # step 2 of the ritual, one line too late
    ^^^^^^^^^^^^
OSError: [Errno 9] Bad file descriptor
The same mistake, now wearing the atomic save's clothes. os.fdopen(fd, "wb") does not borrow the descriptor mkstemp handed you, it adopts it — on the run that produced this traceback mkstemp returned descriptor 3, and closing the wrapper at the end of the with closed 3 along with it, so the integer still sitting in your variable now names nothing. It is really saying: 3 is a number, not a file, and no open file is numbered 3. Then flinch at this, because the crash is the lucky outcome. Open one more file before that line and the descriptor table hands back the lowest free number, which is 3 again — POSIX guarantees that lowest-free rule, and the Windows C runtime we ran this on behaves the same way, so the hazard is not one platform's quirk. We ran exactly that, and os.fsync(fd) returned perfectly happily, having durably synced an unrelated log file. A stale descriptor does not stay wrong. It goes quietly right, about the wrong file.
sync while you still own it — os.fsync(f.fileno()) inside the with os.fdopen(fd, "wb") as f: block, never os.fsync(fd) after it
Traceback (most recent call last):
  File "test_durable.py", line 8, in <module>
    durable_write(io.BytesIO(), b"order 1041 paid\n")   # a stand-in for the real file
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "test_durable.py", line 6, in durable_write
    os.fsync(stream.fileno())
             ^^^^^^^^^^^^^^^
io.UnsupportedOperation: fileno
The third way to hold no descriptor is never to have had one. A BytesIO is a file-shaped object with no file underneath it: it answers write, it answers flush, and both answers are honest, because draining a buffer into nowhere is still draining a buffer. fileno is where the impersonation has to stop, since a descriptor is a receipt the kernel issues and nobody issued one here. It is really saying: I can imitate a file, I cannot imitate the disk. Read it as good news. The stand-in you swapped in to keep the test fast had silently removed the one property the test existed to check, and this line is the only reason you ever found out.
durability is a property of a descriptor, not of a stream — point the test at a real file in a temp directory, or assert that os.fsync was called and leave the syscall to the real run
NOW WRITE IT YOURSELFwhen NOT to fsync — three scenarios, and the honest cost of each answer
You have spent this chapter learning to buy durability. The engineering skill is knowing when not to. Here are three files, and for each one you owe an answer plus the reasoning behind it. (a) A search index your service rebuilds from the source database in about forty seconds, on every start. (b) A telemetry stream of fifty thousand metrics per second, feeding a dashboard where a two-second hole is invisible. (c) A ledger row that says a customer paid you, written just before your API answers 200 OK. Work each one the same way: name the fault you fear, name what is actually lost when it happens, then name what it costs to get that thing back. The answer falls out of the third question every time, because a file you can regenerate cheaply does not need a barrier, and a fact that exists nowhere else does. Then measure, so the decision has a price attached. Write one loop of two thousand small records and run it under three policies — never fsync, fsync every hundredth record, fsync every record — and print records per second for each, next to how many records a power cut would take. Predict the ordering first. Finally, write the middle policy properly: for the telemetry case, an fsync on a timer rather than a counter, so the bound you promise is "at most two seconds of data" instead of "at most N records", whatever the arrival rate turns out to be. One hint and no more: the honest way to describe any policy is a sentence of the form "a power cut costs us at most X". If you cannot fill in X, you do not have a policy, you have a habit.
show the solution
# when_not_to_fsync.py -- the policy is a decision about what losing the tail costs
#
# (a) the derived search index -- NO fsync. A power cut costs a 40-second rebuild
#     from data that is already durable. Paying 0.3 ms per record to protect a
#     copy you can regenerate is buying insurance on a photocopy.
# (b) the telemetry stream -- fsync on a TIMER, not per record. The dashboard
#     tolerates a two-second hole; 50,000 barriers a second is not survivable.
# (c) the row that says a customer paid -- fsync, every time, before you answer
#     "paid". There is no rebuild. The only copy of that fact is the one you keep.
import os, time
from pathlib import Path

P = Path("policy.log")
N = 2000

def run(policy, every=0):
    P.unlink(missing_ok=True)
    with open(P, "wb") as f:
        start = time.perf_counter()
        for i in range(N):
            f.write(b"cpu=0.41 mem=0.68\n")
            if policy == "per-record":
                f.flush()
                os.fsync(f.fileno())
            elif policy == "every-100" and i % 100 == 99:
                f.flush()
                os.fsync(f.fileno())
        f.flush()
        if policy != "no-fsync":
            os.fsync(f.fileno())
        return time.perf_counter() - start

PLAN = [("no-fsync",   0,   "the whole tail, up to 5 seconds of it"),
        ("every-100",  100, "at most 99 records"),
        ("per-record", 0,   "nothing at all")]

for policy, every, risk in PLAN:
    secs = run(policy, every)
    print(f"{policy:11} {N / secs:10,.0f} records/sec   power cut costs: {risk}")

# ------------- what it prints -------------
no-fsync     5,885,815 records/sec   power cut costs: the whole tail, up to 5 seconds of it
every-100      275,843 records/sec   power cut costs: at most 99 records
per-record       3,430 records/sec   power cut costs: nothing at all
#
# One laptop, one NVMe SSD, one run - your digits will differ and the shape will not.
# Between the top row and the bottom row sits a factor of about 1,700, bought with
# nothing but barriers. That is the whole negotiation: throughput on one side,
# the width of the crash window on the other, and no policy that wins both.
FSYNC PER RECORD — one barrier eachr1r2r3r4r5= fsync5 barriers — long wall-clock, ~90 ops/sWAL / GROUP COMMIT — one barrier totalr1r2r3r4r51 fsync1 barrier — fast, ~9000 ops/sdata file (lags the log)replayrecovery = replay the log
Fig — The fsync barrier, not the write, is the throughput cost: batching many records behind a single write-ahead-log fsync trades one durable append for thousands of ops per second, and recovery just replays the log.
Interactivedrag the batch size — watch durable throughput explode (and the crash window grow)
one fsync costs ~8 ms — whether it persists 1 record or 10,000.how many do you dare batch behind it? batch: 1,000 records / fsync DURABLE WRITES / SEC ≈ 100,000 /s RECORDS AT RISK ON A POWER CUT up to 1,000 records SPEEDUP OVER ONE fsync PER RECORD 800× same disk · same durability · just fewer barriers ≈ a mail-truck load behind one trip — this is where real databases live Durable throughput isn't set by disk bandwidth — it's how many records you batch behind one barrier.
1000
Drag from 1 (the honest, slow save — an fsync per record) up to 10,000. The throughput bar rockets because every fsync costs the same fixed ~8 ms barrier, so amortizing it over more records is nearly free speed. But read the right-hand number too: this simple model buffers the whole batch before one fsync, so a power cut there forfeits up to the full batch. Real databases keep the throughput and shrink the loss — they fsync a group of concurrent commits then acknowledge them together, so each transaction pays a few ms of extra latency instead, and acknowledged data is never lost. Latency up a hair, throughput up 800×, durability uncompromised.
Fig — The fsync barrier is a per-call cost, not a per-byte one — so durable throughput is governed by how many records ride behind each barrier. Batching turns ~125 saves/sec into ~100,000/sec on identical hardware; the price you negotiate is the width of the crash window.
Wait — if appending to a log is so much faster than updating data in place, why keep the data files at all? Because a log only grows, and answering "what is the current value of key X?" would mean scanning the entire history every time. The data files (and in-memory indexes) are the materialized current state — fast to read — while the log is the durable record of changes. Fast durable writes and fast reads are different jobs, so real engines keep both and periodically checkpoint the log into the data files, then truncate it. Two structures, two purposes — the same read-versus-write split you'll meet again in every serious system.
↺ reframe
Stop thinking of "saving a file" as a single act. It's a descent through three volatile buffers, and "saved" is only true once fsync has forced your bytes past the last one onto the physical medium. write() reaches the kernel; flush() also reaches the kernel; only fsync() reaches the disk. Wrap it in write-temp-then-rename so no crash exposes a half-file, and batch the fsyncs with a write-ahead log so durability stays affordable. That's not database esoterica — it's the price of making any byte survive a power cut, and now you can name exactly what it costs and exactly what a crash on any line would take.

You've flattened objects to bytes and forced those bytes durably onto disk. Next: what happens when the bytes have to cross to another process — where even the object's shape has to travel. Serialization and the pickle pipe. →

SAY IT BACKthe chapter in five breaths
  1. A returned write() is a count of bytes copied, not bytes kept — the kernel took them into volatile RAM, flipped the page dirty, and handed you a number, and the rest of the chapter is machinery for closing the gap between those two claims.
  2. Between your string and the platter sit three volatile buffers, not one — Python's BufferedWriter in your own heap, the kernel's page cache, the drive's own write cache — and f.flush() empties exactly the first of them into the second.
  3. os.fsync(f.fileno()) is the only durability primitive there is: it blocks until the device confirms, it has to come after the flush because it can only push what the kernel already holds, and close() will never call it on your behalf.
  4. A process crash and a power cut are two different faults with two different victims — after flush() you survive the first and still lose to the second — so a durability claim is nothing more than the name of the deepest call you actually made.
  5. The barrier is charged per call, not per byte, which is why the atomic save fsyncs a temp file before swinging one directory name across, and why a write-ahead log appends sequentially, pays one fsync for the whole batch, and replays itself after a crash.
You already owned the pieces: chapter 15 gave you the descriptor that f.fileno() hands to fsync, the with block whose exit flushes (and, you now know, never syncs), and the volatile / non-volatile split this whole chapter lives inside; chapter 13 gave you the try / except BaseException that lets the atomic save unlink its temp and re-raise instead of littering; chapter 7 gave you the dict that replay() rebuilds an entire database into, one logged line at a time. Chapter 16 added barely any syntax — two function calls and a rename. What it added was a question you can now answer about any line you will ever write: if the power cut here, what survives?
PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 16, in working code

A write() that returns hasn't reached the disk — it has only reached the next buffer. These twelve programs trace a byte through Python's userspace buffer, the kernel's page cache, and the durable medium, so you can see exactly which crash loses which data, and why flush, fsync, and atomic-replace are the tools that make durability real.

The Python buffer — write doesn't mean saved
open() hands you a buffered writer. write() copies your bytes into Python's own memory; they reach the kernel only when the buffer drains — on flush(), on close, or when it fills.
The I/O tower — who is holding the bytes
That single file object is really three stacked layers: text encoding on top, byte-batching in the middle, the raw descriptor at the bottom. flush() means 'drain the middle layer's held bytes with one syscall.'
Past the kernel — fsync and the page cache
Once flushed, bytes sit in the kernel's page cache — in RAM, not on the platter. os.fsync forces them down to the durable medium; fdatasync does the same but skips the metadata barrier.
Surviving a crash — the two-window model
Two crashes, two windows: a process crash loses only Python's buffer; a power cut loses the page cache too. Durability means fsync before you acknowledge, and atomic os.replace so a reader never sees a half-written file.
end of chapter 16 · six sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked