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.
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.
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:
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 seekA 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.
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:
>>> 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 byNow 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:
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 kernelZero, 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.
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.io.DEFAULT_BUFFER_SIZE, which is 8192 bytes. Nothing crosses into the kernel until the bytearray fills, you flush, or you close."\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.f.write() is its own syscall, straight to the kernel. Correct for a device or a pipe you must not batch; expensive everywhere else.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 is the TextIOWrapper, f.buffer the BufferedWriter holding the bytearray, f.buffer.raw the FileIO wrapped around chapter 15's file descriptor.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 3flush()is notfsync(). It moves your data from one volatile buffer into a different volatile buffer, and stops.buffering=0in text mode raisesValueError: can't have unbuffered text I/O— the codec needs a buffer under it to work on.buffering=1in binary mode is not an error. Python warnsRuntimeWarning: line buffering (buffering=1) isn't supported in binary modeand silently uses 8192 instead.os.stat(path).st_sizereports what the kernel holds, never what Python is still holding. That is exactly why it makes such a good probe.- Closing flushes, always — the
withblock'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.
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.>>> 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')
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.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. →
# 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
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.
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.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.
fsync works at the descriptor level, so this is how a Python file object names itself to a syscall.fsync 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.mtime and friends. POSIX only, so guard it with hasattr(os, "fdatasync") if your code must also run on Windows.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- The first line of that output is the order law, felt once:
fsyncbeforeflushreached the kernel with zero bytes to sync, and reported success. os.fsync(f)does work — CPython callsfileno()for you. Writeos.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.fdatasyncsimply does not exist on Windows, so an unguarded call is anAttributeErroron someone else's machine, not a slow path.- Wrapping every write in
flush+fsyncis 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:
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-tripA 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.
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. →
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 : 40004The 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.
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:
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: durableCrash 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.
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. →
# 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.
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.
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.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
raiseWalk 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.
dir= argument is load-bearing: it is what keeps the temp on the same filesystem as the target.write, flush and the with block back. Chapter 15's tower, built on a descriptor you were handed rather than one open() made.os.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 that descriptor. POSIX only: Windows will not let you open a directory, so real code branches on os.name.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'- 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
/tmpfeels tidy and breaks everything./tmpis often a separate mount, andos.replaceacross filesystems raisesOSError: [Errno 18] Invalid cross-device link. Path.write_textandPath.write_bytesare not atomic. They areopen("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.replacecan fail withPermissionErrorif another process holds the target open. POSIX simply swings the name and lets the old reader keep reading.
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. →
# 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"]
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:
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-tripFeed 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.
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.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 stateThat 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.
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 filewith 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.f.flush() then os.fsync(f.fileno()) as the last two lines under the withTraceback (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 descriptoros.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.os.fsync(f.fileno()) inside the with os.fdopen(fd, "wb") as f: block, never os.fsync(fd) after itTraceback (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: filenoBytesIO 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.os.fsync was called and leave the syscall to the real run200 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 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. →
- 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. - Between your string and the platter sit three volatile buffers, not one — Python's
BufferedWriterin your own heap, the kernel's page cache, the drive's own write cache — andf.flush()empties exactly the first of them into the second. 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, andclose()will never call it on your behalf.- 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. - 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.
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?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.