Ch. 21 · Operating Systems

Operating Systems: Journaling and Durability

Why a crash mid-update corrupts a filesystem, how write-ahead journaling and replay fix it, ext4's modes, and what journaling won't do for you.

~7 min readadvancedupdated Oct 19, 2026

Appending one block to a file sounds like one write. To the filesystem it’s at least three: the data block itself, the inode (new size, new block pointer), and the free-space bitmap (that block is now in use). Disks write those one at a time, and power can fail between any two. If the inode is updated but the bitmap isn’t, the filesystem may later hand the same block to another file. If the bitmap is updated but the inode isn’t, the block is lost forever. If the inode points to the block before the data lands, the file contains whatever garbage was there before.

This is the crash-consistency problem, and journaling is the standard answer. Interviewers ask about it to see whether you understand write-ahead logging as a general technique, since the same idea runs databases, message queues and replicated logs, and whether you know the gap between “the filesystem is consistent” and “my data is safe”.

The old answer: check everything after a crash

Before journaling, filesystems such as ext2 left consistency to fsck. After an unclean shutdown, fsck walked every inode, every directory and the bitmaps, cross-checking them and fixing contradictions. It worked, but it couldn’t know what you meant to write, only make the structures agree. And it scaled with disk size: on a multi-terabyte volume, a full check could take hours before the machine was usable. That startup delay, more than anything, is why journaling took over.

Write-ahead journaling in four steps

A journal is a reserved region of the disk where the filesystem records what it’s about to do before doing it. The OSTEP chapter on journaling describes the protocol in phases, and the essential version is:

  1. Journal write. Write a transaction to the journal: a begin marker, the new contents of every block the update will change, and nothing else yet.
  2. Journal commit. Once those writes are on disk, write a commit record. A transaction is real only after its commit record is durable. The ordering between steps 1 and 2 is enforced with a cache flush or a write with forced unit access, because drives reorder writes otherwise.
  3. Checkpoint. Write the blocks to their real locations in the filesystem.
  4. Free. Mark the transaction’s journal space as reusable.

After a crash, recovery reads the journal. Committed transactions are replayed by writing their blocks to their final locations again. Uncommitted ones are ignored. Replay is idempotent, since writing the same block contents twice gives the same result, so a crash during recovery is also fine. Recovery time depends on the journal’s size, not the disk’s: seconds instead of hours.

Modern journals add checksums to transactions so that a commit record can be written together with the transaction body, and a torn transaction is detected by a checksum mismatch rather than by strict ordering. ext4 supports this as journal checksumming.

A model you can crash at every step

To see why the protocol works, I wrote a small Python model of a “disk” with two account balances stored in fixed-width slots and a journal region. The update is a transfer of 30 from Alice (100) to Bob (50). The script crashes after every possible step, and also simulates a torn final write where only the first 3 bytes reach the disk. Without a journal, the update is two in-place writes. With one, it’s: write a checksummed intent record, write a commit marker, update both slots, clear the journal. Recovery replays the journal record only if its checksum matches and the commit marker is present:

if journal.endswith(b'C') and len(journal) > 9:
    crc, body = journal[:8], journal[8:-1]
    if f"{zlib.crc32(body):08x}".encode() == crc:
        for k, v in json.loads(body).items(): data[k] = slot_bytes(v)
python

Counting every crash point, these are the states the disk could be left in after recovery:

in place     alice=70 bob=50 total=120 x2, alice=70 bob=80 total=150 x1, alice=100 bob=50 total=150 x2
with journal alice=70 bob=80 total=150 x8, alice=100 bob=50 total=150 x3
Text

Updating in place, two of the crash points lose 30 from the system: Alice was debited and Bob was never credited. With the journal, every crash ends in either the old state or the new state, never in between. A crash before the commit marker leaves the old values; a crash after it is repaired by replay, including crashes in the middle of updating the slots. This is a model of the protocol, not a test of a real filesystem, but it shows exactly the guarantee a journal provides: atomicity of a multi-block update.

What ext4 actually journals

Writing every data block twice (once to the journal, once to its home) halves write bandwidth for large files, so most filesystems don’t do it by default. ext4 has three modes, set with the data= mount option:

  • data=journal journals data and metadata. Strongest guarantees, highest write cost.
  • data=ordered is the default. Only metadata goes through the journal, but ext4 writes a file’s data blocks to their final location before committing the metadata that points to them. After a crash, a file never points at blocks containing stale data from some other file.
  • data=writeback journals metadata with no ordering for data. The structure stays consistent, but a file recently extended can contain old garbage after a crash.

XFS journals metadata only. Copy-on-write filesystems take a different route: APFS on macOS, ZFS and btrfs never overwrite live blocks in place. They write new versions elsewhere and switch a root pointer atomically, which gives the same all-or-nothing property without a separate journal. HFS+, APFS’s predecessor, used a journal. I ran the model above on macOS with APFS underneath; nothing in it depends on the filesystem, and I didn’t test ext4 mount modes, which are Linux-only.

What journaling doesn’t promise

Journaling keeps the filesystem’s structures consistent: directories, inodes, allocation maps. It doesn’t make your application’s data durable or atomic. Three gaps matter in practice.

Unflushed writes are still lost. write() copies data into the page cache. If the machine crashes before the kernel writes it out (seconds later, typically), the data never reached the disk at all, journal or not. Durability needs fsync() (or fdatasync()), and on macOS fcntl(F_FULLFSYNC) to force the drive’s own cache to flush.

Your multi-step update isn’t a filesystem transaction. Rewriting a config file in place with open(path, 'w') truncates it first. A crash after the truncate leaves an empty file, and the filesystem is perfectly consistent about it. The fix is the write-new-then-rename pattern: write a temporary file, fsync it, rename() it over the old one, then fsync the directory.

Applications need their own log. Databases journal their own pages with a write-ahead log (PostgreSQL’s WAL, SQLite’s rollback journal or WAL mode) and fsync it at commit, exactly because the filesystem journal doesn’t know where one of their transactions begins or ends. The model above is that technique at application level.

Gotcha

Mounting with data=writeback, disabling barriers, or running on a drive that ignores flush requests can break the ordering journaling depends on. The filesystem then can’t keep its promise after a power cut, however good the journal format is.

Interview practice: the empty config file

After a power cut, a service fails to start because its JSON config file is zero bytes long. The server uses ext4 with journaling enabled, and the team asks how the file could be empty “if the filesystem has a journal”. What do you tell them, and what change would you make?

Answer

The journal did its job: the filesystem’s metadata is consistent. It’s the application’s update that wasn’t atomic. The service most likely rewrote the config in place, which truncates the file to zero length and then writes the new contents. The truncate is a metadata change that ext4 committed through its journal, but the new data was still in the page cache when the power failed, so after recovery the filesystem correctly shows a file of length zero. In ordered mode ext4 protects against stale data appearing, not against a half-finished application update. The fix is to write the new contents to a temporary file in the same directory, fsync it, rename() it over the original, and fsync the directory so the rename itself is durable. A crash at any point then leaves either the complete old file or the complete new one. For data that changes often, a small embedded database such as SQLite, which keeps its own journal, is often simpler than hand-rolled files.

More in Operating Systems

read ✓Operating Systems · hard

Filesystem Writes, Buffers and Durability

What write, fsync and F_FULLFSYNC really promise, measured on an M1 (2 µs, 25 µs, 3.8 ms), plus atomic file replacement and group commit.

~6 min readread →
read ✓Operating Systems · hard

Operating Systems: CPU Cache Locality

Measured on an M1: column-order matrix walks up to 30x slower, shuffled lists at 97 ns a node, and false sharing slowing two counters 4x.

~6 min readread →
read ✓Operating Systems · hard

Copy-on-Write Memory and fork

How fork shares pages until a write, measured on macOS: 16,517 pages copied on write, few on read, and Python copying pages just by reading.

~6 min readread →
esc