Rust's std::fs Isn't Atomic: Writing Files Safely With Rename
Here's a bug that only shows up when you're unlucky: a service writes its config out with std::fs::write, the machine loses power (or the process gets SIGKILLed, or the disk hiccups) halfway through, and on reboot the config file is either empty or contains half a JSON document. Nothing in the Rust standard library warned you this could happen, because nothing about fs::write promises otherwise.
It's an easy assumption to make. fs::write reads like a single operation:
std::fs::write("config.json", data)?;
One line, looks atomic. But under the hood it's just File::create followed by write_all, i.e. open-with-truncate, then write the bytes. That's two syscalls at minimum, and the truncate happens first. If the process dies between the truncate and the write completing, the file on disk is empty or partially written. If it dies before the write buffer is even flushed by the OS, you might see stale data mixed with new data, depending on the filesystem. There's no point at which the operation is all-or-nothing.
What "atomic" actually means here
When people say a file write is atomic, they mean: any process observing the file at any moment sees either the old, complete contents, or the new, complete contents. Never a partial mix, never a truncated stub, never a missing file. It doesn't mean the write is instantaneous, it means there's no visible in-between state.
POSIX filesystems give you exactly one operation with this property for regular files: rename(2). Renaming a file within the same filesystem is a single directory-entry update. The kernel doesn't do it in two halves. Either the directory entry points at the old inode or the new one, and a crash mid-rename resolves to one or the other, never a corrupted entry.
So the standard trick for atomic file writes is: don't write to the target path at all. Write your new content to a temporary file, make sure it's actually on disk, then rename the temp file onto the target. Readers either see the old file (rename hasn't happened yet) or the fully-written new file (rename has happened). They never see a half-written target, because the target was never opened for writing in the first place.
A minimal implementation
use std::fs::{self, File};
use std::io::{self, Write};
use std::path::Path;
fn write_atomic(path: &Path, contents: &[u8]) -> io::Result<()> {
let dir = path.parent().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "path has no parent directory")
})?;
let tmp_path = dir.join(format!(
".{}.tmp-{}",
path.file_name().unwrap().to_string_lossy(),
std::process::id()
));
let mut tmp_file = File::create(&tmp_path)?;
tmp_file.write_all(contents)?;
tmp_file.sync_all()?;
drop(tmp_file);
fs::rename(&tmp_path, path)?;
File::open(dir)?.sync_all()?;
Ok(())
}
Every piece here is load-bearing:
- The temp file lives in the same directory as the target, not in
/tmp.rename(2)is only atomic within a single filesystem; if the temp file and target are on different mount points,fs::renamereturns anEXDEVerror instead of silently falling back to copy-then-delete. Same directory guarantees same filesystem. sync_all()before the rename flushes the temp file's data to disk. Skip this and the rename can complete (so the new file is visible immediately) while the actual bytes are still sitting in a write-back cache. A crash right after the rename can then leave you with a file of the right name and the wrong (zeroed or garbage) content, which is arguably worse than the original problem, because it looks correct until you read it.- The process ID in the temp filename avoids collisions if two instances of the same process race to write the same path. It's not a substitute for real locking if you actually have concurrent writers, just cheap insurance against leftover junk from a previous crashed run colliding with a new one.
The bit everyone forgets: fsync the directory too
This is the part that catches people out even when they know about the temp-file-and-rename pattern. On Linux, renaming a file updates the directory's contents (it now has a new entry pointing at your inode, and no longer has the old temp-file entry), but that directory update lives in the page cache just like file data does. A crash between the rename returning and the directory being flushed can, on some filesystems, lose the rename entirely, leaving you back with the old file, or occasionally in an inconsistent state depending on the filesystem's journalling behaviour.
Calling sync_all() on a File opened against the directory itself (as above) forces that directory metadata to disk. It looks a bit odd, opening a directory as a file, but it's exactly what tools like PostgreSQL and SQLite do internally, and it's the reason their write-ahead logs survive power loss. Skipping it doesn't cause corruption in the way skipping the file fsync does, but it can silently undo the atomic write you just did, which defeats the point of doing any of this.
Windows is a different animal
Everything above is POSIX behaviour. On Windows, fs::rename is backed by MoveFileExW with MOVEFILE_REPLACE_EXISTING, which is also atomic with respect to crashes for the rename step itself, but the file locking model is stricter: you generally can't rename over a file that another process has open without sharing permissions, and NTFS's metadata journal behaves differently from Linux's various filesystem journals. Rust's standard library smooths over the API difference (you call the same fs::rename either way) but not the underlying guarantees, so if cross-platform durability matters, test on the platforms you actually ship to rather than assuming POSIX semantics transfer.
When to reach for a crate instead
The tempfile crate handles the temp-file creation and cleanup-on-drop parts of this more carefully than the hand-rolled version above (in particular, it cleans up the temp file if you bail out before the rename, whereas the code here leaves it behind on error). For anything beyond a quick utility script, tempfile::NamedTempFile::persist is the same pattern with the edge cases already handled, and it's worth using rather than re-debugging your own cleanup logic. The manual version is worth writing once, though, because it's the only way to actually see what "atomic write" is buying you and what it costs: one extra file, one extra rename, two fsyncs instead of zero.
None of this matters for a scratch file or a cache you're happy to lose. It matters for anything where "the file was half-written" is a worse failure mode than "the write didn't happen at all": config files, on-disk indexes, anything a process reads on startup and would otherwise fail to parse. The cost is a few extra syscalls per write. The alternative is debugging a corrupted file at 3am and not being sure whether the bug is in your code or in whatever wrote the file six months ago.