Files, Paths and NIO
Every program eventually needs to read something from disk or write something to
it. Java has two APIs for this: the old java.io.File from 1996, and
java.nio.file from Java 7.
Use java.nio.file. The old one returns false instead of telling you why
something failed, has no useful exception messages, and cannot express symbolic
links or file attributes. It survives because a great deal of code was written
against it, so you will read it — but do not write it.
Path
A Path is a location. It does not have to exist.
Path file = Path.of("/var/data/riztech", "deliveries.csv");
path : /var/data/riztech/deliveries.csv
fileName : deliveries.csv
parent : /var/data/riztech
absolute : true
normalize : a/c/d (from "a/b/../c/./d")
relativize: deliveries.csv
resolve up: archive.csv
| Method | Gives |
|---|---|
Path.of(a, b, c) |
Joins with the platform separator |
getFileName() |
The last element |
getParent() |
Everything before it |
resolve(other) |
This path plus another — the usual way to build one |
resolveSibling(other) |
Replace the last element |
relativize(other) |
The route from this path to that one |
normalize() |
Removes . and .. |
toAbsolutePath() |
Against the working directory |
startsWith(other) |
Path-element prefix, not text prefix |
Never build a path by concatenating strings with / or \. Path.of uses
the right separator for the platform, so the same code works on a developer's
Windows laptop and a Linux server.
File.separator is [/]
Path.of handles it: data/2026/sep.csv
One security note worth having early: normalize() and startsWith are how
you stop a path traversal attack. If a user supplies a filename and you do
uploadDir.resolve(userInput), they can send ../../etc/passwd. Normalise the
result and check it still starts with your directory.
Files
Everything you do to a file is a static method on Files.
| Call | Does |
|---|---|
Files.exists(p) / notExists(p) |
Presence |
Files.size(p) |
Bytes |
Files.createDirectories(p) |
Makes the whole tree; no error if it exists |
Files.createFile(p) |
Fails if it already exists |
Files.readString(p) |
The whole file as a String |
Files.readAllLines(p) |
The whole file as a List<String> |
Files.lines(p) |
A lazy Stream<String> — must be closed |
Files.writeString(p, s) |
Writes, replacing |
Files.write(p, bytes) |
Raw bytes |
Files.newBufferedReader(p) / newBufferedWriter(p) |
For large or incremental work |
Files.copy(a, b) / move(a, b) |
With StandardCopyOption flags |
Files.delete(p) |
Throws if missing |
Files.deleteIfExists(p) |
Does not |
Files.list(dir) |
One level, as a Stream<Path> — must be closed |
Files.walk(dir) |
Recursive, as a Stream<Path> — must be closed |
Files.readAttributes(p, BasicFileAttributes.class) |
Size, times, type |
Files.createDirectories(base);
Files.writeString(file, content, StandardCharsets.UTF_8);
exists : true
size : 81 bytes
readString: 3 lines
readAllLines first: date,customer,area,tiffins
Appending needs an explicit option, because the default replaces:
Files.writeString(file, "2026-09-02,Kavita,Wagholi,3\n", StandardOpenOption.APPEND);
Always specify the charset
Files.writeString(file, content, StandardCharsets.UTF_8);
Files.readString(file, StandardCharsets.UTF_8);
The methods have overloads that use a default. Since Java 18 that default is UTF-8 everywhere, which fixed a long-standing source of pain — before that it was the platform's encoding, so a file written on a Windows machine in India and read on a Linux server came back with the wrong characters.
Say UTF-8 explicitly anyway. It costs a few characters and it makes the intent obvious to a reader who does not know which Java version this was written for.
The two streams that must be closed
Files.lines and Files.list are lazy and hold an open file handle:
try (Stream<String> lines = Files.lines(file)) {
long rows = lines.skip(1).filter(l -> !l.isBlank()).count();
}
Without the try-with-resources the handle stays open until garbage collection, and a loop over thousands of files hits the operating system limit — the leak from the last module, in the place you are most likely to cause it.
Use Files.readAllLines when the file fits in memory and you avoid the
question entirely. Use Files.lines when it does not. "Fits in memory" for a
server with a few hundred megabytes of heap means up to tens of megabytes of
text; a gigabyte log file does not.
Same rule for Files.list and Files.walk:
try (Stream<Path> entries = Files.list(base)) {
entries.map(Path::getFileName).sorted().forEach(System.out::println);
}
Writing safely: the atomic move
A program that writes a report directly to its final location has a window where the file is half-written. If something reads it then — or if the process is killed — the result is a truncated file that looks valid.
Path target = base.resolve("report.txt");
Path tmp = base.resolve("report.txt.tmp");
Files.writeString(tmp, content);
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
target : final content
tmp gone : true
Write to a temporary file in the same directory, then move. A move within one filesystem is atomic: readers see either the old file or the new one, never a partial one. The "same directory" part matters — a move across filesystems is a copy and delete, and is not atomic.
This is the pattern the capstone uses for saving data, and it is what separates a program you would trust with a month of records from one you would not.
Failures
Files.readString(base.resolve("nope.csv"));
NoSuchFileException: /var/folders/.../riztech-java-m8/nope.csv
NoSuchFileException extends IOException and its message is the path, which is
exactly what you want. The ones you will meet:
| Exception | Means |
|---|---|
NoSuchFileException |
The path does not exist |
FileAlreadyExistsException |
createFile on something already there |
AccessDeniedException |
Permissions |
DirectoryNotEmptyException |
delete on a non-empty directory |
NotDirectoryException |
list on a regular file |
All checked, all extending IOException. Per module 7: catch them where you can
add context — which file, which operation — and wrap.
Do not check exists() and then read. Between the two calls the file can
disappear, and you have written a race condition. Just read, and catch
NoSuchFileException.
Deleting a tree
There is no one-call recursive delete. The idiom:
try (Stream<Path> all = Files.walk(base)) {
all.sorted(Comparator.reverseOrder()).forEach(p -> {
try { Files.delete(p); } catch (IOException ignored) { }
});
}
reverseOrder() puts children before their parents, because a directory must be
empty before it can be deleted. The absence of a one-liner is deliberate — a
recursive delete is not something a language should make easy to do by accident.
Check your work
Which API should you use, and why not the other? java.nio.file.
java.io.File returns false rather than saying why something failed and has no
useful exceptions.
Why Path.of("a", "b") rather than "a/" + "b"? It uses the platform's
separator, so the same code works on Windows and Linux.
How do you stop a path traversal attack? normalize() the resolved path and
check it still startsWith your base directory.
Which two Files methods must be closed, and why? Files.lines and
Files.list (and walk) return lazy streams holding an open file handle.
When should you prefer readAllLines to lines? Whenever the file fits in
memory. It closes immediately and removes the question.
How do you write a file without a window where it is half-written? Write to a
temporary file in the same directory, then Files.move with ATOMIC_MOVE.
Why not call exists() before reading? The file can vanish between the two
calls. Read, and catch NoSuchFileException.
Why does a recursive delete need reverseOrder()? A directory must be empty
before it can be deleted, so children must come before their parents.
Practice 3, the atomic write. Writing directly to the target and killing the
program halfway leaves a truncated file that still parses as valid CSV — fewer
rows, no error. With the temp-then-move version, the target either holds the
complete old file or the complete new one. That is the whole argument, and it is
worth causing once with Ctrl+C.
Practice 5, the leak. Calling Files.lines without closing it usually
appears to work, because garbage collection eventually closes the handle. Run it
a few thousand times in a loop and you get Too many open files — pointing at
whichever open happened to be unlucky, not at any of the leaks. Lower the limit
with ulimit -n 256 to see it quickly.
Practice
-
Build and inspect paths. Create a path with
Path.of, then printgetFileName,getParent,normalizeon something containing.., andrelativizebetween two paths. Predict each before running. -
Write, append and read. Write a three-line CSV, append a fourth line, then read it back with
readString,readAllLinesandlines. Confirm all three agree on the line count. -
Write atomically. Write a report directly to its final path with a
Thread.sleepin the middle, and kill the program during the sleep. Look at the file. Then do it again with the temp-then-move pattern. -
List and walk. List one directory, then walk a tree, printing relative paths. Use try-with-resources for both.
-
Leak a handle. Call
Files.lineswithout closing it, in a loop over the same file several thousand times. Lower your shell's limit first withulimit -n 256. Then fix it. -
Harder — a safe upload directory. Write
Path safeResolve(Path base, String userSuppliedName)that rejects anything escapingbase. Test it withreport.csv,../secret.txt,sub/dir/x.csv,/etc/passwdand..%2Fsecret.txt. Then write down which of those your first attempt let through — most people's lets one through, and the one it lets through is why the check belongs in a named method rather than inline.
Next: reading and writing real data — CSV, and why you should not write your own parser for long.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship