Deep Engineering
Intermediate·Published·25 MIN

Page cache and fsync: why "written" and "will not be lost" are different claims

`write` returns before the data reaches the disk: it reaches the kernel's cache. Everything else follows from that — why unflushed data survives a process crash but may be lost when the power goes, why a flush costs tens of times more than a write, and why databases commit transactions in batches.

Full technical treatment

TL;DR

write carries the data as far as the kernel's cache and returns — that is where its job ends: "A successful return from write() does not make any guarantee that data has been committed to disk". And that cache is the kernel's memory, not the process's: the data in it is already shared, but it is not durable yet. Durability is asked for separately, by a flush.

Hence the main consequence: the two failures are different. Measured: a process wrote without flushing and was killed by SIGKILL — the data is there and another process can read it, so a process crash loses nothing. When the power goes, though, data that has not become durable yet may be lost, along with the memory the cache lived in. Durability is priced accordingly: measured on this machine, a 4 KiB write — 1.8 us, the same write with a flush — 164.7 us, a ratio of 90.1. Your numbers will differ; the two-order-of-magnitude gap will not.

Beyond that is what separates knowing from having read. fsync and fdatasync are indistinguishable here, and that is a result too: a gap of 0.9 us against a run-to-run spread of 51.1 us, and the block's last line, difference resolvable on this machine, says no — a ratio smaller than the noise may not be claimed. One flush per hundred records is far cheaper than a hundred flushes: measured, 17.2 ms against 1.0 ms, which is exactly why databases commit transactions in groups. fsync on a file does not make its name durable: creating a file changes the directory, and a directory is flushed separately — "For that an explicit fsync() on a file descriptor for the directory is also needed". And closing a file flushes nothing: "Typically, filesystems do not flush buffers when a file is closed" — the habit of "closed, therefore saved" rests on nothing.

Where to start
Before this lesson it is enough to understand
  • a program writes data into a file and gets back either success or an error;
  • a machine has RAM, which disappears when the power goes, and a storage device, which does not;
  • a process can die while the machine keeps running — and the other way round.
You do not need to know in advance
  • what the page cache is, what the kernel's writeback does, and how the kernel's cache differs from a buffer inside the program itself;
  • fsync, fdatasync, O_DIRECT, group commit, flushing a directory.

What is actually being asked

The ladder usually runs like this:

  1. "What does write do?" — the warm-up, where it becomes clear whether the candidate believes the data went to the disk.
  2. "Is the data lost if the process crashes right after write?" — where the substance begins.
  3. "And if the machine goes down?" — the same question with a different answer, and the difference explains the whole mechanism.
  4. "How does fsync differ from fdatasync?" — whether you know they differ over metadata.
  5. "Why does a database not fsync on every write?" — a question about cost.
  6. "Is fsync on the file enough for the file to exist?" — the question where a file's content and its name come apart.

The numbers come from running bench/durability/fsync.py and bench/durability/practice.py. Timings depend heavily on the disk: yours will differ. The observations do not depend on the machine: what survives a process crash, and what each call promises.

Base: where the data is once the write returned success

Before talking about caches and flushes it is worth naming, in ordinary words, the path the data travels. It has four stretches:

  1. the application called write — it handed the kernel a chunk of memory and said which file to put it in;
  2. the kernel's memory — the data is accepted and sits in an area the kernel owns; the call returns at this point;
  3. the filesystem — it decides where on the device those bytes will land and which bookkeeping records have to change with them;
  4. the storage device — the place where bytes stay when there is no power.

The key question is which stretch the write call ends on. It ends on the second, not the fourth: the application gets success when the kernel took the data, not when the disk did. From there the kernel carries it onward — by itself, at its own pace — and the application neither waits for that nor hears about it.

And here is the question this lesson is about: the write returned success — where is the data really? Everything else follows from the answer: what happens to it if the process dies right now, what happens if the power goes, and what it costs to demand that the data already be on the device.

The short answer: the data is in the kernel's memory. That memory is shared — it does not belong to the process that wrote it and outlives that process's death. But it is also the memory that goes away with the power, so "the data was accepted" and "the data is durable" are two different statements, and the second one has to be asked for by a separate call.

That is already enough to answer the basic interview question. Everything below is about what that separate call costs, how its two forms differ, and why databases do not demand an acknowledgement per write.

Mechanism 1: write carries data to the cache, not to the disk

language contractGuarantees written down in write(2), fsync(2) and close(2). Much depends on the filesystem, but not these three statements.

Start with what write(2) says about itself:

A successful return from write() does not make any guarantee that data has been committed to disk. On some filesystems, including NFS, it does not even guarantee that space has successfully been reserved for the data. In this case, some errors might be delayed until a future write(2), fsync(2), or even close(2).

write(2)

Note the second half: an error may arrive later, and it may arrive in close. That is the first practical consequence: code that ignores the result of close loses write errors silently.

What write actually does is move the data into the page cache, the cache of file pages living in the kernel's memory. From there the kernel's writeback will put them on the disk at some point; when exactly, the application neither knows nor decides.

And since the cache is the kernel's memory, "closed the file, therefore saved it" rests on nothing either, which close(2) states separately:

A successful close does not guarantee that the data has been successfully saved to disk, as the kernel uses the buffer cache to defer writes. Typically, filesystems do not flush buffers when a file is closed.

close(2)

Mechanism 2: a process crash and a power loss are different events

The most useful question in the topic: if the data is not on the disk yet, what loses it? Check it the crudest way available. A process writes to a file, never calls fsync — and receives SIGKILL, dying without a single line of cleanup:

2. WHOSE MEMORY THE PAGE CACHE IS
---------------------------------
  child wrote without fsync, then SIGKILL      exit -9
  file content after the kill                  survived the kill
measured observationbench/durability/fsync.py, Linux 6.18.44. The observation does not depend on the machine: the cache belongs to the kernel, not to the process.

exit -9 is how subprocess reports a death by signal: the negative number is the signal number itself. A shell in the same place would print 137, that is 128 + 9 (the lesson on signals).

The data is there. What is more, another process sees it: bench/durability/practice.py prints the file twice — once read back after the writer died, once from an unrelated process — and both lines are the same, because reads go through the same cache.

Hence the distinction worth answering with:

  • A process crash loses nothing that made it into write. The cache outlives a crash, a kill and the OOM killer alike.
  • A power loss is another matter: data that has not become durable yet may be lost there, because the memory the cache lived in goes off with the machine.

Note the "may be". There is no guarantee that unflushed data survives; there is no guarantee that it is certainly gone either — the kernel's writeback may have carried some of those pages to the device already, and the application has no idea which ones. Nothing can be planned on that: from the application's point of view, everything unflushed is data whose fate after a power cut is simply unknown.

That is why "we are fine, the application just restarted" is a legitimate sentence and "we are fine, the machine just rebooted" is not.

Mechanism 3: what fsync promises and what it costs

The promise is written in fsync(2):

fsync() transfers ("flushes") all modified in-core data of (i.e., modified buffer cache pages for) the file referred to by the file descriptor fd to the disk device.

fsync(2)

The price of that promise is the lesson's main measurement:

1. THE PRICE OF DURABILITY, PER WRITE
-------------------------------------
  write only (4 KiB), median                   1.4 us
  write + fdatasync, median                    142.9 us
  write + fsync, median                        143.9 us
  fsync over plain write                       104x
  gap between fsync and fdatasync              0.9 us
  run-to-run spread of the same mode           51.1 us
  difference resolvable on this machine        no
measured observationbench/durability/fsync.py, Linux 6.18.44, the container's virtual disk. Your microseconds will differ; what carries meaning is the ratio and its order of magnitude.

This block comes from the bench/durability/fsync.py run. The exercises below quote a second run, bench/durability/practice.py, where the same pair came out at 1.8 and 164.7 us and the ratio at 90.1. Two runs on one machine diverge exactly as the run-to-run spread line promises: what carries meaning is the order of the ratio, not its digits.

Two things in that block matter in different ways.

The ratio carries meaning. A hundredfold: that is what separates "the data is in kernel memory" from "the data is on the device". It is not a Linux quirk or an oversight but physics: the second case has to wait for a real device to acknowledge the write.

The gap between fsync and fdatasync is not resolvable here, and the script says so itself. A gap of 0.9 us against a spread of 51.1 us between repeats of the same mode — the script measures each mode five times over, which is the run-to-run spread of the same mode line. No ratio may be claimed from that, and the block's last line, difference resolvable on this machine, says no. The difference between the calls is real and documented:

fdatasync() is similar to fsync(), but does not flush modified metadata unless that metadata is needed in order to allow a subsequent data retrieval to be correctly handled.

fsync(2)

So fdatasync saves the metadata write — and the saving shows up where metadata is expensive: when a file grows, on filesystems that journal metadata, on slow devices. On this container's virtual disk it drowned in the spread, and the honest answer here is "not measured", not "the same".

Mechanism 4: why a database does not fsync on every write

The ratio from the previous block is the price of one promise. What follows is arithmetic: how many times to demand it.

3. ONE FSYNC FOR MANY RECORDS, OR ONE EACH
------------------------------------------
  100 records, fsync after each                17.2 ms
  100 records, one fsync at the end            1.0 ms
  ratio                                        17x
measured observationbench/durability/fsync.py. The same hundred records on the same disk; the only difference is how many times durability was demanded.

Same bytes, same disk, and after the last line the data is equally durable in both cases. What differs is the number of points at which acknowledgement was demanded.

Hence group commit: transactions arriving at almost the same time wait for a shared flush. Each waits longer than it would alone, but one latency serves the whole group rather than each transaction separately.

And hence the answer to "why not just fsync every request": you can, and it will be correct. The cost is that ratio multiplied by the number of requests, and it turns into a ceiling on throughput.

Deeper: a file's name does not live in the file

The last step is about the place where the durability of the content and of the name come apart. You created a file, wrote to it, called fsync, and the machine went down. Is the file guaranteed to exist?

fsync(2) answers without ambiguity:

Calling fsync() does not necessarily ensure that the entry in the directory containing the file has also reached disk. For that an explicit fsync() on a file descriptor for the directory is also needed.

fsync(2)
4. WHAT FSYNC IS FOR: THE FILE, NOT THE DIRECTORY
-------------------------------------------------
  file synced                                  yes
  directory sync cost                          0.11 ms
measured observationbench/durability/fsync.py. Flushing the directory is a separate call with a separate cost, small here because the directory is small.

The measurement here does not answer whether the directory needs its own flush — the man page does — but what it costs.

A file's content and the fact that it exists under that name are two different objects, flushed separately. Hence the sequence for an atomic file replacement, which is what gets asked: write to a temporary file, flush it, rename it over the target, flush the directory. Skipping the last step gives you code that works right up to the first power cut, and then loses precisely what it thought it had written.

How to answer in an interview

Short answer: write carries data only as far as the kernel's cache and returns; durability is promised by fsync, and it costs two orders of magnitude more. That is why a process crash loses nothing written — the cache belongs to the kernel — while a power loss may lose everything unflushed.

That is enough for a correct answer. What follows is what you add when the interviewer digs.

If the interviewer digs deeper

Three things separate a good answer. First, you distinguish the two failures, a dying process and a power cut, and say why the answers differ. Second, you give the cost as a number and know what follows from it: group commit exists not for elegance but because flushing on every write runs into the device. Third, you remember the directory: fsync on a file makes the content durable, not the name, and an atomic replacement has to flush both.

And one piece of care in phrasing that gets noticed. "A machine crash loses the data" is too strong a claim; the correct form is that data which has not become durable yet may be lost when the power goes. The kernel's writeback may have carried part of it to the device already, and which part is unknown to the application. The practical conclusion does not change, but the statement becomes true: unflushed data cannot be counted on in either direction.

Next they ask

Next they ask

If fsync is that expensive, can one simply rely on the cache?

Short answer

You can — right up to the first power cut. Everything unflushed lives in the kernel's memory, so in that sense "the data is written" means "the data is written for as long as the machine stays up".

So the practical choice is not "flush or not" but where to put the line: which data must survive a power cut and which may be lost. A transaction log must; a cache of rendered images need not. That line is what decides where fsync belongs in the code.

Next they ask

Will a write error arrive in write?

Short answer

Not necessarily. write(2) warns outright that some errors are deferred to a later write, to fsync, or even to close. The reason is the same: during write nothing reached the device, so nothing is known about the device yet.

Hence the rule most often broken in code: the result of close must be checked, as must the result of fsync. Code that closes a file without checking loses precisely the errors the whole exercise was about.

Next they ask

Does fsync help if the disk lies about completing a write?

Short answer

No, and that is the boundary of the promise. fsync pushes data to the device and waits for its acknowledgement; if the device acknowledges before the data is in non-volatile memory, the guarantee breaks at a level the kernel cannot reach.

Practically this means durability is a property of the whole chain: application, kernel, filesystem, controller, disk. It is verified not by reasoning but by a sudden power-loss test — which is why such tests are part of accepting a storage system.

Next they ask

What does O_DIRECT give you?

Short answer

Not durability but a bypass of the cache: data goes past the page cache and the application takes over buffering and alignment. Durability does not follow — the device may still hold the write in its own cache, which is why databases combine direct I/O with flushing.

The point of O_DIRECT is different: when an application already keeps its own cache (and a database does), the kernel's cache duplicates it and spends the memory twice.

Common misconceptions

Claim

if write returned successfully, the data is on the disk

Actually

It is not: "A successful return from write() does not make any guarantee that data has been committed to disk". The data reached the kernel's cache, and when it reaches the device is not the application's decision. Only fsync promises that — and it costs two orders of magnitude more: measured 1.8 us against 164.7.

Claim

closing the file means saving it

Actually

close(2) answers directly: "Typically, filesystems do not flush buffers when a file is closed". What is more, an error from an earlier write may surface in close itself — which is why its result must be checked.

Claim

if the process crashes before fsync, the data is lost

Actually

It is not. Measured: a process wrote without flushing and was killed by SIGKILL — the content is there and visible to another process. The cache belongs to the kernel, not to the process. When the power goes, though, unflushed data may be lost along with the memory the cache lived in — and that is the whole difference between the two failures.

Claim

fdatasync is noticeably faster than fsync

Actually

On this machine the difference is not resolvable: a gap of 0.9 us against a run-to-run spread of 51.1 us, and the script prints no in its difference resolvable on this machine line. The difference between the calls is real and documented — fdatasync skips metadata that reads do not need — but the saving appears where metadata is expensive, not everywhere.

Claim

fsync on every write is correct, just slow

Actually

It is correct and it costs a measurable amount: a hundred records with a flush after each — 17.2 ms, the same hundred with one flush at the end — 1.0 ms, seventeen times apart. After the last line the durability is identical. What differs is the number of acknowledgements demanded, and group commit in databases is built on exactly that.

Claim

fsync on a file guarantees the file exists

Actually

It guarantees its content is durable. The name lives in the directory, which is a separate object: "For that an explicit fsync() on a file descriptor for the directory is also needed". Hence the mandatory last step of an atomic replacement — flushing the directory.

Claim

O_DIRECT makes a write durable

Actually

O_DIRECT bypasses the kernel's cache but not the device's: the acknowledgement may come before the data is in non-volatile memory. That is why databases combine direct I/O with flushing rather than substituting one for the other.

Practice

Two exercises. Answer first, then check against the real output: in both, the correct answer comes from a script's committed output rather than being written by hand.

Practice · predict the output

A process opened a file, wrote a string into it, never called fsync — and was killed by SIGKILL. Two things are printed: what the file holds after its death, and what a completely different process sees in that file. What does this code print?
print(content_after_kill(path) or "(empty)")
print(seen_by_another_process(path) or "(empty)")

Practice · estimate

How many times more expensive is a 4 KiB write with a flush to disk than the same write without one?
times

Knowledge check

Question 1 of 6

A process wrote data to a file and was killed by SIGKILL before calling fsync. What happened to the data?

Sources & further reading

3 SOURCES

  1. fsync(2), Linux man-pages 6.7Official documentation. What the flush promises: "fsync() transfers ('flushes') all modified in-core data of (i.e., modified buffer cache pages for) the file referred to by the file descriptor fd to the disk device". And what it does NOT promise, which is half the lesson: "Calling fsync() does not necessarily ensure that the entry in the directory containing the file has also reached disk. For that an explicit fsync() on a file descriptor for the directory is also needed". The difference from fdatasync: "fdatasync() is similar to fsync(), but does not flush modified metadata unless that metadata is needed in order to allow a subsequent data retrieval to be correctly handled".https://man7.org/linux/man-pages/man2/fsync.2.html
  2. write(2), Linux man-pages 6.7Official documentation. Why a return from write says nothing about the disk: "A successful return from write() does not make any guarantee that data has been committed to disk. On some filesystems, including NFS, it does not even guarantee that space has successfully been reserved for the data. In this case, some errors might be delayed until a future write(2), fsync(2), or even close(2)".https://man7.org/linux/man-pages/man2/write.2.html
  3. close(2), Linux man-pages 6.7Official documentation. Against the habit of "closed, therefore saved": "A successful close does not guarantee that the data has been successfully saved to disk, as the kernel uses the buffer cache to defer writes. Typically, filesystems do not flush buffers when a file is closed".https://man7.org/linux/man-pages/man2/close.2.html