On a Mac, a full fsync of 4 KB waits for everyone else's writes
macOS has three ways to say "make this file safe": fsync(), fcntl(F_BARRIERFSYNC) and fcntl(F_FULLFSYNC). On an M6 Mac mini's internal SSD they cost about 15 µs, 110 µs and 3 ms for a 4 KB write. Which one you get depends on the language: Go, Rust and Node use the 3 ms one by default, while Python and Swift use the 15 µs one. Apple's own SQLite, asked for the strongest option, quietly uses the middle one.
The surprise was the expensive one. When another process has a lot of unwritten data buffered (a download, a build, a file copy), F_FULLFSYNC on my 4 KB file waits until that data is written too: 33 ms behind a 256 MB backlog, 104 ms behind 1 GB. Next to a process writing 1.5 GB/s, a full sync took 0.8 seconds.
What each call promises
Apple's fsync(2) man page is unusually frank: fsync() moves the data to the drive, but "the drive itself may not physically write the data … for quite some time and it may be written in an out-of-order sequence", so after a power cut "only some or none of their data" may be there. F_FULLFSYNC does an fsync() and then asks the drive to flush its own cache. F_BARRIERFSYNC does an fsync() and then sends a barrier: what was synced before it reaches storage before anything after it, but the call doesn't wait for that to happen. I can't test durability (it means pulling power from the machine), so everything below is about cost and about which call each language makes.
The cost
One thread, a 4 KB pwrite at a random offset in a preallocated 16 MB file, then a sync, for 5 seconds per call type:
| Call | Median | p99 | Syncs per second |
|---|---|---|---|
fsync() | 15 µs | 22 µs | 58,000 |
F_BARRIERFSYNC | 109 µs | 535 µs | 6,900 |
F_FULLFSYNC | 2,975 µs | 3,826 µs | 355 |
A second benchmark interleaved the calls round-robin on one file, 2,000 rounds each. The ratios held, though each call got slower when it followed a full flush: medians 50–95 µs, 310 µs and 3.5 ms. So a full flush costs about 30 barriers or 200 plain fsyncs one call at a time, and about 11 barriers or 40–65 fsyncs when interleaved. (fdatasync() exists in the C library, isn't in the headers, and cost the same as fsync() or less.)
Full flushes from different threads partly share the work. With each thread syncing its own file, 2 threads got no more than 1 (350 per second), 4 got 817, and 16 got 1,172, with the median wait growing to 12 ms.
Who calls what
I wrote a small library that wraps fsync, fdatasync and fcntl, loaded it into each program with DYLD_INSERT_LIBRARIES, and counted the calls while each program wrote 4 KB and synced it 200 times with its standard API:
| API | Calls | Time per write + sync |
|---|---|---|
C fsync(), Python os.fsync | fsync | ~20 µs |
Swift FileHandle.synchronize(), Data.write(options: .atomic) | fsync | 22 µs, 139 µs |
Go (*os.File).Sync | F_FULLFSYNC | 3.0 ms |
Rust sync_all, sync_data | F_FULLFSYNC | 2.8 ms |
Node fs.fsync, fs.fdatasync, filehandle.sync(), .datasync() | F_FULLFSYNC | 2.8–3.2 ms |
Go was the one program the wrapper couldn't report on (Go exits without running the library's exit hook), so that row comes from the timing and from Go's source, where internal/poll/fd_fsync_darwin.go calls F_FULLFSYNC and falls back to fsync only if the file system doesn't support it. Python has no os.fdatasync on macOS at all. A Python program that wants the full flush has to call fcntl.fcntl(fd, fcntl.F_FULLFSYNC) itself.
So the same "sync this file" line is 150 times more expensive in Go, Rust or Node than in Python or Swift, and the Go/Rust/Node version is the one that matches what the man page says a database needs.
SQLite: Apple's build swaps in the barrier
SQLite has a fullfsync pragma for exactly this, off by default. I ran 200 single-row inserts, each its own transaction, through the SQLite that ships with Python (3.53.1, built from the public source) and through Apple's system libsqlite3 (3.54.0):
| Mode | Public SQLite | Apple's SQLite | Per insert |
|---|---|---|---|
| rollback journal, default | 4 fsync | 4 fsync | 137 / 141 µs |
rollback journal, fullfsync=1 | 3 F_FULLFSYNC + 1 fsync | 3 F_BARRIERFSYNC + 1 fsync | 9.2 ms / 0.66 ms |
WAL, synchronous=FULL, fullfsync=1 | 1 F_FULLFSYNC | 1 F_BARRIERFSYNC | 3.3 ms / 0.39 ms |
(Counts are per commit, from totals over 200 commits.) Apple's build turns fullfsync=1 into barriers: 14 times cheaper than the public build in rollback mode, and a weaker promise. It also has checkpoint_fullfsync on by default, so WAL checkpoints get a barrier even with fullfsync off. That reads like a deliberate trade: ordering is what keeps a SQLite file from being corrupted, and losing the last few commits after a power cut is, for most apps, acceptable. If your Mac app links the system SQLite and you turned on fullfsync for durability, you're getting ordering instead.
Waiting for someone else's data
To see what a full flush costs on a machine doing other things, I had a second process write into a 256 MB file of its own with plain buffered writes at a fixed rate, and measured syncs of my 4 KB file meanwhile (two passes, rates in opposite order, median of each pass):
| Neighbour writes | F_FULLFSYNC | F_BARRIERFSYNC | fsync |
|---|---|---|---|
| nothing | 2.0–3.1 ms | 0.14–0.22 ms | 14–16 µs |
| 100 MB/s | 3.0–4.0 ms | 0.16–0.34 ms | 15–16 µs |
| 400 MB/s | 4.0–10 ms | 0.35–0.38 ms | 16–18 µs |
| 1.3–1.6 GB/s | 760–810 ms | 0.7–0.9 ms | 21–27 µs |
The barrier's median stayed under a millisecond, but its p99 at the highest rate was 0.13 and 1.3 seconds in the two passes. fsync barely noticed.
To see why, I used a backlog instead of a stream. A second process writes some amount of data without syncing and keeps the file open. My process, in another directory, then times one 4 KB sync. Afterwards the second process times an F_FULLFSYNC of its own file, which shows how much of its data was still unwritten. Nine trials each, alternating the order, medians in milliseconds:
| Other process's unsynced data | My sync, by call | My full sync | Its full sync afterwards |
|---|---|---|---|
| none | fsync 0.14, barrier 1.1 | 5.1 | 3.5 |
| 64 MB | fsync 0.07, barrier 1.9 | 5.9 | 5.4 |
| 256 MB | fsync 0.04, barrier 7.5 | 33 | 6.4 (33 after my fsync) |
| 1 GB | fsync 0.04, barrier 9.1 | 104 | 6.4 (103 after my fsync) |
My full sync of 4 KB took as long as the other process's own full sync would have, and left almost nothing for it. After my plain fsync or barrier, the other process still had 103 ms of flushing to do. So on APFS, F_FULLFSYNC doesn't only empty the drive's cache: it writes out other files' unsynced data first, from any process. The man page only says it "drains the entire queue of the device", which you'd expect to cover data already sent to the drive, not data still in memory. I can't tell from outside whether APFS is committing everything it has or the cache is being flushed for some other reason; I can only see who pays.
The single-process version of the test, where the same program wrote the large file and then synced a small one, showed the same thing with more spread: 0.1 to 2 seconds behind 1 GB.
One trap I fell into: my first cross-process runs said there was no effect. My probe program did an untimed full sync on its new, empty file before it started the clock, and that call was the one that waited for the other process's data. The timed call then found nothing to wait for. Anything a benchmark does before starting its timer can absorb the cost it was built to measure.
What to do
- If you write Go, Rust or Node on a Mac, a sync per write limits you to a few hundred writes per second per thread, and a busy machine can stretch one to a second. Batch writes and sync once per batch.
- If you write Python or Swift and need data to survive a power cut, plain
fsyncdoesn't promise that on macOS; useF_FULLFSYNCwhere it matters, andF_BARRIERFSYNCwhere ordering is enough. - For a latency-sensitive service, a full sync's cost depends on what the rest of the machine is writing, not just on your data.
Versions: macOS 27 on APFS, Apple M6 Mac mini internal SSD; Go 1.27.1, Rust 1.99.0, Node 26.10.0 (libuv 1.53.0), Python 3.14.8, Swift 6.4, SQLite 3.53.1 (Python) and 3.54.0 (system). The disk was about 90% full, which may make flushes slower than on an emptier one.