JAVA / FILES, INPUT/OUTPUT AND SERIALIZATION
Working with directories and walking file trees
Create nested directories and walk them with Files.list, walk/find and walkFileTree, pruning subtrees and deleting bottom-up without leaking handles.
What you will learn
- Close every Files.list/walk/find stream: it holds open OS directory handles
- Delete a tree bottom-up with postVisitDirectory or a reverse-sorted walk
- Prune whole subtrees by returning SKIP_SUBTREE from preVisitDirectory
- Sort walk results yourself; sibling order inside a directory is unspecified
Understanding Working with directories and walking file trees
A directory on disk is a file whose contents are a list of names, so reading one means opening a handle and iterating it. That is why Files.newDirectoryStream, Files.list, Files.walk and Files.find all hand back something closeable, and why a walk over a deep tree can hold several handles open at once, one for each level it is currently inside. It also explains the ordering: entries arrive in whatever sequence the filesystem stores them, which on ext4 is hash order, so nothing promises alphabetical and you sort explicitly when order matters.
There are two traversal shapes. Files.walk and Files.find are a pull model: lazy, pre-order (a directory is emitted before its contents), bounded by maxDepth, and shaped with ordinary stream filters. Files.walkFileTree is a push model: you supply a FileVisitor and the JDK calls preVisitDirectory, visitFile, visitFileFailed and postVisitDirectory, and you steer with the returned FileVisitResult (CONTINUE, SKIP_SUBTREE, SKIP_SIBLINGS, TERMINATE). Only the push model tells you when a directory is finished, which is what you need for work a parent can do only after its children: deleting it, summing its size, writing an index into it.
Failures behave differently in each model. In the stream model an IOException hit while descending is wrapped in UncheckedIOException and thrown out of the terminal operation, killing the pipeline halfway through; the visitor model hands the same error to visitFileFailed, or as the second argument of postVisitDirectory, so you can return CONTINUE and skip just the unreadable part. Neither follows symbolic links unless you pass FileVisitOption.FOLLOW_LINKS, and once you do, a link pointing back at an ancestor produces FileSystemLoopException instead of an infinite descent.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.stream.Stream;
public class DirectoryTour {
public static void main(String[] args) throws IOException {
Path root = Files.createTempDirectory("tour");
Files.createDirectories(root.resolve("src/main"));
Files.createDirectories(root.resolve("src/test"));
Files.writeString(root.resolve("README.md"), "hi\n");
Files.writeString(root.resolve("src/main/App.java"), "class App {}\n");
Files.writeString(root.resolve("src/test/AppTest.java"), "class AppTest {}\n");
System.out.println("one level:");
try (Stream<Path> level = Files.list(root)) {
level.map(root::relativize).map(Path::toString).sorted()
.forEach(name -> System.out.println(" " + name));
}
System.out.println("whole tree:");
try (Stream<Path> tree = Files.walk(root)) {
tree.map(root::relativize).map(Path::toString).sorted()
.forEach(name -> System.out.println(" " + (name.isEmpty() ? "." : name)));
}
try (Stream<Path> tree = Files.walk(root)) {
// sorted() drains the walk before the loop starts, so nothing is
// deleted while a directory is still being read
for (Path p : tree.sorted(Comparator.reverseOrder()).toList()) {
Files.delete(p);
}
}
System.out.println("cleaned up: " + Files.notExists(root));
}
}A tree walk is a stack of open directory handles delivered parent-first, so you close what you open, never trust sibling order, and use post-order callbacks for anything that depends on a directory's contents being finished.
Worked examples
Recursive delete with a FileVisitor
Shows why a directory can only be removed in postVisitDirectory, after its entries have been visited.
import java.io.IOException;
import java.nio.file.DirectoryNotEmptyException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
public class DeleteTree {
static final class Deleter extends SimpleFileVisitor<Path> {
int files;
int dirs;
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
Files.delete(file);
files++;
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException failure) throws IOException {
if (failure != null) {
throw failure;
}
Files.delete(dir);
dirs++;
return FileVisitResult.CONTINUE;
}
}
public static void main(String[] args) throws IOException {
Path root = Files.createTempDirectory("del");
Files.createDirectories(root.resolve("a/b"));
Files.writeString(root.resolve("x.txt"), "x\n");
Files.writeString(root.resolve("a/y.txt"), "y\n");
Files.writeString(root.resolve("a/b/z.txt"), "z\n");
try {
Files.delete(root);
} catch (DirectoryNotEmptyException e) {
System.out.println("Files.delete(root) refused: " + e.getClass().getSimpleName());
}
Deleter deleter = new Deleter();
Files.walkFileTree(root, deleter);
System.out.println("removed " + deleter.files + " files and " + deleter.dirs + " directories");
System.out.println("root gone: " + Files.notExists(root));
}
}Example explained
Line 1Files.delete(root) fails first because the default provider maps the ENOTEMPTY error to DirectoryNotEmptyException; a directory must be empty before it can go.
Line 2visitFile is called once per regular file found anywhere under root, so the visitor replaces hand-written recursion over Files.list.
Line 3postVisitDirectory fires only after that directory's entries are exhausted, which is the exact moment it is empty and deletable, so dirs also counts root itself.
Line 4The failure parameter is non-null when the directory could not be fully read; rethrowing it aborts the walk instead of pretending an unreadable subtree was cleaned.
What maxDepth actually counts
Demonstrates that depth is measured from the start directory, with depth 1 meaning direct children only.
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 DepthLimit {
public static void main(String[] args) throws IOException {
Path root = Files.createTempDirectory("depth");
Files.createDirectories(root.resolve("a/b"));
Files.writeString(root.resolve("top.log"), "1\n");
Files.writeString(root.resolve("a/mid.log"), "2\n");
Files.writeString(root.resolve("a/b/deep.log"), "3\n");
for (int depth = 1; depth <= 3; depth++) {
try (Stream<Path> found = Files.find(root, depth, (p, attrs) -> attrs.isRegularFile())) {
List<String> names = found.map(root::relativize)
.map(Path::toString)
.sorted()
.toList();
System.out.println("maxDepth " + depth + " -> " + names);
}
}
}
}Example explained
Line 1maxDepth is relative to the start directory: root sits at depth 0, its direct children at depth 1, so a limit of 1 never sees a/mid.log.
Line 2The BiPredicate receives the BasicFileAttributes the walker already read, so isRegularFile costs no extra filesystem call.
Line 3Files.walk(root, depth) is the same traversal with an always-true predicate, and both emit root itself, which is why the isRegularFile test is needed to count files.
Line 4sorted() runs on the mapped strings, not on the filesystem's own order, so the printed list is reproducible across machines.
Important notes
Files.walk and Files.find emit the start directory itself at depth 0, so any count that omits an isRegularFile filter is off by at least one.
Do not add or remove entries in a directory a lazy walk is still reading; materialize the paths first with sorted() or toList(), then modify.
Common mistakes
Calling Files.walk or Files.list outside try-with-resources: the directory handles stay open until garbage collection, and a long-running process eventually dies with FileSystemException: Too many open files.
Deleting during a plain pre-order walk, or calling Files.delete on a directory: the parent is reached before its contents, so you get DirectoryNotEmptyException and a half-emptied tree.
Using Files.createDirectory for a nested path like out/reports/2026: it creates only the last segment and throws NoSuchFileException when a parent is missing, where createDirectories builds the whole chain.
Try it yourself
Change, predict, then run
Create a temp directory holding a/App.java, a/App.class and a/b/Util.java, then use Files.walk to print the sorted relative paths of the .java files only. Finish by deleting the whole tree with a reverse-sorted walk so Files.notExists(root) prints true.
Open the Java workspaceCheck your understanding
For every directory in a project you want one line with the total bytes of that directory and everything below it, printed at the moment the directory's subtree is fully accounted for. Which mechanism gives you that ordering?
- preVisitDirectory, since the BasicFileAttributes it receives already holds the subtree's total size
- Files.walk(root).sorted(), since sorted order reaches the deepest paths first
- postVisitDirectory in a FileVisitor, since it runs only after every entry in that directory has been visited
- Files.list(root) on its own, since it returns nested entries grouped by directory
Show answer
postVisitDirectory is the post-order hook, so a directory's running total is complete exactly when it fires, and you also receive any IOException from reading it. Ascending sorted() is tempting but does the opposite: a parent path is a prefix of its children, so it sorts before them, giving you parents first. And a directory's attrs.size() is the size of the directory entry itself, typically 4096 bytes, never the recursive total.