Command Palette

Search for a command to run...

PHASE 12Intermediate Java 7+ ~32 min· topic 3 of 5

Topic 12.3

Path and Files (NIO.2)

In one line

Path is an object that names a file or folder, and Files is a class of static methods that act on the file system: read, write, copy, move, delete, list and inspect. Together (NIO.2, Java 7, with Files.readString/writeString and Path.of added in Java 11) they replace the old java.io.File for all new code.

Think of it like this

A home address written on an envelope. The address (Path) is just text that says where a house is: writing it down doesn't build the house, and the house might not exist. The postal service (Files) is what actually goes there to deliver, collect or check. In Java, creating a Path never touches the disk; only Files methods do.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Path
An object holding the name of a file or folder, like reports/2024/june.csv. It is only a name; the file may not exist.
Absolute path
A path that starts from the top of the file system, like /home/asha/notes.txt or C:\Users\asha\notes.txt.
Relative path
A path with no root, like data/orders.csv. Java resolves it against the working directory.
Working directory
The folder the program was started from. Path.of("").toAbsolutePath() shows it.
NIO.2
The java.nio.file package added in Java 7: Path, Files and related classes. NIO stands for "New I/O".
Open option
A flag that says how to open a file, such as APPEND or CREATE_NEW.
Atomic move
A rename that happens in one indivisible step, so nobody can ever see the file half-moved or half-written.
TOCTOU
"Time of check to time of use": a bug where something changes between checking a condition and acting on it.
Path traversal
An attack where input like ../../secret.txt makes a program reach files outside the folder it meant to use.

Step by step

01A Path is just a name

Path.of("projects", "shop", "src", "Main.java") joins the parts with the platform separator (/ on Linux and macOS, \ on Windows). Nothing is checked on disk: you can make a Path for a file that doesn't exist yet, which is exactly what you need before creating it.

A Path is immutable. Methods like resolve and normalize return new paths. Convert between old and new with file.toPath() and path.toFile() when a legacy API needs a File.

Main.javawhole filejava
Path p = Path.of("projects", "shop", "src", "Main.java");
p.getFileName();                 // Main.java
p.getParent();                   // projects/shop/src
p.getNameCount();                // 4
Path.of("projects/shop").resolve("docs/readme.md");   // projects/shop/docs/readme.md
Path.of("a/./b/../c").normalize();                     // a/c

02Path methods versus String methods

Path comparisons work on name elements, not characters. Path.of("projects/shopping").startsWith(Path.of("projects/shop")) is false, because shopping and shop are different names, while the String version is true. That difference is what makes startsWith safe for security checks.

normalize() is purely textual. It removes a/.. without checking whether a is a symbolic link. For the real location on disk, use toRealPath(), which resolves links and throws NoSuchFileException if the file doesn't exist.

03Reading and writing small files

Java 11's writeString and readString are one-liners that use UTF-8 by default. readString is strict: if the file isn't valid UTF-8 it throws MalformedInputException rather than guessing.

Passing StandardOpenOption.APPEND adds to the end. Without options, writeString replaces the whole file.

Notes.javawhole filejava
Path notes = Path.of("notes.txt");
Files.writeString(notes, "buy milk\n");                               // create or replace
Files.writeString(notes, "call Ravi\n", StandardOpenOption.APPEND);   // add to the end
String all = Files.readString(notes);                                 // whole file, UTF-8
List<String> lines = Files.readAllLines(notes);                       // [buy milk, call Ravi]
long size = Files.size(notes);                                        // 19 bytes
terminal
$ java Notes.java
cat notes.txt
── expected output ──
buy milk
call Ravi

04Streaming big files and directories

Files.lines, Files.list and Files.walk return streams that hold an open file or directory handle. A stream doesn't close itself when the terminal operation finishes; you must close it. Forget, and a server that does this per request eventually fails with Too many open files.

Files.walk(root) visits every file and folder under root, depth-first, without following symbolic links by default. Files.find adds a filter that can see file attributes cheaply.

Scan.javawhole filejava
try (Stream<String> lines = Files.lines(Path.of("access.log"))) {
    long errors = lines.filter(l -> l.contains(" 500 ")).count();
}

try (Stream<Path> files = Files.walk(Path.of("src"))) {
    List<Path> javaFiles = files.filter(f -> f.toString().endsWith(".java")).toList();
}

05Create, copy, move, delete

Files.createDirectories(dir) creates every missing parent and doesn't complain if the folder exists; createDirectory creates exactly one and throws FileAlreadyExistsException if it exists.

copy and move fail with FileAlreadyExistsException if the target exists, unless you pass REPLACE_EXISTING. delete throws NoSuchFileException for a missing file and DirectoryNotEmptyException for a folder with content; to delete a tree, walk it in reverse order (deepest first).

Organise.javawhole filejava
Path dir = Files.createDirectories(Path.of("archive", "2024"));
Files.copy(Path.of("report.csv"), dir.resolve("report.csv"), StandardCopyOption.REPLACE_EXISTING);
Files.move(Path.of("draft.txt"), dir.resolve("final.txt"));      // rename / move
boolean removed = Files.deleteIfExists(Path.of("old.tmp"));      // false if it wasn't there
Create, copy, move, deletediagram
Rendering diagram…

06Atomic writes: never leave half a file

If a program crashes in the middle of Files.writeString(config, ...), the target can be left empty or cut off, because the default options truncate it first. Readers may also see a half-written file at any moment.

The fix: write to a temporary file in the same folder (so it's on the same file system), then Files.move(tmp, target, ATOMIC_MOVE, REPLACE_EXISTING). The rename is a single file-system operation: readers see either the complete old file or the complete new one. If the file system can't do it atomically, Java throws AtomicMoveNotSupportedException instead of quietly copying.

SafeSave.javawhole filejava
static void saveAtomically(Path target, String content) throws IOException {
    Path tmp = Files.createTempFile(target.getParent(), "save-", ".tmp");
    try {
        Files.writeString(tmp, content);
        Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE,
                                StandardCopyOption.REPLACE_EXISTING);
    } finally {
        Files.deleteIfExists(tmp);        // no-op if the move succeeded
    }
}
Atomic writes: never leave half a filediagram
Rendering diagram…

07Defend against path traversal

A download endpoint that does base.resolve(request.getParameter("name")) can be asked for ../../etc/passwd or an absolute path; resolve with an absolute argument simply returns that argument. Never trust a file name from a user.

Defence: resolve against an absolute, normalized base, normalize the result, and check startsWith(base) (the Path version, which compares whole names). Reject anything else. For extra safety also reject symbolic links (toRealPath() and compare again) and allow-list the file names or extensions you expect. The runnable example below shows the check.

Try it yourself

  1. 1

    Predict normalize and relativize

    In "Path anatomy", add show(Path.of("a/b").relativize(Path.of("a/b/c/d"))) and show(Path.of("../x/./y/..").normalize()). Predict both results before you run. (A leading .. can't be removed, because there's no name before it.)

  2. 2

    Attack your own guard

    In "Block path traversal", add the inputs "uploads/../uploads/ok.txt", "./././x" and "..". Predict ALLOW or REJECT for each, then run it.

  3. 3

    Run the real-files example locally

    Save "Everyday Files operations" as Main.java, run java Main.java twice, and compare the output. Then delete the REPLACE_EXISTING option and run it again: which exception appears, and why only on the second run?

Code & diagrams

Path anatomy, no disk needed Java 11+ New tab

None of these files exist. Path methods work on names only; Files methods touch the disk.

Sign in to run this example in your browser.

Expected output

path:        projects/shop/src/Main.java
file name:   Main.java
parent:      projects/shop/src
root:        null
name count:  4
name(1):     shop
subpath:     shop/src
absolute?    false
resolve:     projects/shop/docs/readme.md
sibling:     projects/blog
normalize:   projects/shop/docs/readme.md
relativize:  ../blog/post.md
Path startsWith:   false
String startsWith: true
endsWith:    true
Block path traversal Java 11+ New tab

resolve() with an absolute path returns that path unchanged, so the startsWith check is what stops "/etc/passwd". This is a defence; also allow-list expected names in real code.

Sign in to run this example in your browser.

Expected output

ALLOW  cat.png  ->  uploads/cat.png
ALLOW  2024/report.pdf  ->  uploads/2024/report.pdf
ALLOW  a/../dog.png  ->  uploads/dog.png
REJECT ../secrets.txt
REJECT a/../../etc/passwd
REJECT /etc/passwd
Strict decoding: why readString can throw New tab

new String(...) silently replaces bad bytes with U+FFFD. Files.readString and Files.readAllLines decode strictly, like the second case, and throw MalformedInputException.

Sign in to run this example in your browser.

Expected output

new String: length 4, last char U+FFFD
strict decoder: java.nio.charset.MalformedInputException: Input length = 1
as ISO-8859-1: length 4, last char U+E9
Everyday Files operations (run on your machine) Java 11+java

Run with java Main.java. Output on Linux or macOS: exists: true, size: 36 / lines: [INFO start, ERROR disk full, INFO stop] / errors: 1 / entry: app.log / entry: app.log.bak / already exists: demo/logs/app.log / deleted backup: true. On Windows the path prints with backslashes.

import java.io.IOException;
import java.nio.file.*;
import java.util.List;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) throws IOException {
        Path dir = Files.createDirectories(Path.of("demo", "logs"));
        Path log = dir.resolve("app.log");

        Files.writeString(log, "INFO start\nERROR disk full\n");
        Files.writeString(log, "INFO stop\n", StandardOpenOption.APPEND);
        System.out.println("exists: " + Files.exists(log) + ", size: " + Files.size(log));

        List<String> lines = Files.readAllLines(log);
        System.out.println("lines: " + lines);

        try (Stream<String> s = Files.lines(log)) {
            System.out.println("errors: " + s.filter(l -> l.startsWith("ERROR")).count());
        }

        Path copy = Files.copy(log, dir.resolve("app.log.bak"), StandardCopyOption.REPLACE_EXISTING);
        try (Stream<Path> entries = Files.list(dir)) {
            entries.map(Path::getFileName).sorted().forEach(n -> System.out.println("entry: " + n));
        }
        try {
            Files.createFile(log);                       // CREATE_NEW semantics
        } catch (FileAlreadyExistsException e) {
            System.out.println("already exists: " + e.getFile());
        }
        System.out.println("deleted backup: " + Files.deleteIfExists(copy));
    }
}
Old java.io.File versus Path (for reading legacy code)java
// Legacy: errors are just 'false', no reason given
File f = new File("data/out.txt");
boolean made = f.getParentFile().mkdirs();
boolean gone = f.delete();                   // false: missing? locked? no permission?

// NIO.2: the exception tells you what happened
Path p = Path.of("data/out.txt");
Files.createDirectories(p.getParent());
Files.delete(p);                             // NoSuchFileException: data/out.txt

// Converting between them
Path fromFile = f.toPath();
File fromPath = p.toFile();

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Read a file that isn't there

Call Files.readString(Path.of("notes.txt")) in a folder with no notes.txt.

terminal
$ java Main.java
── what you'll see ──
Exception in thread "main" java.nio.file.NoSuchFileException: notes.txt

Break #2

Forget to close Files.lines

In a request handler, write long n = Files.lines(path).count(); without try-with-resources, and call it thousands of times.

terminal
$ java Server.java
── what you'll see ──
java.nio.file.FileSystemException: /data/orders.csv: Too many open files

Break #3

Read a non-UTF-8 file with readString

Read a file saved in ISO-8859-1 (or Windows-1252) that contains café with Files.readString(path).

terminal
$ java Main.java
── what you'll see ──
Exception in thread "main" java.nio.charset.MalformedInputException: Input length = 1

Myth vs fact

Myth

Path.of("x.txt") creates or opens the file.

Fact

It only builds a name. Nothing touches the disk until you call a Files method.

Myth

Check Files.exists first, then create or delete.

Fact

Another process can change the file between the check and the action. Let the operation decide (CREATE_NEW, createFile, deleteIfExists) and handle the specific exception.

Myth

Files.notExists(p) is the same as !Files.exists(p).

Fact

Both return false when Java can't tell (for example, no permission to look). The file system has three answers: exists, doesn't exist, unknown.

Myth

normalize() makes a path safe.

Fact

It only removes . and name/.. textually. Safety comes from checking the normalized result startsWith an allowed base, and from not following unexpected symbolic links.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Path comes from a FileSystem (FileSystems.getDefault()), and other providers exist: the ZIP file system lets you open a .jar or .zip with FileSystems.newFileSystem(zipPath) and use Files.copy, Files.walk and the rest inside it. Test libraries such as Google's Jimfs provide an in-memory file system, so file code can be unit-tested without touching the disk.

  • ▸

    Files.readAllBytes and readString size the array from the file's length up front. They throw OutOfMemoryError: Required array size too large for files over about 2 GB, because Java arrays are indexed by int. Use streaming or FileChannel for huge files; FileChannel.map memory-maps a file so the OS pages it in on demand.

  • ▸

    Files.move with ATOMIC_MOVE maps to rename(2) on Linux and MoveFileEx on Windows. Atomicity only holds within one file system, which is why the temp file must be in the target's folder. For durability after a crash, you'd also force the temp file's channel before the move and sync the directory, which databases do and most apps skip.

  • ▸

    WatchService (Java 7) lets you register a directory and receive ENTRY_CREATE/ENTRY_MODIFY/ENTRY_DELETE events. It uses inotify on Linux, but on macOS the JDK falls back to polling, so events can arrive seconds late. File attributes are read in bulk with Files.readAttributes(p, BasicFileAttributes.class), which is one system call instead of several.

Remember this

  1. 1

    Before Java 7 there was only java.io.File, which mixed both jobs and had real problems: methods like delete() and mkdir() returned false without saying why, it had no support for symbolic links or file attributes, and listing a large directory loaded everything at once. NIO.2 (JSR 203, Java 7) added java.nio.file: Path, Paths, Files, FileSystem. Its methods throw specific exceptions such as NoSuchFileException, FileAlreadyExistsException, AccessDeniedException and DirectoryNotEmptyException, so you learn exactly what went wrong.

  2. 2

    You create a Path with Path.of("data", "orders.csv") (Java 11; in Java 7 and 8 use Paths.get(...), which does the same). A path has a root (/ or C:\, or none for a relative path) and a list of name elements. Pure path methods work on the text only: getFileName(), getParent(), resolve("child") (join), resolveSibling, normalize() (remove . and name/..), relativize (the path from one to another), startsWith and toAbsolutePath(). A relative path is resolved against the current working directory, the folder the JVM was started in.

  3. 3

    For small files, read and write in one call: Files.readString(path) and Files.writeString(path, text) (Java 11), Files.readAllLines(path) and Files.write(path, lines) (Java 7), Files.readAllBytes(path). They open, read or write, and close for you, and they use UTF-8 by default. They load the whole file into memory, so don't use them on a 5 GB log.

  4. 4

    For big files, stream: Files.newBufferedReader(path) (Topic 12.2), or Files.lines(path) (Java 8), a lazy Stream<String>. Files.lines keeps the file open until the stream is closed, so it must be used in try-with-resources: try (Stream<String> lines = Files.lines(path)) { ... }. The same is true for Files.list and Files.walk, which stream a directory's entries and a whole folder tree.

  5. 5

    Writes take open options (StandardOpenOption). The default for writeString/write/newBufferedWriter is CREATE, TRUNCATE_EXISTING, WRITE: create the file if needed and replace its content. Use APPEND to add to the end and CREATE_NEW to fail with FileAlreadyExistsException if it exists. Checking Files.exists(p) and then creating is a race: another process can create the file in between (a TOCTOU bug, time-of-check to time-of-use). Let the operation itself check, using CREATE_NEW or Files.createFile.

  6. 6

    Files.copy, Files.move and Files.delete throw if something is in the way unless you pass options like REPLACE_EXISTING; deleteIfExists returns false instead of throwing. Files.move(tmp, target, ATOMIC_MOVE) renames in one step, so readers see the old file or the new one but never half a file: the standard atomic write pattern is "write to a temp file in the same folder, then atomically move it over the target". Paths built from user input must be checked against path traversal (../../etc/passwd): resolve, normalize, then confirm the result still startsWith your base folder.

Explain it without notes

01

What's the difference between Path and Files, and why did NIO.2 replace java.io.File?

02

Which Files methods load a whole file into memory, and which stream it? When would you use each?

03

Why must Files.lines, Files.list and Files.walk be closed, when Files.readAllLines doesn't need to be?

04

How do you write a file so that a crash or a concurrent reader never sees a half-written version?

05

How do you safely serve a file whose name comes from a user?

Practice

01

Write static String extension(Path p) that returns the file extension in lower case ("" if none), using only getFileName() and String methods. Test it with report.PDF, archive.tar.gz, README and .gitignore.

02

Given the base Path.of("site") and the paths site/index.html, site/blog/post.html and site/img/logo.png, print each one relative to the base, with / separators.

03

Write static Path backupName(Path original, int n) that turns docs/plan.txt into docs/plan.txt.1 style siblings for n = 1 and n = 2, using resolveSibling. Print both with / separators.

Trade-offs

  • ↔

    readString/readAllLines are the simplest and fastest for small files but load everything into memory; streaming APIs scale to any size but need careful closing and more code.

  • ↔

    Atomic write-then-rename protects readers and survives crashes, but costs an extra file, needs write permission on the folder, and changes the file's identity (inode), which breaks tools that hold the old file open, like tail -f without -F.

  • ↔

    Relative paths make programs portable between machines but depend on the working directory, which differs between your IDE, a terminal and a service manager. Production code usually derives paths from configuration.

Done when you can

  • Done when you can explain why creating a Path never touches the disk and which Files methods do.

  • Done when you can use resolve, normalize, relativize and startsWith correctly, including the difference from String.startsWith.

  • Done when you choose between whole-file methods and streaming methods based on file size.

  • Done when every Files.lines/list/walk stream is in try-with-resources.

  • Done when you can write a file atomically and block path traversal from user input.