JAVA / FILES, INPUT/OUTPUT AND SERIALIZATION
Paths, Files and the modern nio file API
Build, join, compare and normalize file locations with java.nio.file.Path, and tell exactly which calls touch the disk.
What you will learn
- Build paths as elements with Path.of("a", "b") instead of gluing separators by hand
- Use resolve to append, relativize to subtract, normalize to drop . and .. lexically
- Compare real files with Files.isSameFile, not Path.equals, which is purely lexical
- Query existence, size and type through Files; a Path alone never touches the disk
Understanding Paths, Files and the modern nio file API
A Path is not a file. It is an immutable value object holding an optional root (/ on Unix, C:\ on Windows) followed by a list of name elements, plus the FileSystem provider that knows how to print and compare them. Path.of("var", "log", "app.log") joins those elements with the provider's separator, so the same call yields var/log/app.log on Linux and var\log\app.log on Windows, and nothing on disk is consulted: the file need not exist. Because the object is immutable, resolve, normalize and toAbsolutePath return new instances and leave the receiver exactly as it was.
The nio API deliberately splits naming from doing: Path answers questions about the text of a location, and the static methods on Files are the only things that reach the filesystem. normalize() is pure text surgery, deleting . elements and collapsing name/.. pairs without asking whether name exists or is a symbolic link, while toRealPath() performs real lookups, follows links and throws NoSuchFileException when a component is missing. The same split explains equals: it compares root and elements literally, so /data/./x and /data/x are unequal Paths even though Files.isSameFile reports they name one file.
resolve and relativize are the two directions of one relationship: for a relative x, base.relativize(base.resolve(x)) gives back x, which makes relativize the natural way to turn an absolute path into a name relative to some root directory. The rule that catches people out is that resolve returns its argument unchanged when that argument is absolute, so an absolute string arriving from outside your program silently escapes the base directory. Element-based methods (getNameCount, getName, startsWith, endsWith, iteration) all work in whole name elements and skip the root, which is why endsWith(".log") is false for app.log: the final element is app.log, not .log.
import java.nio.file.Path;
public class PathBasics {
public static void main(String[] args) {
Path base = Path.of("/srv", "app", "data");
Path file = base.resolve("logs/2026-09-03.log");
System.out.println("full: " + file);
System.out.println("fileName: " + file.getFileName());
System.out.println("parent: " + file.getParent());
System.out.println("root: " + file.getRoot());
System.out.println("nameCount: " + file.getNameCount());
System.out.println("name(1): " + file.getName(1));
System.out.println("relativized: " + base.relativize(file));
System.out.println("normalized: " + file.resolve("../old.log").normalize());
System.out.println("absolute: " + file.isAbsolute());
System.out.println("base unchanged: " + base);
}
}A Path is an immutable, purely lexical name for a location, and only the Files class turns that name into an actual filesystem operation.
Worked examples
How resolve decides what to keep
Shows that an absolute argument overrides the base, and that equality between paths is a literal element comparison.
import java.nio.file.Path;
public class ResolveRules {
public static void main(String[] args) {
Path base = Path.of("/home/ana/project");
System.out.println(base.resolve("src/Main.java"));
System.out.println(base.resolve("/etc/passwd"));
System.out.println(base.resolveSibling("archive"));
Path a = Path.of("/home/ana/project/src");
Path b = Path.of("/home/ana/project/./src");
System.out.println("b prints as: " + b);
System.out.println("equals: " + a.equals(b));
System.out.println("equals after normalize: " + a.equals(b.normalize()));
}
}Example explained
Line 1resolve("src/Main.java") appends because the argument is relative, so the base stays in front.
Line 2resolve("/etc/passwd") returns the argument as-is: an absolute other always wins, which is how unvalidated input escapes a directory.
Line 3resolveSibling drops the last element first, so the result is anchored at /home/ana rather than inside project.
Line 4Path.of keeps the literal . element, so a.equals(b) is false until normalize() removes it.
Path names, Files acts
Demonstrates that constructing a Path creates nothing, and that two unequal Paths can refer to the same file on disk.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
public class PathMeetsFiles {
public static void main(String[] args) throws IOException {
Path dir = Files.createTempDirectory("nio-demo");
Path notes = dir.resolve("notes.txt");
System.out.println("exists before write: " + Files.exists(notes));
Files.writeString(notes, "hello nio\n");
Files.createDirectory(dir.resolve("sub"));
System.out.println("exists after write: " + Files.exists(notes));
System.out.println("size: " + Files.size(notes));
System.out.println("regular file: " + Files.isRegularFile(notes));
System.out.println("name inside dir: " + dir.relativize(notes));
Path detour = dir.resolve("sub/../notes.txt");
System.out.println("isSameFile: " + Files.isSameFile(detour, notes));
System.out.println("equals: " + detour.equals(notes));
Files.delete(notes);
Files.delete(dir.resolve("sub"));
Files.delete(dir);
}
}Example explained
Line 1Files.exists(notes) is false immediately after dir.resolve, because building a Path only builds a name.
Line 2size and isRegularFile live on Files, not on Path; Path has no method that can report them.
Line 3Files.isSameFile compares the actual files behind the two names, so the sub/.. detour matches notes.txt.
Line 4equals on those same two objects is false, since the sub and .. elements are still present in the element list.
Elements, not characters
Shows that separators come from the FileSystem and that startsWith/endsWith/subpath operate on whole name elements.
import java.io.File;
import java.nio.file.FileSystems;
import java.nio.file.Path;
public class PortablePaths {
public static void main(String[] args) {
System.out.println("separator: [" + FileSystems.getDefault().getSeparator() + "]");
Path p = Path.of("config", "db", "settings.properties");
System.out.println("joined: " + p);
for (Path element : p) {
System.out.println("element: " + element);
}
System.out.println("subpath(0,2): " + p.subpath(0, 2));
System.out.println("endsWith settings.properties: " + p.endsWith("settings.properties"));
System.out.println("endsWith properties: " + p.endsWith("properties"));
File legacy = p.toFile();
System.out.println("round trip equal: " + legacy.toPath().equals(p));
}
}Example explained
Line 1The separator is supplied by the default FileSystem, so this identical Path.of call prints backslashes on Windows.
Line 2Iterating a Path yields its name elements one at a time and never yields the root.
Line 3endsWith("properties") is false because it matches a trailing element, not a trailing substring; use getFileName().toString().endsWith(".properties") for extensions.
Line 4toFile() and toPath() convert losslessly here, which is the bridge to older java.io APIs.
Important notes
Path.of arrived in Java 11; on Java 8 use Paths.get, which behaves identically and now simply delegates to Path.of.
Path.equals follows the provider's rules: case-sensitive on Linux, case-insensitive on Windows, so the same comparison can differ by platform even for one file.
Common mistakes
Passing an unchecked name straight into base.resolve: if it starts with / the base is discarded entirely, so base.resolve("/etc/passwd") is /etc/passwd and the code reads a file far outside the intended directory.
Writing path.normalize(); or path.toAbsolutePath(); as a standalone statement and reusing path afterwards. Path is immutable, the return value was the only result, and the later equals or startsWith check fails on the still-unnormalized path.
Using endsWith(".log") or startsWith("/da") on a Path as if they were String methods. They compare whole name elements, so both are false for /data/app.log and a file filter written that way silently matches nothing.
Try it yourself
Change, predict, then run
Build Path.of("/var", "www", "html"), resolve "../logs/access.log" onto it, then print the joined path, its normalize() form, and getNameCount() for both. Explain in a comment why the count drops by two.
Open the Java workspaceCheck your understanding
Path a = Path.of("/data/./reports"); Path b = Path.of("/data/reports"); the directory /data/reports exists on disk. What does a.equals(b) return and why?
- false, because equals compares name elements literally and a still contains a "." element
- true, because both paths refer to the same existing directory on disk
- false, because equals compares object identity and these are two distinct objects
- true, because Path.of removes redundant "." elements while parsing the string
Show answer
equals compares the root plus the element list, and a has three elements (data, ., reports) against b's two, so the result is false. The second option is the tempting one: Path.equals never consults the filesystem, and Files.isSameFile(a, b) is what would return true there. Path.of also does not strip "." elements; only normalize() does.