Java Write Files
›Short versionOpen if you only have a minute.
- Text is characters. The file stores bytes. Pass StandardCharsets.UTF_8 so the mapping is the same on every machine.
- FileWriter(String) encodes with Charset.defaultCharset() and truncates the file. Files.writeString encodes as UTF-8 when you omit the charset.
- The default open options truncate the target first. A crash before the last byte leaves a partial file, and the old contents are already gone.
- Write the full text to a temp file in the same directory, then Files.move with ATOMIC_MOVE. Readers see the old file or the new one.
- ATOMIC_MOVE ignores REPLACE_EXISTING. A temp file on another drive throws AtomicMoveNotSupportedException and leaves the target as it was.
Writing is where characters become bytes, and where a crash can publish half of them. Pick the at the call. Publish the finished bytes with a rename when readers expect a complete file.
Encode with UTF-8
can hold every character a Java string can hold. This writes one line, including é, and creates the file if it is absent.
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
public class WriteNote {
public static void main(String[] args) throws IOException {
Path file = Path.of("notes", "today.txt");
Files.createDirectories(file.getParent());
Files.writeString(file, "café\n", StandardCharsets.UTF_8);
}
}$ java WriteNote$ xxd notes/today.txt00000000: 6361 66c3 a90a caf...
63 61 66 is caf. c3 a9 is é in UTF-8. 0a is the newline written by \n in the Java string. A reader on another machine recovers café by decoding those bytes as UTF-8.
A opened as new FileWriter("notes/today.txt") uses the default charset of the runtime. From Java 18 that default is UTF-8. On earlier releases it followed the operating system, and a Windows machine could store é as the single byte E9. The constructor that takes StandardCharsets.UTF_8 removes that difference. Close the writer with try-with-resources.
What a crash leaves behind
writeString with no opens the file with CREATE, TRUNCATE_EXISTING, and WRITE. Truncate sets the length to zero before the new bytes are copied. That window is a .
Append is a different option set. A log line wants CREATE and APPEND, and it omits TRUNCATE_EXISTING. The file grows. A crash can still tear the last line, which is why log readers tolerate a short final line. A config file does not have that tolerance. Readers expect the previous complete document or the next complete one.
writeString opened config.json with the default options, and the process was killed halfway through the bytes. What does a later read see?
A shorter file: the prefix of the new bytes that reached disk, or an empty file if the kill landed just after the truncate. The previous complete JSON was discarded when the file was opened. There is no second copy under that name.
Publish with an atomic move
Write the whole document to a temporary file beside the target, then publish it with an . The part file can be partial. today.txt changes in one step.
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
static void replace(Path target, String text) throws IOException {
Path dir = target.getParent() == null ? Path.of(".") : target.getParent();
Files.createDirectories(dir);
Path temp = Files.createTempFile(dir, ".part-", ".tmp");
try {
try (FileChannel channel = FileChannel.open(temp, StandardOpenOption.WRITE)) {
ByteBuffer encoded = StandardCharsets.UTF_8.encode(text);
while (encoded.hasRemaining()) {
channel.write(encoded);
}
channel.force(true);
}
Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException e) {
throw new IOException("atomic replace needs the same drive as " + target, e);
} finally {
Files.deleteIfExists(temp);
}
}The loop finishes the buffer. One channel.write can return after copying only some of the remaining bytes, and stopping there would publish a short part file under the real name. channel.force(true) asks the operating system to push the bytes to the device before the rename. writeString returning means the bytes were handed to the operating system. A power loss can still drop them until they are forced. After a successful move the temp name is gone, and deleteIfExists in finally finds nothing to remove. If the write or the move throws, finally removes the part file and the target stays as it was.
On a POSIX system the rename replaces today.txt if it already exists. ATOMIC_MOVE does not consult REPLACE_EXISTING, because it ignores the other options. If this runtime throws AtomicMoveNotSupportedException whenever the target exists, keep the move atomic by writing a new generation name and updating a link, or stay on a file system where rename replaces. Falling back to a plain move copies bytes under the public name and opens the partial-write window again.
Java Read Files decodes the bytes this page wrote. The charset has to be the one used here.