NIO.2: Path, Files, and Modern File Handling
Executive Summary
With NIO.2’s Path and Files, Path.of builds platform-correct file locations with algebra instead of string concatenation. For example, resolve joins, getParent and getFileName navigate, normalize cleans. Meanwhile, the Files utility class performs the everyday operations in one call each. It offers readString and writeString for whole files, readAllLines for lists, and lines for lazy streams. It also offers exists, createDirectories, copy, move, and size for management, createTempFile for scratch space, and walk for traversing directories as a Stream of Path. Files.lines and Files.walk hold file handles open, so they belong inside try-with-resources, the exceptions article’s rule applied to streams.
The typed exception subclasses, NoSuchFileException, FileAlreadyExistsException, DirectoryNotEmptyException, name the failure precisely. Also, creation calls like createFile and the CREATE_NEW option replace check-then-act races with single atomic operations. In short, use Path and Files for all new file code, while the classic stack remains for sockets and custom wrapping, which the next article’s networking practice will exercise.
Path: The Modern Handle
Path is a value that represents a location, not the file itself, so it does not touch the disk until you ask it to. The Path documentation defines it as pure algebra, which is what makes it safer than the string surgery it replaces. For example, here is the wrong code first:
// WRONG: string surgery, correct only by luck
String config = dir + "/" + name + ".txt"; // wrong separator on Windows,
// no ".." handling, no type
// RIGHT: path algebra, correct by construction
Path root = Path.of("data");
Path config = root.resolve("settings.txt"); // data/settings.txt, platform-correct
Path parent = config.getParent(); // data
Path file = config.getFileName(); // settings.txt
Path clean = Path.of("data/./logs/../x.txt").normalize(); // data/x.txt
The delta: resolve joins segments with the platform’s separator and refuses to invent directories. Meanwhile, normalize collapses “.” and “..” entries, and getParent with getFileName navigate without substring arithmetic. The operations compose, so directory walking and report building read as transformations on locations rather than string formats.
| Operation | Result | Replaces |
|---|---|---|
| Path.of(“a”, “b.txt”) | a/b.txt, platform separators | Concatenation with guessed separators |
| path.resolve(other) | Joined path | dir + “/” + name |
| path.getParent() | Directory above | lastIndexOf(“/”) + substring |
| path.getFileName() | Last segment | substring after last slash |
| path.normalize() | Redundant segments removed | Manual “..” cleanup |
| path.toAbsolutePath() | Full location | user.dir assumptions |
Files: The One-Line Toolkit
The Files class is a utility class exactly in the shape of the last article’s Objects and Math. It is final, static, stateless, and covers the everyday operations, per the NIO.2 file I/O lesson. In practice, one table replaces most of the classic stack:
| Call | Job | Classic equivalent |
|---|---|---|
| Files.readString(path) | Whole file as a String, UTF-8 by default | Five-line reader stack |
| Files.writeString(path, text) | Whole file out, truncate or append by option | PrintWriter stack |
| Files.readAllLines(path) | List<String>, all in memory | readLine loop |
| Files.lines(path) | Stream<String>, lazy, must be closed | BufferedReader.lines() |
| Files.exists, Files.size | Checks and measurements | File.exists and length |
| Files.createDirectories(path) | All missing levels, no boolean dance | File.mkdirs() |
| Files.copy, Files.move | With REPLACE_EXISTING options | Stream copy loops |
| Files.createTempFile(prefix, suffix) | Unique scratch file | Hand-rolled names |
| Files.walk(start) | Stream<Path> over a whole tree | Recursion |
Side by side, the delta on the previous article’s centerpiece:
// the classic stack, correct but ceremonial
try (var reader = new BufferedReader(new InputStreamReader(
new FileInputStream("settings.txt"), StandardCharsets.UTF_8))) {
var sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line).append(System.lineSeparator());
}
return sb.toString();
}
// the NIO.2 call: one line, UTF-8 by contract, same guarantees
return Files.readString(Path.of("settings.txt"));
However, nothing about the underlying work changed: the same buffering, the same decoding, the same exception channel, IOException. What changed is that the JDK owns the ceremony. As a result, the charset defaults, the loop, and the resource handling are right by construction instead of by review.
Files.lines and Files.walk: Streams That Hold Handles
Two Files methods return streams, and both are the Part 1 laziness article’s subject wearing file handles. Files.lines reads a file lazily, one line at a time. Similarly, Files.walk traverses a directory tree lazily, one entry at a time. Because the stream holds the file or directory open until it is consumed, both belong inside try-with-resources. That is exactly what the exceptions article requires for every resource:
// reading lazily: the close matters
long blankCount;
try (var lines = Files.lines(Path.of("app.log"))) {
blankCount = lines.filter(String::isBlank).count();
} // the handle is released here, on every path
// walking a tree as a pipeline, with the streams article's operations
long appLogs;
try (var paths = Files.walk(Path.of("logs"))) {
appLogs = paths
.filter(p -> p.toString().endsWith(".log"))
.filter(p -> p.toString().contains("app"))
.count();
}
The composition is the payoff. A directory tree becomes a Stream of Path, so everything from the streams articles applies: filter by suffix, map to sizes, collect reports, all without writing a single recursive method. As a result, the discipline is one rule: the stream is the resource, so the try block ends when the processing ends, not when the method returns the count.
Typed Failures and Atomic Operations
NIO.2 did to file exceptions what the custom exceptions article did for your domain: it typed them. NoSuchFileException, FileAlreadyExistsException, DirectoryNotEmptyException, and AccessDeniedException are IOException subclasses that name the failure precisely. Also, each carries the file that failed:
try {
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
} catch (NoSuchFileException e) {
// typed, and e.getFile() names exactly which side was missing
throw new ConfigException("missing source: " + e.getFile(), e);
}
Similarly, the same design shows in creation operations, which replace check-then-act races with single atomic calls. For example, here is the wrong code first, the classic race every operating systems course warns about:
// WRONG: check, then create: another process can win between the lines
if (!Files.exists(path)) {
Files.createFile(path); // FileAlreadyExistsException arrives here anyway
}
// RIGHT: the creation is the check, atomically
Files.createFile(path); // succeeds, or throws FileAlreadyExistsException
// lock files use the same trick:
Files.writeString(lockPath, "locked",
StandardOpenOption.CREATE_NEW); // one caller wins, everyone else throws
The delta: exists-then-create has a window between the check and the create. In that window, two processes can both pass the check, the time-of-check to time-of-use race. Instead, CREATE_NEW and createFile make the operating system the referee, the only referee that sees both processes at once. The lesson also matches the concurrency articles ahead: prefer operations that are atomic at the boundary to checks that are correct only in a single-threaded world.
Finally, scratch space completes the toolkit:
Path report = Files.createTempFile("report-", ".txt"); // unique name, in the temp dir
Files.writeString(report, contents);
// the OS guarantees uniqueness: no hand-rolled names, no collisions
The Complete Example: CatalogStore, Rewritten
The library practice’s CatalogStore was written the classic way to teach the layers. Here is the same store, rewritten the modern way. In effect, the diff is the whole argument:
package com.imraan.library.store;
import com.imraan.library.model.Book;
import com.imraan.library.model.Isbn;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
public final class CatalogStore {
public void save(List<Book> books, Path path) throws IOException {
var text = new StringBuilder();
for (Book book : books) {
text.append(book.isbn().value()).append('|')
.append(book.title()).append('|')
.append(book.author()).append('|')
.append(book.totalCopies()).append(System.lineSeparator());
}
Files.writeString(path, text.toString(), StandardCharsets.UTF_8);
}
public List<Book> load(Path path) throws IOException {
var books = new ArrayList<Book>();
for (String line : Files.readAllLines(path, StandardCharsets.UTF_8)) {
String[] parts = line.split("\\|"); // pipe is a regex character, still
books.add(new Book(new Isbn(parts[0]), parts[1], parts[2],
Integer.parseInt(parts[3])));
}
return books;
}
}
Compare against the practice build. Two try-with-resources stacks collapsed into two calls, and the signatures improved from String to Path. Meanwhile, the behavior stays identical, same format, same revalidation through the Book constructor on load. The domain logic did not change by a character, which is the point. After all, the practice put the rules in the model, so upgrading the plumbing was free.
How Real Systems Do This
In practice, Path and Files are the default file API in modern production Java. For example, configuration loading is Files.readString at startup, and template and report generation are writeString plus write calls. Similarly, log shipping walks directories with Files.walk, and the build tools in Part 7 copy, move, and hash files through Files. Meanwhile, the typed exceptions earn their keep in operations code, where “which file was missing” is half the ticket text.
The cleanup job is the shape worth remembering, because it is where the walk API either shines or bites. In my experience, the bite happened on a log-rotation cleaner that walked a directory deleting files older than a threshold. However, the walk was still open while the deletes ran. On a large directory, the stream ran past entries the loop had already removed. The fix was the shape this article teaches. First, collect the paths inside the walk’s try block, close the walk, then delete from the collected list. In other words, walks are snapshots in motion, so treat them read-only.
Two more boundary notes also carry forward. The old java.io.File converts with file.toPath() and Path.toFile(), so legacy code integrates at the edge rather than in the middle. And the classic stack from the previous article is not retired. Sockets and network streams in Part 4’s networking articles use exactly those classes, because there is no Files one-liner for a TCP connection. Knowing when the ceremony is still required is part of knowing why it existed.
Decision Framework
- Representing a location? Path.of, and build it with resolve, never with string concatenation.
- Reading a whole small or medium file? readString for text, readAllBytes for binary.
- Reading something large, or streaming lazily? Files.lines inside try-with-resources, one line in memory at a time.
- Traversing directories? Files.walk as a pipeline, read-only, collected inside the try block before any mutation.
- Creating files where duplicates are possible or contended? The atomic creation calls, createFile and CREATE_NEW, never exists-then-create.
- Scratch output? Files.createTempFile, with the OS as the name generator.
- Working with sockets, network streams, or custom wrapping layers? The classic java.io stack, which Part 4’s networking articles put back to work.
When NOT to Use This
- Do not walk huge trees without a filter and a depth. For example, Files.walk over a seven-figure directory tree is a stream of everything underneath. Therefore, filter early, or walk with a bounded depth so the walk stays proportional to the question.
- Do not keep Files.lines or Files.walk open past their work. The stream is the resource. Return the collected result, not the stream, and let the try block close the handle while the method’s callers stay file-handle-clean.
- Do not mix java.io.File and Path styles in the same layer. Instead, convert at the boundary with toPath and keep the codebase one API, because every mix point is a place where the two libraries’ slightly different behaviors surprise someone.
Common Mistakes
- Leaving Files.lines or Files.walk outside try-with-resources: the handle stays open until garbage collection. Then, on a busy server, the open-file limit is what breaks, hours later, somewhere else.
- String concatenation for paths: wrong separators on Windows, “..” left unnormalized, and no type saying “this is a location”.
- exists-then-create races: two processes pass the check in the same window. Instead, CREATE_NEW and createFile make the operating system the referee.
- Mutating the tree while walking it: the stream observes entries as it moves, so deletes behind the cursor produce entries that vanish mid-iteration. Collect, close, then act.
- Catching bare IOException when a typed subclass would name the failure. After all, NoSuchFileException and friends exist so the boundary can translate precisely instead of guessing.
- Using File in new code: it converts with one call, but its methods lie quietly. For example, exists returns false on permission errors, and boolean mkdirs swallows the reason.
Key Takeaways
- Path is location algebra: of, resolve, getParent, getFileName, normalize replace string surgery with typed, platform-correct operations.
- Files gives the everyday operations one call each: readString, writeString, readAllLines, lines, exists, createDirectories, copy, move, createTempFile.
- Files.lines and Files.walk return streams that hold file handles: both belong inside try-with-resources, closed on every path.
- Typed IOException subclasses, NoSuchFileException, FileAlreadyExistsException, DirectoryNotEmptyException, name the failing file and the failure precisely.
- Atomic creation beats check-then-create: createFile and CREATE_NEW close the race window by letting the operating system decide.
- Walks are read-only: collect inside the try block, close, then mutate the tree.
- The classic java.io stack survives at the edges, sockets and custom wrapping, and legacy File converts with toPath.
FAQ
What is NIO.2 in Java?
The modern file API introduced in Java 7: Path as the typed handle for locations and Files as the static toolkit for operations, plus typed exceptions and walkable directory streams. It is the default for all new file code.
What is the difference between File and Path in Java?
File is the 1996 class with boolean-returning methods and string-based paths; Path is its typed replacement, with algebra operations, precise exceptions, and integration with the Files toolkit. Convert with file.toPath() and prefer Path everywhere in new code.
How do I read a file in one line in Java?
Files.readString(Path.of(“file.txt”)) for a text file as one String, Files.readAllBytes for binary, Files.readAllLines for a List of lines. Each call owns the buffering, the UTF-8 decoding, and the closing.
What does Files.walk do in Java?
It returns a lazy Stream of Path covering a directory and everything under it, up to a maximum depth if you set one. Filter and collect inside try-with-resources, because the stream holds the directory open until closed.
How do I create a temporary file in Java?
Files.createTempFile(prefix, suffix) returns a Path with a unique name in the system temp directory. The uniqueness is guaranteed by the JDK and the operating system, so no hand-rolled names or collision checks.
Conclusion
NIO.2 is the previous article’s rules with the ceremony lifted. It keeps the same charsets and buffers and try-with-resources discipline, expressed as one-line calls over typed paths. Path and Files are now your default tools, while the classic stack remains precisely where its layers are still the only option.
The next article finishes Part 3 the way the course’s parts always end: by building. The CSV practice takes a real file and reads it through this article’s API. Then it answers business questions with the streams trilogy, with everything from this part composed in one program.
In short, modern file code is one line for reading, one for writing, and one rule underneath: the stream is the resource, so the try closes it.
Last updated on 25 September 2026.
