files and paths
Path, Files, walking trees, and watching directories.
- chapter
- chapter 10.03
- reading time
- 7 min read
a Path is just a name: a root and a list of name elements. making one never touches the disk. Files is the toolbox of static methods that actually read, write, copy, walk and watch. together they replaced java.io.File java 7+, which mixed both jobs up and answered errors with false.
the picture
you know how new File("data.txt") "works" even when there's no such file? that's because it was never a file. it's an address on an envelope. you can write any address you like; whether a house is there is a separate question.
Path makes that split explicit. the path is the address (pure text with structure). Files is the postman (the one who actually walks there, and comes back with an exception if nobody's home).
a path is a root plus a list of name elements, indexed from 0. the root isn't a name, which is why the count is 7. getParent() just drops the last name: it doesn't clean up .., and it doesn't look at the disk.
import java.nio.file.*;
void main() {
Path p = Path.of("does/not/exist.txt"); // fine. it's just a name
IO.println(p.getFileName() + " " + Files.exists(p));
IO.println(p.toAbsolutePath().startsWith(System.getProperty("user.dir")));
}exist.txt false true
this one needs a real JVM (files, the network or a database), so it doesn't run in the browser. copy it into java Main.java to try it.
IO.println(Path.of("a/b").resolve("/c"));the Files toolbox
every method is static, takes Paths, throws a specific IOException subclass when something goes wrong, and defaults to UTF-8 for text.
import java.nio.file.*;
import java.util.List;
void main() throws Exception {
Path dir = Files.createTempDirectory("demo");
Path todo = dir.resolve("todo.txt");
IO.println(Files.exists(todo));
Files.writeString(todo, "buy milk\nship it\n");
Files.writeString(todo, "call mom\n", StandardOpenOption.APPEND);
List<String> lines = Files.readAllLines(todo);
IO.println(lines + " " + Files.size(todo) + " bytes");
Path copy = Files.copy(todo, dir.resolve("todo.bak"));
IO.println(Files.mismatch(todo, copy)); // -1 means identical
try (var files = Files.list(dir)) {
IO.println(files.map(p -> p.getFileName().toString()).sorted().toList());
}
Files.delete(copy); Files.delete(todo); Files.delete(dir);
}false [buy milk, ship it, call mom] 26 bytes -1 [todo.bak, todo.txt]
this one needs a real JVM (files, the network or a database), so it doesn't run in the browser. copy it into java Main.java to try it.
| job | call |
|---|---|
| small text file in / out | readString, writeString, readAllLines |
| big text file, line by line | lines (a lazy Stream, close it) or newBufferedReader |
| bytes | readAllBytes, write, newInputStream, newOutputStream |
| copy / move | copy(a, b, REPLACE_EXISTING), move(a, b, ATOMIC_MOVE) |
| make directories | createDirectory (parent must exist), createDirectories (makes the chain) |
| delete | delete (throws if missing), deleteIfExists |
| ask questions | exists, isDirectory, isReadable, size, getLastModifiedTime |
| compare | mismatch(a, b) java 12+: index of the first different byte, or -1 |
| scratch space | createTempFile, createTempDirectory |
StandardOpenOption is how you say how to open: CREATE, APPEND, TRUNCATE_EXISTING, CREATE_NEW (fail if it's already there, the safe way to claim a file name). the defaults for writeString are CREATE, TRUNCATE_EXISTING, WRITE: it replaces the file.
why not java.io.File?
File came with java 1.0 and has three problems you'll hit: most failures return false instead of telling you why, it can't do symlinks, permissions or atomic moves properly, and a few methods behave differently per OS. Path + Files arrived in java 7 (NIO.2, JSR 203) to fix all of it.
import java.io.File;
import java.nio.file.*;
void main() throws Exception {
Path dir = Files.createTempDirectory("full");
Files.writeString(dir.resolve("a.txt"), "hi");
IO.println("File.delete: " + new File(dir.toString()).delete());
try {
Files.delete(dir);
} catch (Exception e) {
IO.println("Files.delete: " + e.getClass().getSimpleName());
}
try {
Files.delete(dir.resolve("nope.txt"));
} catch (Exception e) {
IO.println("Files.delete: " + e.getClass().getSimpleName());
}
Files.delete(dir.resolve("a.txt"));
Files.delete(dir);
}File.delete: false Files.delete: DirectoryNotEmptyException Files.delete: NoSuchFileException
this one needs a real JVM (catching a runtime exception), so it doesn't run in the browser. copy it into java Main.java to try it.
false, why? you'll never know. Files names the actual problem. when old APIs hand you a File, convert at the edge: file.toPath() and path.toFile() go both ways.
walking a tree
Files.walk(root) gives you a lazy Stream<Path> of everything under root, depth-first, starting with root itself. Files.list(dir) is the one-level version. Files.find is walk plus a filter that also sees each file's attributes.
- site/
- build.gradle
- docs/
- readme.md
- src/
- main/
- App.java
- Util.java
- nothing yet. the stream is lazy: no disk access until you pull.
press step. walk starts at the root itself, then goes depth-first.
maxDepth. a directory is entered the moment it's found, before its siblings: that's depth-first. the current path is the one the stream just handed out.import java.io.IOException;
import java.nio.file.*;
import java.util.Comparator;
import java.util.stream.Stream;
void main() throws IOException {
Path root = Files.createTempDirectory("site");
Files.createDirectories(root.resolve("src/main"));
Files.createDirectories(root.resolve("docs"));
Files.writeString(root.resolve("src/main/App.java"), "class App {}");
Files.writeString(root.resolve("src/main/Util.java"), "class Util {}");
Files.writeString(root.resolve("docs/readme.md"), "# hi");
Files.writeString(root.resolve("build.gradle"), "");
try (Stream<Path> all = Files.walk(root)) {
all.map(root::relativize).map(Path::toString).sorted().forEach(p -> IO.println("[" + p + "]"));
}
try (Stream<Path> java = Files.find(root, 10, (p, attrs) -> p.toString().endsWith(".java"))) {
IO.println("java files: " + java.count());
}
// delete the whole tree: children before parents, so walk in reverse order
try (Stream<Path> all = Files.walk(root)) {
all.sorted(Comparator.reverseOrder()).forEach(p -> {
try { Files.delete(p); } catch (IOException e) { throw new RuntimeException(e); }
});
}
IO.println("deleted: " + !Files.exists(root));
}[] [build.gradle] [docs] [docs/readme.md] [src] [src/main] [src/main/App.java] [src/main/Util.java] java files: 2 deleted: true
this one needs a real JVM (files, the network or a database), so it doesn't run in the browser. copy it into java Main.java to try it.
the empty [] at the top is root relativized against itself. and the reverse-sorted delete is the standard trick for rm -rf: a directory has to be empty before Files.delete accepts it, and reverse lexical order puts every child before its parent.
for more control (skip a subtree, handle errors per file, act on the way out of a directory), use Files.walkFileTree with a FileVisitor. it's the callback version of the same depth-first walk.
watching a directory
a WatchService tells you when entries in a directory are created, modified or deleted, so you can react to a dropped file instead of polling yourself.
hover a stage.
import java.nio.file.*;
import java.util.concurrent.TimeUnit;
import static java.nio.file.StandardWatchEventKinds.*;
void main() throws Exception {
Path dir = Files.createTempDirectory("inbox");
try (WatchService watcher = FileSystems.getDefault().newWatchService()) {
dir.register(watcher, ENTRY_CREATE, ENTRY_DELETE);
Files.createFile(dir.resolve("order-42.json"));
WatchKey key = watcher.poll(10, TimeUnit.SECONDS);
for (WatchEvent<?> e : key.pollEvents()) {
IO.println(e.kind() + " " + e.context());
}
IO.println("still valid: " + key.reset());
}
Files.delete(dir.resolve("order-42.json"));
Files.delete(dir);
}ENTRY_CREATE order-42.json still valid: true
this one needs a real JVM (files, the network or a database), so it doesn't run in the browser. copy it into java Main.java to try it.
a zip file is a filesystem too+
Path isn't tied to your disk. it belongs to a FileSystem, and the JDK ships a provider that treats a zip (or jar) as one. same Files calls, different world:
import java.nio.file.*;
import java.util.Map;
void main() throws Exception {
Path zip = Files.createTempDirectory("z").resolve("notes.zip");
try (FileSystem fs = FileSystems.newFileSystem(zip, Map.of("create", "true"))) {
Path inside = fs.getPath("/2026/october.txt");
Files.createDirectories(inside.getParent());
Files.writeString(inside, "ship the io chapters");
}
try (FileSystem fs = FileSystems.newFileSystem(zip)) {
Path inside = fs.getPath("/2026/october.txt");
IO.println(Files.readString(inside));
IO.println(inside.getFileSystem().provider().getScheme());
}
}ship the io chapters jar
this one needs a real JVM (files, the network or a database), so it doesn't run in the browser. copy it into java Main.java to try it.
that's also how the JDK reads its own class library: jrt:/java.base/java/lang/String.class is a path in the jrt filesystem, backed by the lib/modules image.
- a
Pathis a name. creating one never touches the disk. Filesdoes the work, and throws an exception that says what went wrong.resolveglues paths, unless the other one is absolute.normalizeis textual.relativizeis the inverse.- relative paths resolve against the working directory, not your code's location.
walk,list,find,lines: lazy streams holding OS handles. try-with-resources.WatchService: one directory, not recursive, polls every 2 s on macOS.