> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# How to Read and Write Files in Java with NIO.2 (Java 21)
- URL: https://www.ggorantala.dev/how-to-read-write-files-nio-java/
- Published: 2026-07-22T05:04:00.000Z
- Updated: 2026-10-31T06:34:03.000Z
- Description: 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.
- Author: Gopi Gorantala
- Tags: how-to-tutorials, java-io, java-tutorials

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

```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

```

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.

```log
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:

```bash
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:

```log
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`

```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)

```java
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"));
    }
}

```

```bash
java PathsDemo.java

```

```log
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:

```java
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`:

```java
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:

```log
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](https://ggorantala.dev/how-to-read-large-files-line-by-line-java/?ref=ggorantala.dev).)

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`

```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:

```log
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.

```bash
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`

```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`

```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));
    }
}

```

```bash
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
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:

```bash
java -Dstdout.encoding=UTF-8 -cp out com.example.parcels.Main

```

```log
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:

```bash
find data -type f
cat data/archive/scans.log

```

```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](https://ggorantala.dev/how-to-write-unit-tests-junit-5-java/?ref=ggorantala.dev) will cover `@TempDir` properly.)

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

```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")));
    }
}

```

```bash
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

```

```log
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](https://ggorantala.dev/how-to-use-java-nio-channels-buffers/?ref=ggorantala.dev). For what "try-with-resources" does with suppressed exceptions when `close()` also fails, see [the try-with-resources how-to](https://ggorantala.dev/how-to-use-try-resources-java/?ref=ggorantala.dev).

## 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](https://ggorantala.dev/how-to-implement-equals-hashcode-java/?ref=ggorantala.dev) if you want `ScanEvent` compared properly, [the records how-to](https://ggorantala.dev/how-to-use-java-records/?ref=ggorantala.dev) for what the record gave us in Step 1, [the immutable class how-to](https://ggorantala.dev/how-to-create-immutable-class-java/?ref=ggorantala.dev), 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

- [Files (Java SE 21 API)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/nio/file/Files.html?ref=ggorantala.dev)
- [Path (Java SE 21 API)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/nio/file/Path.html?ref=ggorantala.dev)
- [StandardOpenOption (Java SE 21 API)](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/nio/file/StandardOpenOption.html?ref=ggorantala.dev)
- [JEP 400: UTF-8 by Default](https://openjdk.org/jeps/400?ref=ggorantala.dev)
- [Reading and writing small files, dev.java](https://dev.java/learn/java-io/reading-writing/small-files/?ref=ggorantala.dev)
- [IntelliJ IDEA 2026.2.2 announcement](https://blog.jetbrains.com/idea/2026/09/intellij-idea-2026-2-2/?ref=ggorantala.dev)
- [Eclipse IDE 2026-06 (4.40) coverage, ADT Magazine](https://adtmag.com/articles/2026/06/15/ide-2026-adds-java-26-support-and-developer-tooling-updates.aspx?p=1&ref=ggorantala.dev)