How would you write a text file so that a crash never leaves a half-written or corrupted file (atomic / durable writes)?
answer
- Write temp -> Files.move ATOMIC_MOVE + REPLACE_EXISTING
- Rename is atomic only on same filesystem
- Atomicity != durability; fsync data AND directory
- Plain write truncates target first (torn on crash)
- FileChannel.force(true) to persist to device
basics
~20 sWrite to a temporary file first, then rename it over the real file. Rename is atomic on the same filesystem, so readers either see the old file or the complete new one, never a half-written file. If a crash happens mid-write, only the temp file is damaged.
solid answer
~40 sA plain Files.writeString truncates the target and streams bytes in; a crash mid-write leaves a truncated, corrupt file, and even after the call the OS may still hold data in its page cache rather than on disk. The standard safe pattern is write-temp-then-rename: write the full content to a temp file in the same directory, then atomically move it onto the target with Files.move(tmp, target, ATOMIC_MOVE, REPLACE_EXISTING). Rename within one filesystem is atomic, so any reader sees either the complete old or complete new file. For durability against power loss, fsync the temp file's data and the directory around the rename (e.g. via FileChannel.force(true)), because rename atomicity guarantees visibility ordering, not that bytes hit the platter. ATOMIC_MOVE requires source and target on the same filesystem, otherwise it throws.
code
java · 23 linesimport java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import static java.nio.file.StandardCopyOption.*;
void writeAtomically(Path target, String content) throws Exception {
Path dir = target.toAbsolutePath().getParent();
Path tmp = Files.createTempFile(dir, target.getFileName().toString(), ".tmp");
try {
Files.writeString(tmp, content, StandardCharsets.UTF_8);
// durability: flush temp file's bytes to the device
try (FileChannel ch = FileChannel.open(tmp, StandardOpenOption.WRITE)) {
ch.force(true);
}
// atomic publish (same filesystem required)
Files.move(tmp, target, ATOMIC_MOVE, REPLACE_EXISTING);
// persist the rename itself (POSIX; guard on non-POSIX)
try (FileChannel d = FileChannel.open(dir)) { d.force(true); }
catch (Exception ignoredOnNonPosix) { }
} finally {
Files.deleteIfExists(tmp); // cleanup if move failed
}
}go deeper
Aware that writing directly can corrupt a file if the program crashes; a temp-then-rename idea is enough.
Can implement write-temp-then-Files.move with ATOMIC_MOVE/REPLACE_EXISTING for safe visibility.
Distinguishes atomicity (rename) from durability (fsync), knows the same-filesystem constraint and REPLACE_EXISTING semantics.
Designs the full temp-write + fsync-file + fsync-directory protocol, handles cross-platform fsync/atomic-move limits, and decides where this rigor is warranted vs overkill across the system.
## The two problems Naively writing a file has two failure modes: 1. **Torn / partial write (atomicity):** `Files.writeString(target, ...)` first **truncates** `target` to zero, then writes bytes. If the process or machine crashes partway, `target` is now a *truncated, corrupt* file — and any concurrent reader can observe an empty or half-written file. 2. **Lost data on power failure (durability):** even after `write` returns, the bytes often live only in the OS **page cache** (RAM), not yet on the physical disk. A power loss can lose them. ## Atomicity: write-temp-then-rename The classic fix decouples *writing* from *publishing*: ```java Path target = Path.of("config.json"); Path tmp = Files.createTempFile(target.getParent(), "config", ".tmp"); Files.writeString(tmp, content, StandardCharsets.UTF_8); Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); ``` Key insight: on a POSIX filesystem, **`rename()` over an existing path is atomic** — at any instant the name `config.json` points either to the *old* inode or the *new* one, never a mix. `StandardCopyOption.ATOMIC_MOVE` asks for exactly that. So a reader always sees a *complete* file; a crash before the move leaves only the throwaway temp file. The temp file **must be in the same directory/filesystem** as the target, because an atomic rename cannot cross filesystems (`Files.move` would otherwise fall back to copy-then-delete, which is *not* atomic, or throw `AtomicMoveNotSupportedException`). ## Durability: fsync Atomic rename guarantees *which* file you see, not that its bytes are physically persisted. For crash/power-loss durability you must **fsync**: 1. Write the temp file, then force its data to disk: open it via `FileChannel` and call `channel.force(true)` (the boolean also forces metadata) — or use `StandardOpenOption.SYNC`/`DSYNC` when opening. 2. Perform the atomic move. 3. **fsync the directory** too, so the rename itself (a directory metadata change) is persisted; otherwise after a crash the directory entry might still point at the old file. ```java try (FileChannel ch = FileChannel.open(tmp, StandardOpenOption.WRITE)) { ch.force(true); // flush file data+metadata to the device } // ... Files.move(...) ... try (FileChannel dir = FileChannel.open(target.getParent())) { dir.force(true); // persist the directory entry (POSIX; not portable to all OSes) } ``` Directory fsync is POSIX-specific and may throw on some platforms (e.g. Windows), so production code guards it. ## Why not just flush? `flush()` only pushes from the JVM buffer into the OS — not to disk. `BufferedWriter.close()` flushes but does not fsync. APPEND avoids truncation but still allows a partially appended record and isn't atomic for full-file replacement. ## When it matters Config files, caches, anything where a reader or a restart could observe a corrupt file. For throwaway logs it's overkill. Commons-io's `FileUtils` and most databases implement exactly this temp-write-fsync-rename dance. ## Summary Atomic visibility = write temp in same dir + `Files.move(..., ATOMIC_MOVE, REPLACE_EXISTING)`. Durability against power loss = additionally fsync the file and its directory. Same-filesystem is mandatory for atomic move.
- Why must the temp file be on the same filesystem as the target?Atomic rename works only within one filesystem; across filesystems Files.move degrades to non-atomic copy-then-delete or throws AtomicMoveNotSupportedException.
- Does ATOMIC_MOVE guarantee the data survives a power loss?No. It guarantees readers see old-or-new, not torn. For power-loss durability you must also fsync the file data and the containing directory.
- Why fsync the directory and not just the file?The rename is a directory metadata change; without fsyncing the directory, a crash could leave the directory entry still pointing at the old file even though the file's bytes are durable.
saying these in an interview costs you the question
- Thinking flush() or close() makes data durable on disk (it doesn't fsync)
- Writing the temp file on a different filesystem and expecting atomic move
- Believing ATOMIC_MOVE also guarantees durability against power loss
- Overwriting the target in place and assuming readers never see a partial file