Reading and Writing Files with NIO
Read and write text files with Files and Path, pick the right method for the file size, and replace files safely in a running service.
String text = Files.readString(path);
Files.writeString(path, text, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
List<String> lines = Files.readAllLines(path);
try (Stream<String> s = Files.lines(path)) { /* large files */ }Modern Java file code uses two types from java.nio.file: Path, which names a file, and Files, a class of static methods that do the work. The older java.io.File, FileReader and FileWriter still work, but Files reports failures with specific exceptions instead of returning false, and it is what current libraries expect.
Pick the method by file size
- Small files (config, templates, fixtures):
Files.readStringandFiles.writeString, both Java 11. They use UTF-8 unless you pass aCharset. - Small files as lines:
Files.readAllLinesandFiles.write(path, lines). - Large files (logs, exports, imports):
Files.linesorFiles.newBufferedReaderto stream line by line, andFiles.newBufferedWriterto write. Memory use stays flat whatever the file size.
readString and readAllLines load the whole file into the heap. That is fine for a 2 KB config file and a production incident for a 2 GB export in a container with a 512 MB limit.
Open options
Files.writeString(path, text); // create or truncate
Files.writeString(path, text, StandardOpenOption.APPEND); // add to the end (file must exist)
Files.writeString(path, text, StandardOpenOption.CREATE,
StandardOpenOption.APPEND); // create if missing, then append
Files.writeString(path, text, StandardOpenOption.CREATE_NEW); // fail if it already exists
With no options, a write creates the file or silently replaces everything in it. CREATE_NEW is the safe choice when overwriting would lose data, because it throws FileAlreadyExistsException instead.
Errors you will meet
NoSuchFileExceptionwhen the file or its parent directory is missing. Writing does not create parent directories; callFiles.createDirectoriesfirst.MalformedInputExceptionwhenreadStringorreadAllLinesmeets bytes that are not valid in the charset, typically a Latin-1 or Windows-1252 file read as UTF-8. Ask for the real encoding or pass the rightCharset.AccessDeniedExceptionwhen the process lacks permission, common in containers that run as a non-root user with a read-only root filesystem.
All of these extend IOException, so one catch handles them, and their getMessage() is usually just the path.
The back-end angle
A file that other code reads while you write it should be replaced atomically: write a temporary file in the same directory, then move it over the target. Readers then see either the old file or the new one, never half of each. Also remember that local disk belongs to one instance: with three replicas behind a load balancer, a file uploaded to one is invisible to the other two. Shared data belongs in a database or object storage; local files suit caches, temporary work and exports you stream straight back to the client.
Example
When to use it
- Stream a multi-gigabyte CSV export to totals without loading it into a container's limited heap.
- Atomically refresh a cached exchange-rate file that request threads read while a scheduler rewrites it.
- Append audit lines to a local file during a batch job and report a clear error when the directory is not writable.
More examples
Missing files and the wrong encoding
Both exceptions extend IOException, but catching them separately lets an import endpoint answer with a useful message. Old exports from spreadsheets and legacy systems are often Latin-1 or Windows-1252, so accept a charset parameter rather than guessing.
void main() throws IOException {
Path dir = Files.createTempDirectory("import");
try {
try {
Files.readString(dir.resolve("missing.csv"));
} catch (NoSuchFileException e) {
IO.println("missing: " + Path.of(e.getFile()).getFileName());
}
// "caf" plus 0xE9, which is "e acute" in Latin-1 but not valid UTF-8 on its own.
Path legacy = dir.resolve("legacy.txt");
Files.write(legacy, new byte[] {'c', 'a', 'f', (byte) 0xE9});
try {
Files.readString(legacy);
} catch (MalformedInputException e) {
IO.println("UTF-8 read failed: " + e.getClass().getSimpleName());
}
String text = Files.readString(legacy, StandardCharsets.ISO_8859_1);
IO.println("Latin-1 read: " + text.length() + " chars, last is U+%04X".formatted((int) text.charAt(3)));
Files.delete(legacy);
} finally {
Files.deleteIfExists(dir);
}
}
// Output:
// missing: missing.csv
// UTF-8 read failed: MalformedInputException
// Latin-1 read: 4 chars, last is U+00E9Replacing a file atomically
A reader that opens rates.json during the update sees the complete old version or the complete new one, never a half-written file. ATOMIC_MOVE throws AtomicMoveNotSupportedException if the file system cannot rename in one step, for example across two mounts.
static void writeAtomically(Path target, String content) throws IOException {
// The temporary file must be in the same directory so the move is a rename.
Path tmp = Files.createTempFile(target.getParent(), "tmp-", ".part");
try {
Files.writeString(tmp, content);
Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} finally {
Files.deleteIfExists(tmp);
}
}
void main() throws IOException {
Path dir = Files.createTempDirectory("cache");
Path target = dir.resolve("rates.json");
try {
writeAtomically(target, "{\"GBP\":1.00}");
writeAtomically(target, "{\"GBP\":1.00,\"EUR\":1.17}");
IO.println(Files.readString(target));
try (Stream<Path> files = Files.list(dir)) {
IO.println("files left in directory: " + files.count());
}
} finally {
Files.deleteIfExists(target);
Files.deleteIfExists(dir);
}
}
// Output:
// {"GBP":1.00,"EUR":1.17}
// files left in directory: 1Streaming a large file line by line
Only one line is in memory at a time, so the same code handles a file of any size. Both Files.lines and the reader hold an open file handle, which is why each sits in a try-with-resources block.
void main() throws IOException {
Path file = Files.createTempFile("transactions", ".csv");
try {
try (BufferedWriter out = Files.newBufferedWriter(file)) {
for (int i = 0; i < 100_000; i++) {
out.write("txn-" + i + "," + (i % 100));
out.newLine();
}
}
long total;
try (Stream<String> lines = Files.lines(file)) {
total = lines.mapToLong(line -> Long.parseLong(line.substring(line.indexOf(',') + 1)))
.sum();
}
long large;
try (BufferedReader in = Files.newBufferedReader(file)) {
large = in.lines().filter(line -> line.endsWith(",99")).count();
}
IO.println("total: " + total);
IO.println("rows with amount 99: " + large);
} finally {
Files.deleteIfExists(file);
}
}
// Output:
// total: 4950000
// rows with amount 99: 1000
Discussion