A successful buffered write makes bytes available through the kernel’s file state, but does not by itself establish crash durability. fsync requests synchronization of a file’s data and associated metadata and waits for completion as reported by the storage stack. It is one operation in an update protocol. Correct ordering, checked errors, directory entries, and the storage system’s actual guarantees still matter.
When it breaks
User-space buffering creates an earlier boundary. Calling fsync on a descriptor cannot synchronize bytes that a language-level buffer has not yet submitted to the kernel. Flush that buffer first, and check errors from both stages. Ordinary writes can also complete partially, and delayed storage errors may surface during synchronization instead of at the original write.
Synchronizing a newly created file does not necessarily synchronize the directory entry that names it. On Linux, a durable creation or replacement protocol also needs the appropriate directory synchronization. Atomic rename provides a namespace visibility property while the system is running; it does not independently establish that the directory change survived a crash.
fdatasync does not mean no metadata. It synchronizes data and metadata needed for subsequent data retrieval, including a changed file length. It can omit other metadata updates that are not needed for that purpose. Treating it as either identical to fsync in every respect or completely indifferent to metadata obscures the reason for choosing between them.
Check it quickly
Save this as syncfile.py and run python3 syncfile.py on a disposable Linux system with Python and strace. The temporary directory must support regular-file and directory synchronization. The recorded run used Linux 6.12.76, Python 3.11.2, strace 6.1, and an ext4 volume. The example checks real calls and runtime results; it does not cut power to the storage:
from pathlib import Path
from tempfile import TemporaryDirectory
import subprocess,sys
program=r'''
from pathlib import Path
import errno,os,sys
root=Path(sys.argv[1]);current=root/'current';candidate=root/'candidate'
current.write_bytes(b'old');old=current.open('rb')
try:
with candidate.open('wb') as handle:
handle.write(b'new');handle.flush();os.fsync(handle.fileno())
os.replace(candidate,current)
directory=os.open(root,os.O_RDONLY|os.O_DIRECTORY)
try:os.fsync(directory)
finally:os.close(directory)
assert current.read_bytes()==b'new' and old.read()==b'old'
with current.open('ab') as handle:
handle.write(b'-extended');handle.flush();os.fdatasync(handle.fileno())
assert current.read_bytes()==b'new-extended' and current.stat().st_size==12
read_end,write_end=os.pipe()
try:
try:os.fsync(write_end)
except OSError as error:assert error.errno==errno.EINVAL
else:raise AssertionError('fsync on a pipe unexpectedly succeeded')
finally:os.close(read_end);os.close(write_end)
finally:old.close()
print('replacement content, old open reference, appended length, and unsupported-pipe error verified')
'''
with TemporaryDirectory(prefix='xbcatm-fsync-') as directory:
root=Path(directory);trace=root/'trace'
result=subprocess.run(['strace','-yy','-s','200','-e','trace=%file,write,fsync,fdatasync','-o',str(trace),'--',sys.executable,'-c',program,str(root)],capture_output=True,text=True,timeout=10)
assert result.returncode==0,result.stderr
lines=trace.read_text().splitlines()
file_sync=next(i for i,line in enumerate(lines) if 'fsync(' in line and str(root/'candidate') in line and '= 0' in line)
rename=next(i for i,line in enumerate(lines) if line.startswith('rename') and str(root/'current') in line and '= 0' in line)
directory_sync=next(i for i,line in enumerate(lines) if 'fsync(' in line and '<'+str(root)+'>' in line and '= 0' in line)
assert file_sync<rename<directory_sync
assert any('fdatasync(' in line and str(root/'current') in line and '= 0' in line for line in lines)
print(result.stdout,end='')
for line in lines:
if line.startswith(('fsync(','fdatasync(','rename')):print(line)
print('syscall order verified: flush data, fsync candidate, replace name, fsync parent directory')
The child creates a current file containing old and keeps an open read reference. It writes new to a separate candidate, flushes the Python buffer, and calls fsync on that candidate. Keeping the candidate in the same directory also keeps the later rename within the same filesystem for this exercise.
It then replaces the current pathname and synchronizes an open descriptor for the containing directory. The trace asserts the sequence: candidate fsync succeeds, rename succeeds, then parent-directory fsync succeeds. These are observed system calls with checked results, rather than a source-code comment claiming that the intended synchronization order was followed.
Reading the current path returns new, while the reference opened earlier still reads old. This distinguishes atomic name replacement from changing every existing open reference. The old reference remains attached to its earlier file object. Readers that need a new version must select it according to the application’s publication protocol.
The child next appends -extended, flushes its buffer, and calls fdatasync. The resulting content is new-extended with length twelve. This exercises successful data synchronization after a size-changing operation, consistent with the contract that metadata needed to retrieve the extended data cannot simply be ignored.
Finally, fsync on a pipe fails with EINVAL. The fixture checks that failure rather than treating every descriptor as a synchronizable regular file. All handles close during cleanup. The trace proves that the local kernel accepted the relevant file and directory requests; it does not independently prove that a particular physical device honors flushes during a power failure.
Define the acknowledgment boundary
Specify which update must survive and what the reader may observe. For a single-file replacement, synchronize the candidate before publication and the affected directory afterward. A move involving different directories can require considering both directory updates. Cross-filesystem movement has different mechanics, so a convenient rename-based protocol cannot be assumed to apply unchanged.
If synchronization fails, do not report the durable update as committed. Earlier writes or a rename may already have taken effect, so failure is not automatic rollback. Retain enough transaction or version information to determine the actual state and recover according to the application’s contract. Fsync alone does not make a sequence of changes to multiple files one atomic transaction.
Batching several operations behind a shared commit boundary can reduce synchronization cost when the application contract permits it. Acknowledge only after the required boundary succeeds. Validate crash recovery on the real storage stack, including its filesystem and device-cache behavior, because a virtualized laboratory’s successful syscall trace establishes API behavior without proving every lower layer’s failure guarantees.