Java NIO.2: Working with Files Using Path and Files
A program searches for text reports with Files.walk(dir).filter(p -> p.endsWith(".txt")). It compiles, throws no exceptions, and finds 0 files — even when report.txt is right there:
Path file = Path.of("docs", "report.txt");
System.out.println(file.endsWith(".txt")); // false
System.out.println(file.endsWith("report.txt")); // true
System.out.println(file.toString().endsWith(".txt")); // true Path.endsWith() does not compare characters of a string; it compares whole path elements, and the last element here is report.txt, not .txt. The path and file API has quite a few non-obvious spots like this, and all of them are covered below.
NIO.2 is the API of the java.nio.file package (Java 7) for working with the file system: the Path interface describes the path to a file or directory, and the utility class Files performs operations on it — checks whether it exists, reads and writes content, creates, copies, moves and deletes files, and walks a directory tree. Unlike java.io.File, the methods of Files throw an exception that names the cause (NoSuchFileException, FileAlreadyExistsException) instead of returning false. A task-by-task comparison of File and Files is in the section File vs Path and Files: which to use.
Creating a Path: Path.of and Paths.get
A Path object is created with the static method Path.of() (Java 11) or Paths.get() (Java 7) — both accept the whole path or its parts and return the same result. Creating a Path does not touch the disk: the file may not exist, only the path syntax is checked.
| Method | Example | Comment |
|---|---|---|
Path.of(String first, String... more) | Path.of("data", "reports", "2026.txt") | Recommended way (Java 11+); the parts are joined with the system separator |
Paths.get(String first, String... more) | Paths.get("data/reports/2026.txt") | The same for Java 7–10; in newer versions it calls Path.of() internally |
Path.of(URI uri) | Path.of(URI.create("file:///tmp/a.txt")) | From a URI with the file: scheme; for a scheme with no open file system — FileSystemNotFoundException |
file.toPath() | new File("a.txt").toPath() | Bridge from the old java.io.File; the other way — path.toFile() |
Java understands / in a path string on any OS, and a printed Path uses the system separator: data/reports/2026.txt on Linux and macOS, data\reports\2026.txt on Windows. A path that is invalid for the OS (for example, Path.of("a:b?.txt") on Windows) immediately throws InvalidPathException. A relative path, just like with File, is resolved against the JVM working directory (System.getProperty("user.dir")).
Parts of a path: getFileName, getParent, getRoot
A Path stores the path as a sequence of name elements and gives access to them: getFileName() returns the last element, getParent() everything except the last one, getRoot() the root (/ or C:\), and getNameCount() and getName(i) the number of elements and an element by index. All of them return a Path, not a String.
import java.nio.file.Path;
import java.nio.file.Paths;
public class PathParts {
public static void main(String[] args) {
Path p1 = Path.of("data", "reports", "2026.txt");
Path p2 = Paths.get("data/reports/2026.txt");
System.out.println(p1);
System.out.println("Equal: " + p1.equals(p2));
System.out.println("Name: " + p1.getFileName());
System.out.println("Parent: " + p1.getParent());
System.out.println("Root: " + p1.getRoot());
System.out.println("Name count: " + p1.getNameCount());
System.out.println("First element: " + p1.getName(0));
System.out.println("Subpath 1..3: " + p1.subpath(1, 3));
System.out.println("Absolute: " + p1.isAbsolute());
System.out.println("Parent of a.txt: " + Path.of("a.txt").getParent());
}
} Output (Linux, macOS; on Windows the separator is \):
data/reports/2026.txt
Equal: true
Name: 2026.txt
Parent: data/reports
Root: null
Name count: 3
First element: data
Subpath 1..3: reports/2026.txt
Absolute: false
Parent of a.txt: null A relative path has no root, so getRoot() returns null; for a single-element path getParent() returns null as well. Check the result for null before calling methods on it, or you will get a NullPointerException. An index outside 0..getNameCount()-1 in getName() throws IllegalArgumentException.
resolve, relativize and normalize
These three methods treat paths as text and never touch the disk: resolve() appends a path to a base path, relativize() builds a relative path from one path to another, and normalize() removes the . and .. elements. Use resolve() instead of gluing strings with + "/" +: it inserts the right separator for you.
import java.nio.file.Path;
public class ResolveDemo {
public static void main(String[] args) {
Path base = Path.of("project");
Path main = base.resolve("src/Main.java");
Path docs = Path.of("project/docs/guide.md");
System.out.println(main);
System.out.println(main.resolveSibling("Test.java"));
System.out.println(main.relativize(docs));
System.out.println(Path.of("project/src/../docs/./guide.md").normalize());
System.out.println(Path.of("../a/../../b").normalize());
try {
main.relativize(Path.of("/tmp").toAbsolutePath());
} catch (IllegalArgumentException e) {
System.out.println("relativize: IllegalArgumentException");
}
}
} Output (Linux, macOS; on Windows the separator is \):
project/src/Main.java
project/src/Test.java
../../docs/guide.md
project/docs/guide.md
../../b
relativize: IllegalArgumentException To get from project/src/Main.java to project/docs/guide.md, you go up two levels (../..) and down into docs. normalize() does not remove leading .. elements: there is nowhere to go above the start of a relative path. relativize() works only between two relative or two absolute paths — mixing them throws IllegalArgumentException.
One more resolve() quirk: if the argument is an absolute path, the method simply returns the argument. On Linux, Path.of("project").resolve("/etc/hosts") gives /etc/hosts, not project/etc/hosts. That is why a file name that comes from a user must never be appended to a working directory without a check — how to check it is shown in the FAQ.
toAbsolutePath and toRealPath
toAbsolutePath() simply prepends the working directory to a relative path and does not touch the disk, so it works for missing files too and leaves . and .. as they are. toRealPath() reads the file system: it returns the canonical path without .., with symbolic links resolved, and throws NoSuchFileException if the file does not exist.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
public class RealPathDemo {
public static void main(String[] args) throws IOException {
Files.createDirectories(Path.of("demo-dir"));
Files.writeString(Path.of("notes.txt"), "hello");
Path p = Path.of("demo-dir/../notes.txt");
System.out.println(p.toAbsolutePath());
System.out.println(p.toRealPath());
try {
Path.of("missing.txt").toRealPath();
} catch (NoSuchFileException e) {
System.out.println("toRealPath: NoSuchFileException");
}
System.out.println(Path.of("missing.txt").toAbsolutePath());
}
} Output (Linux, working directory /home/alex/project; on Windows you get, for example, C:\projects\demo\demo-dir\..\notes.txt):
/home/alex/project/demo-dir/../notes.txt
/home/alex/project/notes.txt
toRealPath: NoSuchFileException
/home/alex/project/missing.txt In java.io.File the same roles are played by getAbsolutePath() and getCanonicalPath() (see the FAQ of the File class lesson). If you need an absolute path without .. but the file may not exist yet, use p.toAbsolutePath().normalize().
Comparing paths: equals, startsWith, isSameFile
equals(), startsWith() and endsWith() of Path compare paths as sequences of elements without touching the disk: a/../b.txt and b.txt are different paths to them. The only way to find out whether two paths point to the same file is Files.isSameFile(), which reads the file system.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class PathCompare {
public static void main(String[] args) throws IOException {
Files.createDirectories(Path.of("a"));
Files.writeString(Path.of("b.txt"), "B");
Path p1 = Path.of("a/../b.txt");
Path p2 = Path.of("b.txt");
System.out.println("equals: " + p1.equals(p2));
System.out.println("normalize + equals: " + p1.normalize().equals(p2));
System.out.println("isSameFile: " + Files.isSameFile(p1, p2));
Path src = Path.of("projects/app/Main.java");
System.out.println("Path.startsWith: " + src.startsWith("project"));
System.out.println("String.startsWith: " + src.toString().startsWith("project"));
System.out.println("endsWith(\"Main.java\"): " + src.endsWith("Main.java"));
System.out.println("Case: " + Path.of("B.TXT").equals(Path.of("b.txt")));
}
} Output (Linux, macOS; on Windows the last line is Case: true):
equals: false
normalize + equals: true
isSameFile: true
Path.startsWith: false
String.startsWith: true
endsWith("Main.java"): true
Case: false Path.startsWith("project") returned false because the first path element is projects, not project; the string startsWith cannot see that difference. For the same reason, in the example at the top of the lesson endsWith(".txt") was looking for a path element named .txt. To check a file extension, compare the name as a string: p.getFileName().toString().endsWith(".txt").
Letter case depends on the OS
On Windows, Path.equals() and compareTo() ignore letter case, so B.TXT and b.txt are equal. On Linux and macOS the comparison is byte by byte — the paths are different, even though the macOS file system is case-insensitive by default and will open the same file. If a path is used as a HashMap key or a Set element, the program will behave differently on different operating systems.
Checks: exists, isDirectory, isRegularFile, size
Checks are done by static methods of Files: exists() and notExists() tell whether the file is there, isDirectory() and isRegularFile() whether it is a directory or a regular file, isReadable() and isWritable() report access rights, and size() returns the size in bytes. The checks return a boolean without exceptions, but size() throws NoSuchFileException for a missing file.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
public class ChecksDemo {
public static void main(String[] args) throws IOException {
Path file = Path.of("check-demo.txt");
Files.writeString(file, "caf\u00e9"); // the word "café"
System.out.println("exists: " + Files.exists(file));
System.out.println("isRegularFile: " + Files.isRegularFile(file));
System.out.println("isDirectory: " + Files.isDirectory(file));
System.out.println("size: " + Files.size(file));
Path missing = Path.of("missing.txt");
System.out.println("exists: " + Files.exists(missing));
System.out.println("notExists: " + Files.notExists(missing));
try {
Files.size(missing);
} catch (NoSuchFileException e) {
System.out.println("size: NoSuchFileException " + e.getFile());
}
}
} exists: true
isRegularFile: true
isDirectory: false
size: 5
exists: false
notExists: true
size: NoSuchFileException missing.txt The size is 5 bytes, not 4: Files.writeString() writes UTF-8, where é takes 2 bytes (C3 A9). notExists() is not simply !exists(): if the program has no permission to read the directory, the existence of the file cannot be determined, and both methods return false.
exists() is not a guarantee
The code if (Files.exists(p)) { Files.readString(p); } can still throw NoSuchFileException: another program may delete the file between the check and the read (a TOCTOU bug, time-of-check to time-of-use). It is more reliable to perform the operation right away and handle the exception, and to create files with atomic methods — Files.createFile() or the CREATE_NEW option.
Read and write a file: readString, writeString, readAllLines
To read a whole file in Java, use Files.readString(path) (Java 11) to get all the text as one string, or Files.readAllLines(path) to get a list of lines. To write to a file in Java, use Files.writeString(path, text) or Files.write(path, lines). The file is opened and closed inside the method, the default charset is UTF-8, and an existing file is overwritten.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
public class ReadWriteDemo {
public static void main(String[] args) throws IOException {
Path file = Path.of("notes.txt");
Files.writeString(file, "first line\nsecond line\n");
String text = Files.readString(file);
System.out.print(text);
List<String> lines = Files.readAllLines(file);
System.out.println(lines.size() + " " + lines);
Files.write(file, List.of("alpha", "beta")); // file overwritten
System.out.println(Files.readAllLines(file));
byte[] bytes = Files.readAllBytes(file);
System.out.println("Bytes: " + bytes.length);
}
} Output (Linux, macOS; on Windows the last line is Bytes: 13):
first line
second line
2 [first line, second line]
[alpha, beta]
Bytes: 11 Files.write(path, lines) adds the system line separator after every line: \n on Linux and macOS, \r\n on Windows — hence 11 or 13 bytes for alpha and beta. readAllLines() understands all three line endings (\n, \r\n, \r) and does not include them in the result.
| Method | What it does | Edge case |
|---|---|---|
readString(path) (Java 11) | Whole file into one String, UTF-8 | File larger than 2 GB — OutOfMemoryError; not UTF-8 — MalformedInputException |
readAllLines(path) | All lines into a List<String> | Whole file in memory; empty file — empty list |
readAllBytes(path) | All content into a byte[] — for binary files | No charset applied; a file larger than 2 GB cannot be read |
writeString(path, text, options...) (Java 11) | Writes a string in UTF-8 | Without options creates or overwrites the file; missing parent directory — NoSuchFileException |
write(path, lines, options...) | Writes lines, each followed by the OS line separator | Same default behavior as writeString |
write(path, bytes, options...) | Writes a byte array as is | Pairs with readAllBytes(): copying an image or archive through memory |
StandardOpenOption: append, create, overwrite
The mode a file is opened in is set by constants of the StandardOpenOption enum, passed as the last arguments of writeString(), write() and newBufferedWriter(). With no options, the set CREATE, TRUNCATE_EXISTING, WRITE applies — the file is created or truncated to zero. To append to the end of a file, pass APPEND together with CREATE.
| Option | What it does | If the file is missing / exists |
|---|---|---|
WRITE | Open for writing | Missing — NoSuchFileException (without CREATE); exists — writes from the start over the old bytes |
APPEND | Write to the end of the file | Missing — NoSuchFileException unless CREATE is also given |
CREATE | Create the file if it does not exist | Exists — opens the existing file |
CREATE_NEW | Create a new file atomically | Exists — FileAlreadyExistsException |
TRUNCATE_EXISTING | Truncate an existing file to 0 bytes | Exists — content is removed; combined with APPEND — IllegalArgumentException |
DELETE_ON_CLOSE | Delete the file when the stream is closed | Handy for temporary files |
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import static java.nio.file.StandardOpenOption.APPEND;
import static java.nio.file.StandardOpenOption.CREATE;
public class AppendDemo {
public static void main(String[] args) throws IOException {
Path log = Path.of("app.log");
Files.writeString(log, "start\n"); // create or overwrite
Files.writeString(log, "step 1\n", APPEND);
Files.writeString(log, "step 2\n", CREATE, APPEND);
System.out.print(Files.readString(log));
Path other = Path.of("other.log");
Files.deleteIfExists(other);
try {
Files.writeString(other, "x\n", APPEND); // no CREATE
} catch (NoSuchFileException e) {
System.out.println("NoSuchFileException: " + e.getFile());
}
}
} start
step 1
step 2
NoSuchFileException: other.log As soon as you pass at least one option, the default set no longer applies. So a log that must be created on the first run and appended to on later runs needs the pair CREATE, APPEND, not APPEND alone.
Large files: lines, newBufferedReader, newBufferedWriter
readString() and readAllLines() are not suitable for large files: they load the whole file into memory. Read line by line instead — with Files.lines(path) (Java 8), which returns a lazy Stream<String>, or with Files.newBufferedReader(path). To write a lot of data, use Files.newBufferedWriter(path). All three objects must be closed with try-with-resources.
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Stream;
public class LargeFileDemo {
public static void main(String[] args) throws IOException {
Path csv = Path.of("orders.csv");
Files.write(csv, List.of("id;sum", "1;250", "2;100", "3;400"));
try (Stream<String> lines = Files.lines(csv)) {
int total = lines.skip(1) // skip the header
.mapToInt(line -> Integer.parseInt(line.split(";")[1]))
.sum();
System.out.println("Total: " + total);
}
Path report = Path.of("report.txt");
try (BufferedWriter writer = Files.newBufferedWriter(report)) {
writer.write("Orders: 3");
writer.newLine();
}
try (BufferedReader reader = Files.newBufferedReader(report)) {
System.out.println(reader.readLine());
}
}
} Total: 750
Orders: 3 Files.lines() keeps the file open until the stream is closed — what a forgotten close leads to is shown in the mistakes table of the try-with-resources lesson. The operations on lines (skip, mapToInt, filter) are the regular Stream API. newBufferedReader() and newBufferedWriter() return ordinary BufferedReader and BufferedWriter objects from java.io (their methods are in the section BufferedReader and BufferedWriter), but already set to UTF-8 and without a chain of wrappers. For binary data there are Files.newInputStream() and Files.newOutputStream().
Charset: UTF-8 by default and MalformedInputException
Every text method of Files called without a Charset parameter uses UTF-8 on any OS and in any Java version — unlike FileReader, which used the system default charset up to and including Java 17. If a file was written in a different charset, readString() and readAllLines() do not print garbled text; they throw MalformedInputException.
The example below writes the Russian word «Привет» (“hello”) in windows-1251, a legacy Cyrillic charset still common in files exported from older systems, and then reads it back as UTF-8:
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.MalformedInputException;
import java.nio.file.Files;
import java.nio.file.Path;
public class CharsetDemo {
public static void main(String[] args) throws IOException {
Charset cp1251 = Charset.forName("windows-1251");
Path file = Path.of("cp1251.txt");
Files.writeString(file, "Привет", cp1251);
try {
Files.readString(file); // expects UTF-8
} catch (MalformedInputException e) {
System.out.println("MalformedInputException: " + e.getMessage());
}
System.out.println(Files.readString(file, cp1251));
System.out.println("Bytes: " + Files.size(file));
}
} MalformedInputException: Input length = 1
Привет
Bytes: 6 The message Input length = 1 means that one byte does not form a valid UTF-8 sequence: the letter «П» in windows-1251 is the byte 0xCF, which in UTF-8 starts a two-byte character, and the next byte does not fit. In windows-1251 each of the 6 letters takes exactly one byte. The fix is to pass the real charset of the file as the second argument. With Files.lines() the same error appears later, while the stream is being consumed, and is wrapped in UncheckedIOException. How the default charset changed for java.io classes (JEP 400, Java 18) is described in the section A chain of wrappers: from bytes to lines.
Create, copy, move and delete files
Files and directories are created by Files.createFile(), createDirectory() and createDirectories(), copied by Files.copy(), renamed and moved by Files.move(), and deleted by Files.delete() and deleteIfExists(). On failure every method throws a specific exception: the file already exists, the file is missing, the directory is not empty.
| Method | What it does | Exception / edge case |
|---|---|---|
createFile(path) | Creates an empty file | File exists — FileAlreadyExistsException; no parent — NoSuchFileException |
createDirectory(path) | Creates one directory | Directory exists — FileAlreadyExistsException; no parent — NoSuchFileException |
createDirectories(path) | Creates a directory with all missing parents | Directory already exists — not an error; a regular file in its place — FileAlreadyExistsException |
copy(src, target, options...) | Copies a file | Target exists — FileAlreadyExistsException without REPLACE_EXISTING; a directory is copied empty, without its content |
move(src, target, options...) | Renames or moves a file or directory | Target exists — FileAlreadyExistsException; with ATOMIC_MOVE across different drives — AtomicMoveNotSupportedException |
delete(path) | Deletes a file or an empty directory | No file — NoSuchFileException; directory not empty — DirectoryNotEmptyException |
deleteIfExists(path) | Deletes if it exists | Returns false if there was no file; for a non-empty directory — also DirectoryNotEmptyException |
Copy and move options come from StandardCopyOption: REPLACE_EXISTING replaces an existing file, COPY_ATTRIBUTES copies the last-modified time and permissions, and ATOMIC_MOVE moves the file in a single atomic OS operation (another process sees either the old file or the new one, never half of it).
import java.io.IOException;
import java.nio.file.DirectoryNotEmptyException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
public class CopyMoveDeleteDemo {
public static void main(String[] args) throws IOException {
Path in = Files.createDirectories(Path.of("sandbox/in"));
Path src = in.resolve("a.txt");
Files.writeString(src, "data");
Path backup = Path.of("sandbox/a-backup.txt");
Files.deleteIfExists(backup);
Files.copy(src, backup);
try {
Files.copy(src, backup);
} catch (FileAlreadyExistsException e) {
System.out.println("Already exists: " + e.getFile());
}
Files.copy(src, backup, REPLACE_EXISTING);
Path moved = Files.move(backup, Path.of("sandbox/archive.txt"), REPLACE_EXISTING);
System.out.println("Moved to " + moved);
try {
Files.delete(in);
} catch (DirectoryNotEmptyException e) {
System.out.println("Not empty: " + e.getFile());
}
Path nothing = Path.of("sandbox/nothing.txt");
System.out.println("deleteIfExists: " + Files.deleteIfExists(nothing));
try {
Files.delete(nothing);
} catch (NoSuchFileException e) {
System.out.println("No such file: " + e.getFile());
}
}
} Output (Linux, macOS; on Windows the separator is \):
Already exists: sandbox/a-backup.txt
Moved to sandbox/archive.txt
Not empty: sandbox/in
deleteIfExists: false
No such file: sandbox/nothing.txt To copy a file in Java, one line is enough: Files.copy(src, target, REPLACE_EXISTING) — no manual read-write loop like the one in the lesson Java Byte Streams. Files.copy() also has stream overloads: copy(InputStream, Path) saves a stream to a file, and copy(Path, OutputStream) writes a file to a stream.
Walking a directory: list, walk, find, newDirectoryStream
Four methods return the contents of a directory: Files.list() gives only the direct entries, Files.walk() the whole tree recursively, Files.find() the tree filtered by path and attributes, and Files.newDirectoryStream() the entries of one directory filtered by a glob pattern. The first three return a Stream<Path> that must be closed with try-with-resources.
| Method | Depth | Result | Detail |
|---|---|---|---|
list(dir) | 1 level | Stream<Path> | dir itself is not included; not a directory — NotDirectoryException |
walk(dir), walk(dir, maxDepth) | Whole tree or up to maxDepth | Stream<Path> | The first element is dir itself; a directory always comes before its content |
find(dir, maxDepth, (path, attrs) -> ...) | Up to maxDepth | Stream<Path> | Attributes are already read — no extra Files.isRegularFile() call needed |
newDirectoryStream(dir, "*.java") | 1 level | DirectoryStream<Path> (Iterable) | Glob filter: *, ?, {txt,java}; iterated with a for-each loop |
import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
public class WalkDemo {
public static void main(String[] args) throws IOException {
Path root = Path.of("tree");
Files.createDirectories(root.resolve("sub"));
Files.writeString(root.resolve("a.txt"), "A");
Files.writeString(root.resolve("sub/b.java"), "class B {}");
Files.writeString(root.resolve("sub/c.txt"), "C");
System.out.println("list:");
try (Stream<Path> s = Files.list(root)) {
s.sorted().forEach(System.out::println); // list() order is not guaranteed
}
System.out.println("walk:");
try (Stream<Path> s = Files.walk(root)) {
s.sorted().forEach(System.out::println);
}
System.out.println("find *.txt:");
try (Stream<Path> s = Files.find(root, Integer.MAX_VALUE,
(p, attrs) -> attrs.isRegularFile() && p.getFileName().toString().endsWith(".txt"))) {
s.sorted().forEach(System.out::println);
}
try (Stream<Path> s = Files.walk(root)) {
System.out.println("endsWith(\".txt\"): " + s.filter(p -> p.endsWith(".txt")).count());
}
List<String> names = new ArrayList<>();
try (DirectoryStream<Path> ds = Files.newDirectoryStream(root.resolve("sub"), "*.{java,kt}")) {
for (Path p : ds) {
names.add(p.getFileName().toString());
}
}
System.out.println("glob: " + names);
}
} Output (Linux, macOS; on Windows the separator is \):
list:
tree/a.txt
tree/sub
walk:
tree
tree/a.txt
tree/sub
tree/sub/b.java
tree/sub/c.txt
find *.txt:
tree/a.txt
tree/sub/c.txt
endsWith(".txt"): 0
glob: [b.java] The line endsWith(".txt"): 0 is the bug from the beginning of the lesson: find with a string check of the file name found two files, while the filter with Path.endsWith() found none. If you only need to list one directory and mark each entry as a file or a directory, Files.list() plus Files.isDirectory(p) is enough. By default walk() and find() do not follow symbolic links; to make them follow, pass FileVisitOption.FOLLOW_LINKS. Watching a directory for changes (WatchService), channels and asynchronous I/O are separate NIO topics and are not covered in this lesson.
Deleting a directory recursively
Files has no ready-made “delete a directory with all its content” method: Files.delete() removes only an empty directory. The standard approach is to get the whole tree with Files.walk(), sort the paths in reverse order so that nested entries come before their directories, and delete them one by one.
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.stream.Stream;
public class DeleteTreeDemo {
static void deleteTree(Path root, Comparator<Path> order) throws IOException {
try (Stream<Path> s = Files.walk(root)) {
s.sorted(order).forEach(p -> {
try {
Files.delete(p);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
}
}
static void createTree(Path root) throws IOException {
Files.createDirectories(root.resolve("sub/deep"));
Files.writeString(root.resolve("a.txt"), "A");
Files.writeString(root.resolve("sub/deep/b.txt"), "B");
}
public static void main(String[] args) throws IOException {
Path root = Path.of("trash");
createTree(root);
try {
deleteTree(root, Comparator.naturalOrder()); // directory before its content
} catch (UncheckedIOException e) {
System.out.println("Error: " + e.getCause().getClass().getSimpleName());
}
System.out.println("Directory exists: " + Files.exists(root));
deleteTree(root, Comparator.reverseOrder()); // content before the directory
System.out.println("Directory exists: " + Files.exists(root));
}
} Error: DirectoryNotEmptyException
Directory exists: true
Directory exists: false In natural order trash itself comes first, and the very first delete fails. Comparator.reverseOrder() puts trash/sub/deep/b.txt before trash/sub/deep, and that before trash/sub, so every directory is already empty when its turn comes. The lambda in forEach cannot throw the checked IOException, so it is wrapped in UncheckedIOException. For very large trees, where sorting in memory is undesirable, use Files.walkFileTree() with a SimpleFileVisitor: the directory is deleted in postVisitDirectory(), after all its files.
Attributes and temporary files
The last-modified time is returned by Files.getLastModifiedTime(path) as a FileTime, and all basic attributes in a single disk access by Files.readAttributes(path, BasicFileAttributes.class): size, creation and modification time, type. A temporary file is created by Files.createTempFile(prefix, suffix) in the system directory java.io.tmpdir with a unique name.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
public class AttributesDemo {
public static void main(String[] args) throws IOException {
Path tmp = Files.createTempFile("report-", ".txt");
try {
Files.writeString(tmp, "12345");
String name = tmp.getFileName().toString();
System.out.println("Name matches pattern: " + (name.startsWith("report-") && name.endsWith(".txt")));
BasicFileAttributes attrs = Files.readAttributes(tmp, BasicFileAttributes.class);
System.out.println("size: " + attrs.size());
System.out.println("isRegularFile: " + attrs.isRegularFile());
System.out.println("isDirectory: " + attrs.isDirectory());
Files.setLastModifiedTime(tmp, FileTime.fromMillis(0));
System.out.println("lastModified: " + Files.getLastModifiedTime(tmp));
} finally {
Files.deleteIfExists(tmp);
}
System.out.println("Deleted: " + Files.notExists(tmp));
}
} Name matches pattern: true
size: 5
isRegularFile: true
isDirectory: false
lastModified: 1970-01-01T00:00:00Z
Deleted: true The full name of a temporary file is random, for example /tmp/report-8371264519023847.txt on Linux or C:\Users\alex\AppData\Local\Temp\report-8371264519023847.txt on Windows, so the program prints only the pattern check. A temporary directory is created by Files.createTempDirectory(prefix). Not every file system stores the creation time (attrs.creationTime()): where it is missing, the last-modified time is returned.
A temp file does not delete itself
“Temporary” refers only to the location and the name: createTempFile() deletes the file neither when the method returns nor when the program exits. Delete it explicitly in finally, as in the example, open it with the DELETE_ON_CLOSE option, or call tmp.toFile().deleteOnExit() — otherwise a server that creates thousands of such files will eventually fill up /tmp.
Where developers go wrong with Path and Files
Most NIO.2 bugs come from three sources: Path methods compare path elements rather than strings, the default open options do not do what you expect, and Stream<Path> objects must be closed. The table shows what happens in each case and how to fix it.
| Mistake | What happens | How to fix |
|---|---|---|
path.endsWith(".txt") to check the extension | Always false: the whole last path element is compared | path.getFileName().toString().endsWith(".txt") |
Path.of(dir + "/" + name) | Extra or doubled separators, the path is built by hand | dir.resolve(name) |
base.resolve(userInput) without a check | An absolute path or ../.. in the input escapes base | normalize() and a startsWith(base) check, see the FAQ |
Files.writeString(p, s, APPEND) for a new file | NoSuchFileException: without CREATE the file is not created | Files.writeString(p, s, CREATE, APPEND) |
Files.readAllLines() for a multi-gigabyte log | OutOfMemoryError: the whole file is loaded into memory | Files.lines() or newBufferedReader() in try-with-resources |
Files.readString() for a windows-1251 file | MalformedInputException: Input length = 1 | Pass the charset: Files.readString(p, Charset.forName("windows-1251")) |
Files.copy(dir, target) for a directory with files | An empty directory is created, the content is not copied | Walk the tree with Files.walk() and copy each file |
walk(root).sorted().forEach(...) with Files.delete() | DirectoryNotEmptyException on the very first directory | sorted(Comparator.reverseOrder()) |
Files.list(dir) or Files.walk(dir) without try-with-resources | The directory handle stays open until garbage collection | try (Stream<Path> s = Files.list(dir)) { ... } |
p1.equals(p2) to check “same file” | a/../b.txt and b.txt are not equal; letter case is treated differently on Windows and Linux | Files.isSameFile(p1, p2) or comparing toRealPath() |
Frequently Asked Questions
Should I use Path.of() or Paths.get()?
In Java 11 and later, use Path.of(). Both methods return the same result (Paths.get() calls Path.of() internally), but the documentation of the Paths class states that it may be deprecated in a future release. Paths.get() is needed only in Java 7–10 projects and is what you will see in older code and forum answers.
How do I stop a user from escaping a directory through a file name (path traversal)?
Resolve the input against the base directory, normalize it and check the prefix: Path base = Path.of("uploads").toAbsolutePath().normalize(); Path target = base.resolve(name).normalize(); if (!target.startsWith(base)) throw new SecurityException();. For name = "../../etc/passwd" or an absolute path the check fails. startsWith() compares path elements here, so a directory named uploads2 is not mistaken for uploads. Why resolve() lets absolute paths through is explained in the section resolve, relativize and normalize.
How do I get the size of a directory in Java?
Files.size(dir) for a directory returns the size of the directory entry itself (for example, 4096 bytes on Linux), not the sum of its files. The size of the content is calculated by walking the tree: Files.walk(dir) filtered to regular files and summing their sizes, for example with mapToLong(p -> p.toFile().length()). Close the walk() stream with try-with-resources.
Why does Path.of(getClass().getResource("/data.txt").toURI()) fail after packaging into a jar?
Inside a jar, a resource has a URI with the jar: scheme, and by default no file system is open for it — Path.of(uri) throws FileSystemNotFoundException. In the IDE the resource is a regular file, so the error shows up only after packaging. Read classpath resources as a stream: getClass().getResourceAsStream("/data.txt"), and get the text with new String(in.readAllBytes(), StandardCharsets.UTF_8).
What is the difference between Files.walk() and Files.walkFileTree()?
Files.walk() (Java 8) returns a lazy Stream<Path> and is convenient for searching and filtering, but if access to any subdirectory fails, the whole walk stops with UncheckedIOException. Files.walkFileTree() (Java 7) calls FileVisitor methods before and after each directory and for each file: you can skip an inaccessible directory (visitFileFailed), stop the walk, or act after a directory’s content has been processed — for example, delete it.
Practice
Reinforce this lesson with hands-on tasks, each with a level and a time estimate.
All module tasks →
Comments