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.

SyntaxString 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.readString and Files.writeString, both Java 11. They use UTF-8 unless you pass a Charset.
  • Small files as lines: Files.readAllLines and Files.write(path, lines).
  • Large files (logs, exports, imports): Files.lines or Files.newBufferedReader to stream line by line, and Files.newBufferedWriter to 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

  • NoSuchFileException when the file or its parent directory is missing. Writing does not create parent directories; call Files.createDirectories first.
  • MalformedInputException when readString or readAllLines meets 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 right Charset.
  • AccessDeniedException when 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

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · java
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+00E9

Replacing 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.

Example · java
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: 1

Streaming 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.

Example · java
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

  • Be the first to comment on this lesson.