Checkout needs a daily export: every Order as a CSV line under reports/. The first draft is new File("reports/" + date + "/orders.csv"), then a FileInputStream / FileOutputStream, then file.exists(), then dir.listFiles() that returns null. Location and operations are glued to the same mutable File.
Path names the location; Files does the work. This post is java.nio.file inside the JVM process. Shell find is Linux find. IOException and try-with-resources policy live in Exceptions.
Mental model
Path is a location. It does not have to exist yet. Files is the bag of operations — read, write, copy, move, delete, walk. You join locations with resolve; you touch the disk with Files.
Same checkout records as the rest of the series:
public record LineItem(String sku, int quantity, BigDecimal unitPrice) {}
public record Order(
String id,
String customerEmail,
List<LineItem> items,
BigDecimal total,
boolean active) {}
A day’s export is reports/2026-09-06/orders.csv. A SKU dump is reports/2026-09-06/skus.txt. The folder that holds them is the tree Files.walk and Files.find iterate.
| Type | Job |
|---|---|
Path | A location. Need not exist. Not a file handle. |
Files | Static operations on that location |
FileSystem | The store; Path.of uses the default one |
java.io.File | Legacy. Convert with toPath() / toFile() when an old API still wants File |
Path.of already names the type. If you would write var for that local, the decision is var — this post keeps Path on the left so the location stays visible.
Note: Files.exists(path) and Files.notExists(path) are not exact inverses. If the JVM cannot tell (permissions, a broken symlink), both return false.
Paths that do not touch the disk
Build the export location from a root. resolve appends a relative name. An absolute argument replaces the receiver.
Path reports = Path.of("reports");
Path daily = reports.resolve("2026-09-06").resolve("orders.csv");
Path abs = Path.of("/tmp/orders.csv");
reports.resolve(abs); // /tmp/orders.csv — absolute argument wins
daily is still just a location:
reports/2026-09-06/orders.csv
normalize collapses . and .. in the name. It does not talk to the disk and it does not follow symlinks. toRealPath() is the call that does.
Path messy = Path.of("reports/./2026-09-06/../2026-09-06/orders.csv");
Path clean = messy.normalize();
// reports/2026-09-06/orders.csv
relativize answers “how do I walk from here to there?” Both sides must be relative, or both absolute.
Path reportsDir = Path.of("/var/app/reports");
Path export = Path.of("/var/app/reports/2026-09-06/orders.csv");
Path relative = reportsDir.relativize(export);
// 2026-09-06/orders.csv
Use relativize when a report index should store paths inside reports/, not host-specific prefixes. getFileName() is the last element (orders.csv); getParent() is the directory that should exist before you write.
Read, write, copy, move, delete
Create the day folder, then write the export. CREATE_NEW fails if the file already exists — the safe default when a named daily export must not be clobbered.
Path reports = Path.of("reports");
Path day = reports.resolve("2026-09-06");
Path export = day.resolve("orders.csv");
Files.createDirectories(day);
String csv = orders.stream()
.map(o -> o.id() + "," + o.customerEmail() + "," + o.total())
.collect(Collectors.joining("\n", "", "\n"));
Files.writeString(export, csv, StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW);
CREATE_NEW is the option that refuses to overwrite. Bare Files.writeString(path, csv) uses CREATE plus TRUNCATE_EXISTING: missing file is created, existing file is emptied. That is a silent clobber.
Read a small export back as one string:
String body = Files.readString(export, StandardCharsets.UTF_8);
A warehouse SKU dump can be large. Prefer a reader you close, not Files.readAllLines. When the file will not fit in RAM even as a stream of lines, that is external sort, not another List<String>.
Path skuList = day.resolve("skus.txt");
try (BufferedReader in = Files.newBufferedReader(skuList, StandardCharsets.UTF_8)) {
String sku;
while ((sku = in.readLine()) != null) {
// one LineItem.sku per line
}
}
The try owns the reader. How you declare IOException and why the resource must close is Exceptions — this post only shows the NIO handle you put in the parentheses.
Copy yesterday’s export into a backup folder. Files.copy copies one file. A directory argument creates an empty directory at the target; it does not copy the tree.
Path backup = reports.resolve("backup").resolve(export.getFileName());
Files.createDirectories(backup.getParent());
Files.copy(export, backup, StandardCopyOption.COPY_ATTRIBUTES);
Add REPLACE_EXISTING only when yesterday’s backup is allowed to disappear. move is the same idea for “promote the draft report”:
Path draft = day.resolve("orders.draft.csv");
Path published = day.resolve("orders.csv");
Files.move(draft, published, StandardCopyOption.ATOMIC_MOVE);
ATOMIC_MOVE can throw AtomicMoveNotSupportedException across filesystems. Retry without the option, or keep the draft where it is.
delete removes one file, or an empty directory. A reports folder that still has CSV files throws DirectoryNotEmptyException. deleteIfExists returns false instead of throwing when the path is already gone.
Files.delete(day.resolve("orders.draft.csv"));
boolean gone = Files.deleteIfExists(day.resolve("tmp.csv"));
A FileChannel is still a resource. Open it in the same try:
try (FileChannel channel = FileChannel.open(export, StandardOpenOption.READ)) {
long bytes = channel.size();
}
That is the whole channel lesson here: close it. Buffers and mapped files are a different API.
Walk a reports directory
Files.walk is a depth-first walk of a file tree. It is not graph DFS — DFS is vertices and back edges. The stream must be closed; it holds directory handles.
try (Stream<Path> tree = Files.walk(reports)) {
List<Path> csvs = tree
.filter(Files::isRegularFile)
.filter(p -> p.getFileName().toString().endsWith(".csv"))
.toList();
}
Files.find is the same walk with a matcher, so you filter on attributes without a second pass. maxDepth of 2 covers reports/<day>/<file> and stops there.
try (Stream<Path> found = Files.find(
reports,
2,
(path, attrs) -> attrs.isRegularFile()
&& path.getFileName().toString().endsWith(".csv"))) {
found.forEach(path -> {
// import that day's orders
});
}
By default neither call follows symlinks. FileVisitOption.FOLLOW_LINKS can loop if a link points at an ancestor; leave it off unless the tree is yours and you know it is a dag of directories.
Files.lines is a stream of strings, not of paths, and it has the same close rule:
try (Stream<String> lines = Files.lines(skuList, StandardCharsets.UTF_8)) {
lines.map(String::trim)
.filter(s -> !s.isEmpty())
.forEach(sku -> { /* one SKU */ });
}
A Function that calls Files.readString cannot throw IOException — Function spells that slot. Wrap at the boundary; the policy is Exceptions.
Missing files vs files that already exist
NIO does not throw java.io.FileNotFoundException for these operations. The two you will actually catch on an export path are NoSuchFileException and FileAlreadyExistsException.
Path export = Path.of("reports/2026-09-06/orders.csv");
try {
Files.writeString(export, csv, StandardOpenOption.CREATE_NEW);
} catch (NoSuchFileException ex) {
// parent directory missing — createDirectories, then retry
} catch (FileAlreadyExistsException ex) {
// today's export is already on disk — do not clobber
}
| Thrown when | Typical call |
|---|---|
NoSuchFileException | readString / copy source / move source / delete of a path that is gone; writeString when the parent directory does not exist |
FileAlreadyExistsException | CREATE_NEW; copy / move onto a target that exists and you did not pass REPLACE_EXISTING; createFile / createDirectory of a name that is taken |
DirectoryNotEmptyException | delete on a reports folder that still has files |
AtomicMoveNotSupportedException | ATOMIC_MOVE across devices |
Both “missing” and “already there” are subtypes of IOException. Catch the specific type when the recovery differs: create the parent, versus refuse the overwrite. Catching IOException and logging “file problem” hides that fork.
Pitfalls and when not to
Leaving Files.walk, Files.find, or Files.lines open leaks directory handles. Put the stream in try-with-resources even if you only call toList().
writeString without CREATE_NEW overwrites. That is how Tuesday’s orders replace Monday’s under the same filename.
Files.copy on a directory is not a tree copy. Walk and copy files, or stay on the shell for a real directory sync — that job is rsync, not this API.
resolve of an absolute path discards the prefix you thought you were joining. normalize does not prove the file exists. toRealPath() does, and it throws NoSuchFileException if it does not.
Do not load a multi-gigabyte SKU dump with readString or readAllLines. Stream lines; if you must sort more than RAM, use external sort.
Do not copy Files.walk into a service-graph crawler. That is DFS.
Skip java.nio.file when you are not in the process: locating files from a terminal is Linux find. Skip it when the “file” is a database, an object store, or a watch loop — WatchService is a different API. And skip replacing Order persistence with a pile of CSV files because writeString was convenient.
Cheat sheet
Path.of("reports") location; need not exist
path.resolve("orders.csv") append relative; absolute argument replaces
path.normalize() collapse . and .. ; no disk, no symlinks
path.relativize(other) walk from here to there (same relativity)
Files.createDirectories(dir) parents too
Files.writeString CREATE + TRUNCATE by default; use CREATE_NEW
Files.readString whole file, UTF-8; small exports only
Files.newBufferedReader stream a large SKU list; close it
Files.copy / move / delete one file; copy(dir) is not a tree copy
Files.walk / find file-tree Stream; close it; not graph DFS
NoSuchFileException source or parent missing
FileAlreadyExistsException CREATE_NEW or copy onto an existing target
Do:
- Keep locations on
Pathand operations onFiles. createDirectoriesbefore the first write into a day folder.- Use
CREATE_NEWfor a named daily export;REPLACE_EXISTINGonly when a backup is allowed to vanish. - Close
walk/find/lines/ readers / channels in try-with-resources. - Catch
NoSuchFileExceptionandFileAlreadyExistsExceptionseparately when recovery differs.
Don’t:
- Call
writeStringand assume a missing parent, or an existing file, will fail the way you hope. - Treat
Files.walkas Linuxfind, rsync, or graph DFS. - Load a warehouse dump with
readAllLines. - Swallow
IOExceptioninside aFunction.apply. - Mix
new Filepath joining with NIO operations unless an old API still demandsFile.
Wrap-up
java.nio.file splits the old File soup: Path is the location, Files is the operation. resolve / normalize / relativize stay off the disk. readString and writeString cover small order exports; readers and Files.lines cover SKU lists that should not land in a List. walk and find iterate a reports tree inside the process — the shell still owns find on a box you are logged into.
You already have the IOException policy from Exceptions. This post is the API those catch blocks wrap. Next is asserting the export without booting a Spring slice.