Durability and fsync

What "durable" means

An operation is durable when its effects survive a crash of the process, the kernel, or the power supply. For a storage engine, the contract is precise: once Put() returns OK to the caller (with sync enabled), the value must be readable after any crash. Everything on this page is about what it takes to keep that promise, because the operating system's defaults do not.

Three kinds of crash, three levels of safety

Event What is lost Survives if data reached...
Process crash (kill -9, segfault) Process memory, user-space buffers The kernel (any successful write())
Kernel crash / panic Page cache, dirty pages The device
Power loss Device volatile write cache Stable media (flash/platter)
graph LR ub["User-space buffer<br/>(bufio.Writer, []byte)"] -->|"write()"| pc["Kernel page cache<br/>(dirty page)"] pc -->|"fsync() / writeback"| dc["Device write cache<br/>(volatile DRAM)"] dc -->|"cache flush / FUA<br/>(issued by fsync)"| media["Stable media"]

A process crash loses only the leftmost box: a successful write() already survives kill -9. Power loss can lose everything left of "stable media." Testing with kill -9 therefore proves very little about durability — see Testing and Benchmarking a Storage Engine.

The system calls

Call Guarantees
write(fd, ...) Data copied into the page cache. Nothing about disk.
fsync(fd) File data and all metadata (size, mtime, block map) on stable storage; flushes the device cache.
fdatasync(fd) File data plus the metadata needed to read it back (e.g. a size change), but skips non-essential metadata like mtime. Often one fewer I/O than fsync.
open(..., O_DSYNC) Every write() behaves like write() + fdatasync().
sync_file_range(...) (Linux) Starts writeback of a range; no durability guarantee (does not flush metadata or the device cache). Useful only to smooth out I/O.

For an append-only log, the standard choice is write() + fdatasync(). Preallocating the log file (fallocate) and then overwriting it makes fdatasync cheaper still, because the file size no longer changes on each append.

macOS caveat: fsync() on macOS does not flush the drive's write cache. Real durability needs fcntl(fd, F_FULLFSYNC), which is much slower. Go's (*os.File).Sync already uses F_FULLFSYNC on macOS — measured at 4.4 ms per call against 32 µs for a raw fsync(2) in Files and Durability in Go; LevelDB does the same, and SQLite exposes it as PRAGMA fullfsync.

Rule 1: a new file needs its directory synced

A file's name is an entry in its parent directory, and the directory is a separate file with its own data blocks. fsync on the new file makes the file's contents durable, but not necessarily the directory entry that points to it.

f, err := os.OpenFile("/db/000123.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
    return err
}
if _, err := f.Write(record); err != nil {
    return err
}
if err := f.Sync(); err != nil { // the file's contents are durable
    return err
}
// Crash here: on some file systems, after reboot /db/000123.log may not
// exist, even though its data blocks were written.

d, err := os.Open("/db")
if err != nil {
    return err
}
defer d.Close()
return d.Sync() // now the name is durable too

Every time an engine creates, renames, or deletes a file it depends on after a crash — a new WAL segment, a new SSTable, a new manifest — it must fsync the containing directory.

Rule 2: atomic replace = write temp, fsync, rename, fsync dir

rename() is atomic on POSIX file systems: after a crash, the target name refers either to the old file or the new one, never a mix. That makes it the standard way to update a small file such as LevelDB's CURRENT (which names the active manifest):

func setCurrent(dir, manifest string) error {
    tmp := filepath.Join(dir, "CURRENT.tmp")
    if err := writeFileSync(tmp, []byte(manifest+"\n")); err != nil { // write + fsync tmp
        return err
    }
    if err := os.Rename(tmp, filepath.Join(dir, "CURRENT")); err != nil { // atomic swap
        return err
    }
    return syncDir(dir) // make the rename durable
}

This is the function the Go storage engine uses; a general WriteFileAtomic with every error path is in Files and Durability in Go.

Skip any step and a crash can break it:

Step skipped Possible result after crash
fsync of tmp before rename CURRENT exists but is empty or partially written — the rename was persisted before the data (this happened in practice with ext4 delayed allocation)
fsync of directory after rename CURRENT still points to the old manifest — acceptable only if the old manifest is still valid
Use rename, but write directly into CURRENT A torn file: neither old nor new contents

Rule 3: a failed fsync cannot be retried ("fsyncgate")

In 2018, PostgreSQL developers discovered that on Linux, if fsync returns an error (EIO, for example from a failing disk or a full thin-provisioned volume), the kernel may mark the dirty pages clean and drop the error. A second fsync then returns success — but the data was never written. PostgreSQL had been retrying failed fsyncs, believing the retry made the data durable.

The correct response to an fsync failure in a storage engine is to treat the database as unable to guarantee durability: stop accepting writes, surface the error, and recover from the WAL on restart. PostgreSQL now PANICs on fsync failure. LevelDB and RocksDB put the DB into a sticky background-error state where all later writes fail.

if err := db.log.Sync(); err != nil {
    // Do NOT retry and continue. The kernel may already have discarded the data.
    db.setBackgroundError(err) // every subsequent write now returns this error
    return err
}

Rule 4: assume torn and reordered writes

Without fsync between them, writes can reach the disk in any order, and a write larger than one sector can be partially applied. After power loss, a log file may end with:

  • a complete final record,
  • a partial record (torn write),
  • garbage bytes (a size update persisted before the data),
  • a valid record after a missing one, if the writes were reordered.

The defense is the same everywhere: every record carries a length and a checksum (Binary Encoding and Checksums), and recovery stops at the first record that does not verify. The WAL page shows the record format.

The cost of durability, and group commit

fdatasync on an NVMe drive with power-loss protection can take ~20–100 µs; on a consumer SSD that honors cache flushes, often ~0.5–2 ms; on a network volume, ~1–3 ms; on an HDD, ~10 ms. A single writer that syncs every commit can therefore do only a few hundred to a few tens of thousands of commits per second.

Group commit amortizes it: concurrent writers queue up, one leader appends all their records and issues a single fdatasync, and then all of them return. 50 writers waiting for one 2 ms sync = 25,000 commits/s instead of 500. See WAL for the general idea and Write-Ahead Log for the implementation.

Many engines also offer a non-sync mode (LevelDB's WriteOptions::sync = false, the default): writes survive process crashes but may lose the last few seconds on power loss. That is a legitimate choice for some data, as long as it is made explicitly.

File system behavior matters

Crash guarantees differ between file systems and mount options. The 2014 OSDI paper All File Systems Are Not Created Equal (Pillai et al.) tested real applications — including LevelDB, SQLite, Git, and PostgreSQL — against file-system crash behavior and found bugs in almost all of them, typically from assuming an ordering or atomicity the file system did not guarantee. The safe approach is to rely only on what POSIX guarantees (rename atomicity, fsync semantics) plus checksums, and never on a particular file system's convenient behavior. ext4 (default data=ordered) and XFS are the common choices for databases on Linux.

Common pitfall

Calling bufio.Writer.Flush() and believing the data is on disk. Flush only moves data from the user-space buffer into the kernel with write() — the first arrow in the diagram. It survives a process crash but not a power loss. Durability requires (*os.File).Sync() on the underlying file after the flush, plus a directory sync if the file is new. The same confusion exists one level down: os.WriteFile and (*os.File).Close do not sync either. Know which arrow each call moves your bytes across.