Skip to content

How to Read and Write Files in Java with NIO.2 (Java 21)

Build a parcel scan log in Java 21 with NIO.2: append, stream, atomically replace and archive files, plus 20 checks and the errors I hit on purpose.

how-to-tutorials java-io java-tutorials
Gopi Gorantala
Reading Progress

On This Page

Here's the file the finished program leaves on disk. Tabs between the columns, one scan per line, umlauts intact:

PK-1001	2026-10-20T08:15:00Z	Rotterdam hub	RECEIVED
PK-1001	2026-10-21T13:40:00Z	Köln depot	IN_TRANSIT
PK-1001	2026-10-22T10:05:00Z	Zürich van 7	OUT_FOR_DELIVERY

Writing that took one Files.writeString call per scan. Reading it back took one Files.lines. The rest of this article is everything around those two calls: what decides whether the program still works on a Windows laptop, or after somebody kills it in the middle of a save.

If you want to know how to read and write files in Java without FileReader, BufferedWriter and a pile of close() calls, this is the walkthrough. We'll use the java.nio.file package (NIO.2, around since Java 7): Path to name a file, Files to do things to it. The example is a parcel tracker's scan log: append a scan, list one parcel's history, replace the log safely, archive old logs. You'll finish with five small classes, a Main you can run, and twenty checks that prove the behaviour.

It's written for someone who already writes Java and wants file handling that doesn't surprise them later.

Main ──> ScanLog ──> Files / Path  ──> data/scans.log
   │        │
   │        └──> ScanEvent (one record = one line)
   └──> Archiver ─────────────────> data/archive/scans.log

Check the tools you have

Tool Version I used Check it with
JDK OpenJDK 21.0.12.1 (Ubuntu build) java -version
IDE, if you want one IntelliJ IDEA 2026.2.2 or Eclipse IDE 2026-06 (4.40) Help → About
Build tool none; plain javac javac -version
OS I ran it on Linux see the Windows notes below

I read the IDE versions off the vendors' announcement pages. I didn't open either IDE for this article, so the IDE steps below come from the vendors' documentation and my own muscle memory, not from a session I recorded. Everything that prints output was run from a terminal.

Nothing here needs a library. No Maven, no Gradle, no dependency to download, which also means it works behind a locked-down proxy. (I'll come back to why there's no JUnit in the test section.)

You need Java 11 or newer for Path.of and Files.writeString, and Java 16 or newer for the record in Step 1. If you're on 8, the records are the first thing the compiler rejects (I tried --release 8; it stops at line 5 of ScanEvent.java).

If you don't have a JDK, SDKMAN does it in two commands: sdk list java, then sdk install java <the Temurin 21 identifier it shows>.

Lay down the parcel-log project in IntelliJ IDEA, Eclipse or a terminal

I used the terminal as the primary path. Make the folders:

mkdir -p parcel/src/main/java/com/example/parcels
mkdir -p parcel/src/test/java/com/example/parcels
cd parcel

The tree you'll end up with:

parcel/
├── src/
│   ├── main/java/com/example/parcels/
│   │   ├── ScanEvent.java
│   │   ├── ScanLog.java
│   │   ├── Archiver.java
│   │   └── Main.java
│   └── test/java/com/example/parcels/
│       └── Checks.java
├── out/          (compiled main classes)
└── test-out/     (compiled checks)

In IntelliJ IDEA

File → New → Project, pick IntelliJ as the build system (no Maven needed), choose JDK 21, name it parcel. Mark src/main/java as Sources Root and src/test/java as Test Sources Root (right-click the folder → Mark Directory as). Create the com.example.parcels package in both.

In Eclipse

File → New → Java Project, name it parcel, and on the first page choose a JavaSE-21 execution environment. On the second page of the wizard, add src/main/java and src/test/java as source folders. If Eclipse offers to create module-info.java, say no; this project doesn't need it.

With Docker or Podman

Not needed. There's no database or broker here, so no container.

Make the encoding explicit now

Both IDEs and javac read your source files, and this project has "Köln" and "Zürich" in it. JDK 18 and later default to UTF-8 for this; on JDK 17 and earlier javac uses your platform's charset. I pass the flag every time so the answer doesn't depend on the JDK. In a terminal, that's -encoding UTF-8. In IntelliJ: Settings → Editor → File Encodings, set Project Encoding to UTF-8. In Eclipse: Window → Preferences → General → Workspace → Text file encoding, choose UTF-8.

I'll tell you right now what bit me with encodings in this project, because it'll look like a bug in your code: the file was fine and the console was wrong. I'll show it in the "Run it" section.

Read and write files in Java with NIO.2: five steps

Each step gives you the whole file and a checkpoint. If a checkpoint fails, the cause is almost always in the file you just wrote.

Step 1: Describe one scan as one line

Goal: decide what a line in the file looks like, so every other class can stay ignorant of it.

src/main/java/com/example/parcels/ScanEvent.java

package com.example.parcels;

import java.time.Instant;

public record ScanEvent(String parcelId, Instant at, String location, String status) {

    public ScanEvent {
        if (parcelId == null || parcelId.isBlank()) {
            throw new IllegalArgumentException("parcelId is required");
        }
        if (location.contains("\t") || location.contains("\n")) {
            throw new IllegalArgumentException("location can't contain tabs or newlines");
        }
    }

    /** One event becomes one tab-separated line. */
    public String toLine() {
        return String.join("\t", parcelId, at.toString(), location, status);
    }

    public static ScanEvent parse(String line) {
        String[] p = line.split("\t", -1);
        if (p.length != 4) {
            throw new IllegalArgumentException("Expected 4 fields but got " + p.length + ": " + line);
        }
        return new ScanEvent(p[0], Instant.parse(p[1]), p[2], p[3]);
    }
}

Tabs, not commas: place names contain commas ("Frankfurt, Hbf"), and nobody types a tab into a location by accident. The constructor refuses tabs and newlines in location so a bad value can't corrupt the format. split("\t", -1) with the -1 keeps trailing empty fields, otherwise a scan with an empty status would quietly parse as three fields.

Before the file code, get comfortable with Path, because every Files method takes one. A Path is a name, and building one doesn't touch the disk.

PathsDemo.java (a throwaway, outside the project)

import java.nio.file.Path;

public class PathsDemo {
    public static void main(String[] args) {
        Path dir = Path.of("data");
        Path file = dir.resolve("scans.log");
        System.out.println("file        = " + file);
        System.out.println("fileName    = " + file.getFileName());
        System.out.println("parent      = " + file.getParent());
        System.out.println("absolute    = " + file.toAbsolutePath());
        System.out.println("messy       = " + Path.of("data/../data/./scans.log").normalize());
        System.out.println("relativize  = " + Path.of("/srv/parcels").relativize(Path.of("/srv/parcels/archive/scans.log")));
        System.out.println("resolve abs = " + Path.of("/srv/parcels").resolve("/etc/hosts"));
    }
}
java PathsDemo.java
file        = data/scans.log
fileName    = scans.log
parent      = data
absolute    = /home/claude/scratch/demo/data/scans.log
messy       = data/scans.log
relativize  = archive/scans.log
resolve abs = /etc/hosts

(Your absolute line will show your own working directory.) The last line is the one to remember. resolve with an absolute argument throws the base away and returns the argument. If a user-supplied name ever reaches resolve, that's a path-traversal bug waiting for a bad day; see "Before this touches real data".

Checkpoint: javac -encoding UTF-8 -d out src/main/java/com/example/parcels/ScanEvent.java prints nothing and creates out/com/example/parcels/ScanEvent.class.

Step 2: Append a scan to the log

Goal: add a line without losing the lines already there.

You'll write the whole of ScanLog.java in Step 4. For now, read the method that matters:

public void append(ScanEvent event) throws IOException {
    Files.createDirectories(file.getParent());
    Files.writeString(file, event.toLine() + System.lineSeparator(),
            StandardCharsets.UTF_8,
            StandardOpenOption.CREATE, StandardOpenOption.APPEND);
}

Three things in there are doing real work:

  • createDirectories makes data/ (and any parents) if they're missing, and does nothing if they exist. Without it, the first write fails with NoSuchFileException on the folder, which reads like the file is missing.
  • CREATE plus APPEND is the combination you want for a log: make the file if needed, then add to the end.
  • Leave the options out and writeString truncates. The defaults are CREATE, TRUNCATE_EXISTING and WRITE. I put this in the checks, because it's the mistake I've seen most often: someone "logs" a line and the log never has more than one.

Charset is explicit on purpose. The Files methods default to UTF-8 on every JDK that has them, unlike FileWriter, which used the platform default until JDK 18. Spelling it out costs nothing and tells the next reader you thought about it.

Checkpoint: nothing to run yet, since the method needs the rest of the class. Step 4 compiles it.

Step 3: Read it back, two ways

Goal: get events out of the file, and know which of two reading calls to use.

The two methods in ScanLog:

public List<ScanEvent> readAll() throws IOException {
    if (Files.notExists(file)) {
        return List.of();
    }
    return Files.readAllLines(file, StandardCharsets.UTF_8).stream()
            .filter(line -> !line.isBlank())
            .map(ScanEvent::parse)
            .toList();
}

public List<ScanEvent> history(String parcelId) throws IOException {
    if (Files.notExists(file)) {
        return List.of();
    }
    try (Stream<String> lines = Files.lines(file, StandardCharsets.UTF_8)) {
        return lines.filter(line -> line.startsWith(parcelId + "\t"))
                .map(ScanEvent::parse)
                .toList();
    } catch (UncheckedIOException e) {
        throw e.getCause();   // Files.lines wraps IOException; unwrap it
    }
}

readAllLines loads every line into memory. That's fine for a few thousand scans, and wrong for a multi-gigabyte log. Files.lines reads lazily, so memory stays flat, but the returned Stream holds the file open until you close it. Hence the try-with-resources. The startsWith(parcelId + "\t") includes the tab so that PK-1 doesn't match PK-10; that one has its own check.

I measured the cost of forgetting the try. In a loop I opened Files.lines 200 times without closing:

fds before=8 after 200 unclosed=208
after gc=8

200 open file descriptors, one per forgotten stream. The garbage collector eventually closed them (the after gc=8 line), which is exactly what makes this bug nasty: it passes in a short test and falls over in a long-running service with the dreaded "Too many open files". The same loop with try-with-resources stayed at 8.

(For the full read-large-files story, including BufferedReader and memory numbers, see the planned large-file article.)

Checkpoint: same again; the compile at the end of Step 4 covers both methods.

Step 4: Replace the file without a half-written moment

Goal: rewrite the whole log so that a crash mid-write leaves either the old file or the new one, never half of each.

The finished ScanLog.java, complete:

src/main/java/com/example/parcels/ScanLog.java

package com.example.parcels;

import java.io.IOException;
import java.io.UncheckedIOException;
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;
import java.util.List;
import java.util.stream.Stream;

public class ScanLog {

    private final Path file;

    public ScanLog(Path file) {
        this.file = file.toAbsolutePath().normalize();
    }

    public Path file() {
        return file;
    }

    /** Appends one event. Creates the file (and its folder) the first time. */
    public void append(ScanEvent event) throws IOException {
        Files.createDirectories(file.getParent());
        Files.writeString(file, event.toLine() + System.lineSeparator(),
                StandardCharsets.UTF_8,
                StandardOpenOption.CREATE, StandardOpenOption.APPEND);
    }

    /** Reads the whole file. Fine for small logs. */
    public List<ScanEvent> readAll() throws IOException {
        if (Files.notExists(file)) {
            return List.of();
        }
        return Files.readAllLines(file, StandardCharsets.UTF_8).stream()
                .filter(line -> !line.isBlank())
                .map(ScanEvent::parse)
                .toList();
    }

    /** Streams the file line by line. The stream holds the file open until it's closed. */
    public List<ScanEvent> history(String parcelId) throws IOException {
        if (Files.notExists(file)) {
            return List.of();
        }
        try (Stream<String> lines = Files.lines(file, StandardCharsets.UTF_8)) {
            return lines.filter(line -> line.startsWith(parcelId + "\t"))
                    .map(ScanEvent::parse)
                    .toList();
        } catch (UncheckedIOException e) {
            throw e.getCause();   // Files.lines wraps IOException; unwrap it
        }
    }

    /** Replaces the file so a reader never sees a half-written version. */
    public void replaceAll(List<ScanEvent> events) throws IOException {
        Files.createDirectories(file.getParent());
        Path tmp = Files.createTempFile(file.getParent(), "scans-", ".tmp");
        try {
            List<String> lines = events.stream().map(ScanEvent::toLine).toList();
            Files.write(tmp, lines, StandardCharsets.UTF_8);
            try {
                Files.move(tmp, file, StandardCopyOption.ATOMIC_MOVE);
            } catch (AtomicMoveNotSupportedException e) {
                Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
            }
        } finally {
            Files.deleteIfExists(tmp);   // no-op after a successful move
        }
    }
}

The trick in replaceAll: write the new content to a temp file in the same folder, then Files.move it over the real file with ATOMIC_MOVE. Readers see the old file right up to the instant the name flips. Same folder matters because an atomic move can't cross file systems. I forced that one to see the exact message:

java.nio.file.AtomicMoveNotSupportedException: /tmp/force-.../x.log -> /dev/shm/x-moved.log: Invalid cross-device link

The catch falls back to REPLACE_EXISTING for file systems that can't do atomic moves at all. That fallback isn't atomic, and I'd rather you know than assume. The finally removes the temp file if anything failed before the move; after a successful move the temp name no longer exists and deleteIfExists does nothing.

Checkpoint: compile both classes.

javac -encoding UTF-8 --release 21 -d out src/main/java/com/example/parcels/ScanEvent.java src/main/java/com/example/parcels/ScanLog.java

No output means success.

Step 5: Archive old logs and clean up folders

Goal: move finished logs into an archive/ folder, and be able to delete a folder tree in tests.

src/main/java/com/example/parcels/Archiver.java

package com.example.parcels;

import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.List;

public class Archiver {

    /** Moves every *.log in dir into dir/archive and returns the new paths. */
    public static List<Path> archiveLogs(Path dir) throws IOException {
        Path archive = Files.createDirectories(dir.resolve("archive"));
        List<Path> moved = new ArrayList<>();
        try (DirectoryStream<Path> logs = Files.newDirectoryStream(dir, "*.log")) {
            for (Path log : logs) {
                Path target = archive.resolve(log.getFileName());
                moved.add(Files.move(log, target, StandardCopyOption.REPLACE_EXISTING));
            }
        }
        return moved;
    }

    /** Deletes a folder and everything in it. Files.delete alone refuses a non-empty folder. */
    public static void deleteTree(Path root) throws IOException {
        Files.walkFileTree(root, new SimpleFileVisitor<>() {
            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
                Files.delete(file);
                return FileVisitResult.CONTINUE;
            }

            @Override
            public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
                Files.delete(dir);
                return FileVisitResult.CONTINUE;
            }
        });
    }
}

Files.newDirectoryStream(dir, "*.log") takes a glob and, like Files.lines, must be closed; that's the try-with-resources again. Files.move with REPLACE_EXISTING so a second archive run doesn't fail. And deleteTree exists because Files.delete on a folder with anything in it throws DirectoryNotEmptyException. There's no recursive-delete call in Files, so you walk the tree and delete children first.

Run the parcel log end to end

src/main/java/com/example/parcels/Main.java

package com.example.parcels;

import java.nio.file.Path;
import java.time.Instant;
import java.util.List;

public class Main {
    public static void main(String[] args) throws Exception {
        Path dir = Path.of(args.length > 0 ? args[0] : "data");
        ScanLog log = new ScanLog(dir.resolve("scans.log"));

        log.append(new ScanEvent("PK-1001", Instant.parse("2026-10-20T08:15:00Z"), "Rotterdam hub", "RECEIVED"));
        log.append(new ScanEvent("PK-1002", Instant.parse("2026-10-20T09:02:00Z"), "Rotterdam hub", "RECEIVED"));
        log.append(new ScanEvent("PK-1001", Instant.parse("2026-10-21T13:40:00Z"), "Köln depot", "IN_TRANSIT"));
        log.append(new ScanEvent("PK-1001", Instant.parse("2026-10-22T10:05:00Z"), "Zürich van 7", "OUT_FOR_DELIVERY"));

        System.out.println("Log file: " + log.file());
        System.out.println("Events in file: " + log.readAll().size());
        System.out.println("History of PK-1001:");
        log.history("PK-1001").forEach(e ->
                System.out.println("  " + e.at() + "  " + e.status() + "  @ " + e.location()));

        List<ScanEvent> kept = log.readAll().stream()
                .filter(e -> !e.parcelId().equals("PK-1002")).toList();
        log.replaceAll(kept);
        System.out.println("After replaceAll: " + log.readAll().size() + " events");
        System.out.println("Archived: " + Archiver.archiveLogs(dir));
    }
}
javac -encoding UTF-8 --release 21 -d out src/main/java/com/example/parcels/*.java
java -cp out com.example.parcels.Main

My first run, on a Linux box where nobody had set a locale, printed this:

Log file: /home/claude/parcel/data/scans.log
Events in file: 4
History of PK-1001:
  2026-10-20T08:15:00Z  RECEIVED  @ Rotterdam hub
  2026-10-21T13:40:00Z  IN_TRANSIT  @ K?ln depot
  2026-10-22T10:05:00Z  OUT_FOR_DELIVERY  @ Z?rich van 7
After replaceAll: 3 events
Archived: [data/archive/scans.log]

"K?ln". My first thought was that I'd written the file wrongly. I hadn't. cat data/archive/scans.log showed Köln correctly. The problem was the console: with no LANG set, the JVM reported stdout.encoding = ANSI_X3.4-1968 (that's ASCII) and replaced every non-ASCII character on the way out. Files were UTF-8; standard output wasn't.

On JDK 19 and later you can fix it per run with a system property:

java -Dstdout.encoding=UTF-8 -cp out com.example.parcels.Main
Log file: /home/claude/parcel/data/scans.log
Events in file: 4
History of PK-1001:
  2026-10-20T08:15:00Z  RECEIVED  @ Rotterdam hub
  2026-10-21T13:40:00Z  IN_TRANSIT  @ Köln depot
  2026-10-22T10:05:00Z  OUT_FOR_DELIVERY  @ Zürich van 7
After replaceAll: 3 events
Archived: [data/archive/scans.log]

In IntelliJ put that flag under Run → Edit Configurations → Modify options → Add VM options. In Eclipse it's Run → Run Configurations → Arguments → VM arguments. Or set LANG=en_US.UTF-8 in your shell and skip the flag.

Look at the data the run left behind:

find data -type f
cat data/archive/scans.log
data/archive/scans.log
PK-1001	2026-10-20T08:15:00Z	Rotterdam hub	RECEIVED
PK-1001	2026-10-21T13:40:00Z	Köln depot	IN_TRANSIT
PK-1001	2026-10-22T10:05:00Z	Zürich van 7	OUT_FOR_DELIVERY

PK-1002 is gone (that's replaceAll dropping it), the temp file is gone, and the log has moved to archive/. If you see that, the hard part's done.

On Windows, the code is the same. Use ; instead of : in -cp, and expect backslashes in printed paths. I didn't run this on Windows. Antivirus scanners sometimes hold a file open for a moment and make Files.move fail with AccessDeniedException or FileSystemException; if you hit that, retry the move once after a short wait.

Twenty checks that pin the file behaviour

I wanted JUnit here. The machine I built this on could not reach Maven Central, so there was no JUnit jar to download, and I'm not going to show you a pom I never ran. So these are plain checks in a main method. They use a fresh temp folder per run, assert one thing each, and stop on the first failure. Moving them to JUnit 5 is mechanical: every check becomes an @Test and Files.createTempDirectory becomes a @TempDir Path. (A JUnit version of this is on my list; the JUnit 5 how-to will cover @TempDir properly.)

src/test/java/com/example/parcels/Checks.java

package com.example.parcels;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.DirectoryNotEmptyException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.time.Instant;
import java.util.List;
import java.util.stream.Stream;

/** Plain checks: no framework needed. Run with: java -cp out:test-out com.example.parcels.Checks */
public class Checks {

    static int passed = 0;

    static void check(String name, boolean ok) {
        if (!ok) throw new AssertionError("FAILED: " + name);
        passed++;
        System.out.println("ok   " + name);
    }

    static ScanEvent ev(String id, String loc, String status) {
        return new ScanEvent(id, Instant.parse("2026-10-20T08:15:00Z"), loc, status);
    }

    public static void main(String[] args) throws Exception {
        Path dir = Files.createTempDirectory("parcels-check-");
        try {
            appendCreatesFileAndFolder(dir);
            appendKeepsOldLines(dir);
            roundTripKeepsUnicode(dir);
            missingFileMeansEmpty(dir);
            historyFiltersByParcel(dir);
            historyDoesNotMatchPrefix(dir);
            replaceAllLeavesNoTempFiles(dir);
            plainWriteTruncates(dir);
            createNewRefusesExisting(dir);
            readMissingFileThrows(dir);
            latin1FileFailsAsUtf8(dir);
            archiveMovesLogs(dir);
            deleteNeedsEmptyFolder(dir);
            resolveAbsoluteReplaces();
            System.out.println("\n" + passed + " checks passed");
        } finally {
            Archiver.deleteTree(dir);
        }
    }

    static void appendCreatesFileAndFolder(Path dir) throws IOException {
        ScanLog log = new ScanLog(dir.resolve("nested/deeper/a.log"));
        log.append(ev("PK-1", "Rotterdam", "RECEIVED"));
        check("append creates missing folders and file", Files.isRegularFile(log.file()));
    }

    static void appendKeepsOldLines(Path dir) throws IOException {
        ScanLog log = new ScanLog(dir.resolve("b.log"));
        log.append(ev("PK-1", "Rotterdam", "RECEIVED"));
        log.append(ev("PK-1", "Köln", "IN_TRANSIT"));
        check("second append keeps the first line", log.readAll().size() == 2);
    }

    static void roundTripKeepsUnicode(Path dir) throws IOException {
        ScanLog log = new ScanLog(dir.resolve("c.log"));
        log.append(ev("PK-2", "Zürich van 7", "OUT_FOR_DELIVERY"));
        check("umlauts survive a write/read round trip", log.readAll().get(0).location().equals("Zürich van 7"));
    }

    static void missingFileMeansEmpty(Path dir) throws IOException {
        check("readAll on a missing file is empty", new ScanLog(dir.resolve("nope.log")).readAll().isEmpty());
    }

    static void historyFiltersByParcel(Path dir) throws IOException {
        ScanLog log = new ScanLog(dir.resolve("d.log"));
        log.append(ev("PK-1", "A", "RECEIVED"));
        log.append(ev("PK-2", "B", "RECEIVED"));
        log.append(ev("PK-1", "C", "IN_TRANSIT"));
        check("history returns only that parcel", log.history("PK-1").size() == 2);
    }

    static void historyDoesNotMatchPrefix(Path dir) throws IOException {
        ScanLog log = new ScanLog(dir.resolve("e.log"));
        log.append(ev("PK-10", "A", "RECEIVED"));
        check("PK-1 does not match PK-10", log.history("PK-1").isEmpty());
    }

    static void replaceAllLeavesNoTempFiles(Path dir) throws IOException {
        Path sub = Files.createDirectory(dir.resolve("f"));
        ScanLog log = new ScanLog(sub.resolve("f.log"));
        log.append(ev("PK-1", "A", "RECEIVED"));
        log.replaceAll(List.of(ev("PK-9", "Z", "DELIVERED")));
        try (Stream<Path> s = Files.list(sub)) {
            check("replaceAll leaves exactly one file", s.count() == 1);
        }
        check("replaceAll swapped the content", log.readAll().get(0).parcelId().equals("PK-9"));
    }

    static void plainWriteTruncates(Path dir) throws IOException {
        Path p = dir.resolve("g.txt");
        Files.writeString(p, "one\ntwo\n");
        Files.writeString(p, "three\n");           // no options: CREATE, TRUNCATE_EXISTING, WRITE
        check("writeString without APPEND truncates", Files.readString(p).equals("three\n"));
    }

    static void createNewRefusesExisting(Path dir) throws IOException {
        Path p = dir.resolve("h.txt");
        Files.writeString(p, "x");
        try {
            Files.writeString(p, "y", java.nio.file.StandardOpenOption.CREATE_NEW);
            check("CREATE_NEW refuses an existing file", false);
        } catch (java.nio.file.FileAlreadyExistsException e) {
            check("CREATE_NEW refuses an existing file", true);
        }
    }

    static void readMissingFileThrows(Path dir) throws IOException {
        try {
            Files.readString(dir.resolve("missing.txt"));
            check("readString on a missing file throws", false);
        } catch (NoSuchFileException e) {
            check("readString on a missing file throws NoSuchFileException", true);
        }
    }

    static void latin1FileFailsAsUtf8(Path dir) throws IOException {
        Path p = dir.resolve("latin1.txt");
        Files.write(p, "Köln".getBytes(StandardCharsets.ISO_8859_1));
        try {
            Files.readString(p);
            check("Latin-1 bytes fail strict UTF-8 read", false);
        } catch (java.nio.charset.MalformedInputException e) {
            check("Latin-1 bytes fail strict UTF-8 read", true);
        }
        check("same bytes read fine as ISO-8859-1", Files.readString(p, StandardCharsets.ISO_8859_1).equals("Köln"));
    }

    static void archiveMovesLogs(Path dir) throws IOException {
        Path sub = Files.createDirectory(dir.resolve("arch"));
        Files.writeString(sub.resolve("x.log"), "1");
        Files.writeString(sub.resolve("y.log"), "2");
        Files.writeString(sub.resolve("keep.txt"), "3");
        List<Path> moved = Archiver.archiveLogs(sub);
        check("archive moved two logs", moved.size() == 2);
        check("archive left the .txt alone", Files.exists(sub.resolve("keep.txt")));
        check("archived logs are gone from the source", Files.notExists(sub.resolve("x.log")));
    }

    static void deleteNeedsEmptyFolder(Path dir) throws IOException {
        Path sub = Files.createDirectory(dir.resolve("full"));
        Files.writeString(sub.resolve("a"), "1");
        try {
            Files.delete(sub);
            check("Files.delete refuses a non-empty folder", false);
        } catch (DirectoryNotEmptyException e) {
            check("Files.delete refuses a non-empty folder", true);
        }
        Archiver.deleteTree(sub);
        check("deleteTree removes it", Files.notExists(sub));
    }

    static void resolveAbsoluteReplaces() {
        Path base = Path.of("/data/parcels");
        check("resolve with an absolute argument discards the base",
                base.resolve("/etc/passwd").equals(Path.of("/etc/passwd")));
        check("normalize collapses ..", base.resolve("../x").normalize().equals(Path.of("/data/x")));
    }
}
mkdir -p test-out
javac -encoding UTF-8 --release 21 -cp out -d test-out src/test/java/com/example/parcels/Checks.java
java -cp out:test-out com.example.parcels.Checks
ok   append creates missing folders and file
ok   second append keeps the first line
ok   umlauts survive a write/read round trip
ok   readAll on a missing file is empty
ok   history returns only that parcel
ok   PK-1 does not match PK-10
ok   replaceAll leaves exactly one file
ok   replaceAll swapped the content
ok   writeString without APPEND truncates
ok   CREATE_NEW refuses an existing file
ok   readString on a missing file throws NoSuchFileException
ok   Latin-1 bytes fail strict UTF-8 read
ok   same bytes read fine as ISO-8859-1
ok   archive moved two logs
ok   archive left the .txt alone
ok   archived logs are gone from the source
ok   Files.delete refuses a non-empty folder
ok   deleteTree removes it
ok   resolve with an absolute argument discards the base
ok   normalize collapses ..

20 checks passed

Twenty checks, twenty passes. The finally block deletes the temp folder, so repeat runs leave nothing behind (I checked /tmp afterwards: no parcels-check-* folders).

What Files and Path do for you

Path is a value. Path.of("data/scans.log") doesn't check that anything exists, and two paths with the same text are equal. That's why ScanLog calls toAbsolutePath().normalize() once, in the constructor: after that the object means the same file wherever the working directory moves.

Files is a bag of static methods that each do one job and then throw a specific IOException subclass when it fails: NoSuchFileException, FileAlreadyExistsException, DirectoryNotEmptyException, AtomicMoveNotSupportedException. The old java.io.File mostly returned false and left you guessing. Catch the subclass you can do something about; let the rest travel up.

The open options (CREATE, APPEND, CREATE_NEW, TRUNCATE_EXISTING) are where behaviour hides. CREATE_NEW is the "fail if it already exists" switch, which is what you want for a lock file or an export that must never overwrite.

If you want the performance and buffer side (channels, memory-mapped files, why readAllLines is slow on huge input), that's the NIO channels article. For what "try-with-resources" does with suppressed exceptions when close() also fails, see the try-with-resources how-to.

Snags I hit, and snags I forced

The first one happened by accident. The rest I caused deliberately, to copy the exact messages.

What you see What it means Fix
K?ln or Z?rich printed, file looks fine in an editor Console encoding is ASCII (stdout.encoding = ANSI_X3.4-1968). The data is fine. java -Dstdout.encoding=UTF-8 ... (JDK 19+), or set LANG=en_US.UTF-8
java.nio.file.NoSuchFileException: <tmp>/no/such/dir/a.txt on a write The folder doesn't exist, not the file Files.createDirectories(file.getParent()) first
java.nio.file.NoSuchFileException: <tmp>/nope.txt on a read The file isn't there; check the working directory (Path.of("data").toAbsolutePath()) Check the path; return an empty result if absence is normal
java.nio.charset.MalformedInputException: Input length = 1 File isn't UTF-8 (mine was Latin-1 bytes) and you read it as UTF-8 Read with the right charset, e.g. StandardCharsets.ISO_8859_1, and re-save as UTF-8
java.io.UncheckedIOException caused by MalformedInputException Same problem, but thrown from inside a Files.lines stream Catch UncheckedIOException and unwrap getCause(), as history does
java.nio.file.FileAlreadyExistsException: <tmp>/x.log CREATE_NEW, or Files.move without REPLACE_EXISTING, hit an existing file Choose the option on purpose; for a move, add REPLACE_EXISTING
java.nio.file.DirectoryNotEmptyException: <tmp>/sub Files.delete on a non-empty folder Walk the tree and delete children first (Archiver.deleteTree)
AtomicMoveNotSupportedException ... Invalid cross-device link Source and target are on different file systems Put the temp file next to the target
Log only ever has one line writeString or write without APPEND truncates Add StandardOpenOption.CREATE, StandardOpenOption.APPEND
Too many open files after hours Unclosed Files.lines, Files.list, Files.walk or newDirectoryStream try (Stream<...> s = ...) every time
records are not supported in -source 8 You're compiling ScanEvent with a pre-16 --release Use JDK 16+, or turn the record into a class

The one that cost me the most time was the first. I assumed Files.writeString had mangled the umlauts, and re-ran the write three times before I opened the file in a separate program.

Before this touches real data

  • Never resolve a user-supplied name onto a base folder without checking. base.resolve("../../etc/passwd") and base.resolve("/etc/passwd") both leave your folder. Resolve, then normalize(), then verify the result startsWith(base).
  • Don't write secrets to a world-readable file. On POSIX file systems you can create the file with PosixFilePermissions; on Windows, rely on the folder's ACLs.
  • Append from several processes isn't safe by default. APPEND is atomic for small writes on most POSIX systems, but that's the OS being kind, not a Java guarantee. For anything shared, use a real queue or database.
  • replaceAll isn't a backup. Atomic means no half-file. It doesn't mean you can undo the replacement.
  • Test with a temp directory, never the project folder. Files.createTempDirectory plus a cleanup in finally (or JUnit's @TempDir) keeps the suite repeatable.
  • Version notes. Files.readString, writeString and Path.of arrived in Java 11. Files.mismatch is Java 12. On Java 17 and earlier there's no stdout.encoding property, so set LANG to a UTF-8 locale instead. The java PathsDemo.java single-file launch needs Java 11+.

Next reads: the equals/hashCode how-to if you want ScanEvent compared properly, the records how-to for what the record gave us in Step 1, the immutable class how-to, and, for pulling these scans into a database later, the Spring Data articles coming in this series.

Keep these in your head

  • Path names a file; Files acts on it. Neither one is a stream you have to close, but the streams Files hands you are.
  • writeString truncates unless you say APPEND.
  • Name the charset, and don't trust the console to show you what's in the file.
  • Replace a file by writing a sibling temp file and moving it over the original.
  • Every Files.lines, Files.list, Files.walk and newDirectoryStream goes in a try-with-resources.
  • When a delete fails with DirectoryNotEmptyException, you need a tree walk.

Quick answers on reading and writing files

What's the best way to read a file in Java? For a small text file, Files.readString(path) or Files.readAllLines(path). For a big one, Files.lines(path) inside try-with-resources, or a BufferedReader from Files.newBufferedReader. Pick on file size, not habit.

How do I append to a file in Java without overwriting it? Pass StandardOpenOption.CREATE and StandardOpenOption.APPEND to Files.writeString or Files.write. With no options, both calls replace the file's contents.

Why does Java show ? for special characters in the console but not in the file? Standard output has its own encoding, taken from your locale. If it's ASCII, anything else prints as ?. On JDK 19+, run with -Dstdout.encoding=UTF-8, or set LANG to a UTF-8 locale.

Is Files.move with ATOMIC_MOVE really atomic? On the same file system, yes, on the platforms I know of. Across file systems it throws AtomicMoveNotSupportedException. Keep the temp file in the target's folder.

Should I still use java.io.File? For new code, no. Path and Files report why something failed. You'll still meet File in older APIs; file.toPath() and path.toFile() convert between them.

References

how-to-tutorialsjava-iojava-tutorials

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments