JAVA / FILES, INPUT/OUTPUT AND SERIALIZATION
Safe file writes with temp files and atomic moves
Update a file so a reader or a crash can only see the complete old or complete new version, by writing a temp file beside it and swapping it in atomically.
What you will learn
- Write the new content to a temp file in the target's own directory, then rename it
- Use Files.move with ATOMIC_MOVE and keep both paths on one filesystem
- Delete the temp file in a finally block so failures leave no litter
- Call FileChannel.force before the swap when the data must survive power loss
Understanding Safe file writes with temp files and atomic moves
A plain overwrite is not one operation. Files.newBufferedWriter(target) opens the target with TRUNCATE_EXISTING, so the old content disappears before the first new byte arrives, and the new content then dribbles out in buffer-sized chunks. Between those two moments the file on disk is neither version: a reader that opens it sees a header with no rows, and if the process dies there the old data is gone for good. Nothing you add inside that window helps, because the damage comes from writing into the very file everyone else is reading.
The fix is to never touch the target while building the new content. Create a temp file in the target's own directory, write everything into it, close it, and only then call Files.move(tmp, target, REPLACE_EXISTING, ATOMIC_MOVE). That last call is a rename: the directory entry app.properties stops pointing at the old file and starts pointing at the finished new one in a single filesystem operation. The temp file must live in the same directory precisely because a rename only relabels an entry inside one filesystem; across mounts the JDK would have to copy bytes and delete the source, so it throws AtomicMoveNotSupportedException rather than pretend the result was atomic.
Atomic and durable are different guarantees, and the rename only buys you the first. Atomic is about visibility: every open() returns either the complete old file or the complete new file, and a process that already opened the old file keeps reading a consistent snapshot of it. Durable is about surviving a power cut, which needs the temp file's bytes forced to the device before the move and, on POSIX, the containing directory fsynced afterwards so the new name persists. Atomicity also gives you no mutual exclusion: two writers using this pattern both succeed and the last rename wins, so the file is always valid but one update can silently vanish.
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
public class AtomicSave {
interface Content {
void writeTo(BufferedWriter out) throws IOException;
}
static void save(Path target, Content content) throws IOException {
Path dir = target.toAbsolutePath().getParent();
Path tmp = Files.createTempFile(dir, target.getFileName() + ".", ".tmp");
try {
try (BufferedWriter out = Files.newBufferedWriter(tmp, StandardCharsets.UTF_8)) {
content.writeTo(out);
}
Files.move(tmp, target,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
} catch (IOException e) {
Files.deleteIfExists(tmp);
throw e;
}
}
public static void main(String[] args) throws IOException {
Path dir = Files.createTempDirectory("safe-write");
Path config = dir.resolve("app.properties");
save(config, out -> out.write("port=8080\n"));
System.out.println("v1: " + Files.readAllLines(config));
save(config, out -> {
out.write("port=9090\n");
out.write("timeout=30\n");
});
System.out.println("v2: " + Files.readAllLines(config));
try {
save(config, out -> {
out.write("port=7070\n");
throw new IOException("disk full");
});
} catch (IOException e) {
System.out.println("v3 failed: " + e.getMessage());
}
System.out.println("still: " + Files.readAllLines(config));
int leftover = 0;
try (DirectoryStream<Path> tmps = Files.newDirectoryStream(dir, "*.tmp")) {
for (Path ignored : tmps) leftover++;
}
System.out.println("leftover .tmp files: " + leftover);
}
}Never write into the file readers can see; build the complete new version next to it and publish it with a single rename inside the same directory.
Worked examples
What an in-place rewrite costs you
Shows that opening the target for writing destroys the old content immediately and a mid-write failure publishes a plausible but incomplete file.
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
public class InPlaceRewrite {
public static void main(String[] args) throws IOException {
Path dir = Files.createTempDirectory("in-place");
Path file = dir.resolve("rows.csv");
Files.write(file, "id,name\n1,ada\n2,alan\n".getBytes(StandardCharsets.UTF_8));
System.out.println("before: " + Files.readAllLines(file));
try (BufferedWriter out = Files.newBufferedWriter(file, StandardCharsets.UTF_8)) {
System.out.println("size right after open: " + Files.size(file));
out.write("id,name\n");
out.write("1,ada\n");
throw new IOException("failed before row 2");
} catch (IOException e) {
System.out.println("write failed: " + e.getMessage());
}
System.out.println("after: " + Files.readAllLines(file));
System.out.println("size: " + Files.size(file));
}
}Example explained
Line 1Files.newBufferedWriter with no options implies CREATE, TRUNCATE_EXISTING and WRITE, so the original 21 bytes are discarded at open time.
Line 2Files.size(file) called from inside the try block prints 0, proving the old content is already gone before any replacement byte was written.
Line 3The throw skips row 2, but try-with-resources still closes the writer, which flushes the 14 bytes written so far.
Line 4The result parses as valid CSV and carries no marker of the missing row, which is exactly the failure mode the temp-file swap removes.
Forcing bytes to disk before the swap
Uses a FileChannel to push the temp file's contents to the device before the rename publishes the new name.
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
public class DurableSwap {
public static void main(String[] args) throws IOException {
Path dir = Files.createTempDirectory("durable");
Path target = dir.resolve("ledger.txt");
Path tmp = target.resolveSibling("ledger.txt.tmp");
byte[] bytes = "balance=42\n".getBytes(StandardCharsets.UTF_8);
try (FileChannel ch = FileChannel.open(tmp,
StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
ch.write(ByteBuffer.wrap(bytes));
ch.force(true);
System.out.println("forced to disk: " + ch.size() + " bytes");
}
Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE);
System.out.println("target now: " + Files.readAllLines(target));
System.out.println("tmp gone: " + !Files.exists(tmp));
}
}Example explained
Line 1resolveSibling keeps the temp file next to the target, which is what makes ATOMIC_MOVE possible at the end.
Line 2CREATE_NEW fails instead of overwriting if that fixed temp name already exists, so a stale or concurrent temp file is reported rather than silently reused.
Line 3ch.force(true) pushes the file's data and length to storage, so the rename cannot publish a name that points at data still sitting in the page cache.
Line 4force(true) covers the temp file, not the directory entry the rename creates; full durability would also require fsyncing the directory, which java.nio cannot do portably.
Important notes
When ATOMIC_MOVE is present, Files.move ignores the other copy options; the default Unix and Windows providers still replace an existing target. Keeping REPLACE_EXISTING in the call documents intent and gives you the option to retry without ATOMIC_MOVE if you catch AtomicMoveNotSupportedException, but that fallback is no longer atomic.
Files.createTempFile creates the file with owner-only permissions on POSIX and the rename carries them onto the target, so a config file that other accounts used to read can become unreadable; set the permissions on the temp file before moving. An atomic move over a symlink also replaces the link with a regular file.
Common mistakes
Creating the temp file with the two-argument Files.createTempFile("cfg", ".tmp"), which puts it under java.io.tmpdir; on Linux that is often a separate tmpfs mount, so the very last step throws AtomicMoveNotSupportedException after all the writing already succeeded.
Calling Files.move while the writer is still open: the tail of the content is still buffered, so the file that gets published is truncated at the last flush boundary, and on Windows the move itself can fail because the handle is open.
Cleaning up only inside catch (IOException): a NullPointerException or IllegalStateException thrown by the code that produces the content skips the cleanup and leaves a stray .tmp file behind on every such failure.
Try it yourself
Change, predict, then run
Copy the save() helper, replace the catch (IOException) cleanup with a finally block calling Files.deleteIfExists(tmp), then invoke it with a lambda that writes one line and throws an IllegalStateException. Print the target's contents and the count of *.tmp files in the directory to confirm the old file is intact and nothing was left behind.
Open the Java workspaceCheck your understanding
Your save routine builds the temp file with Files.createTempFile("cfg", ".tmp") and finishes with Files.move(tmp, target, REPLACE_EXISTING, ATOMIC_MOVE). It works on your laptop but throws AtomicMoveNotSupportedException on the Linux server. What is actually wrong?
- REPLACE_EXISTING and ATOMIC_MOVE cannot be combined in one Files.move call, and the Linux provider rejects the combination.
- ATOMIC_MOVE refuses to overwrite a target that already exists, so every save after the first one fails.
- The temp file lives under java.io.tmpdir, which is a different filesystem on that server, and a rename cannot span filesystems.
- The temp file was still open for writing, so the rename was blocked until the handle was closed.
Show answer
ATOMIC_MOVE is implemented as a single rename inside one filesystem; when source and destination sit on different mounts the provider would have to copy the bytes and delete the source, which is not atomic, so it reports AtomicMoveNotSupportedException instead of silently degrading. On your laptop the temp directory happens to be on the same volume as the target, which is why it passed there. Option 2 is tempting because the target really does exist, but the default Unix and Windows providers do replace an existing target during an atomic move; the exception names a missing capability, not a name conflict. Fix it by passing the target's own parent directory to Files.createTempFile.