Path and Files are the two types used for file work in modern Java. Path is an abstract pathname that replaces the older File class. Files is a static utility class that does almost every common file operation in one method call: read a whole file into a string, write a list of lines, copy, move, delete, walk a directory tree. This lesson covers how to build a Path, what it exposes, and the parts of the Files API used day to day.
Path Replaces FileThe legacy java.io.File class served as both "a pathname" and "the thing you do file operations on". It tried to be both and was decent at neither. Reading a file with File alone is impossible. It has to be combined with FileReader, BufferedReader, Scanner, or one of half a dozen stream classes. Error handling is bolted on with boolean return values: file.delete() returns false if it failed, with no indication of why.
NIO.2 (added in Java 7) split the two concerns. Path is purely a value object that names a location in a file system. Files is a static class full of methods that take a Path and do work, throwing useful exceptions when something goes wrong.
| Concern | Legacy (File) | Modern (Path + Files) |
|---|---|---|
| Name a location | new File("orders.csv") | Path.of("orders.csv") |
| Read whole file | Combine with FileReader+buffer | Files.readString(path) (1 call) |
| Delete error | Returns false, no reason | Throws IOException with details |
| Symbolic links | Limited, mostly silent | First-class with LinkOption |
| Walk a tree | Recursive listFiles, manual | Files.walk(path) returns a Stream |
| Atomic moves | Not supported | StandardCopyOption.ATOMIC_MOVE |
| Watch for changes | Not supported | WatchService |
The legacy File still works. It appears in older codebases and in some APIs that haven't migrated. New code should use Path and Files. To bridge between them, path.toFile() and file.toPath() convert in either direction.
PathA Path is more than a string. It models the structure of a pathname: a root, zero or more name elements separated by file-system separators, and a final element which is usually the file name. On Linux and macOS the separator is /. On Windows it's \. Path knows about both and exposes methods to inspect each part by name rather than by string surgery.
The cyan box at the top is the full pathname. The orange box is the root (the bit that anchors the path to a file system). The green box is everything except the file name. The teal boxes are the name elements, indexed from 0. The red box is the final segment, which is the file name itself.
Here's the same idea in code:
getNameCount() counts the path components, not including the root. getName(i) returns the i-th component as another Path. getFileName() returns the last component, which is Name(NameCount - 1). getParent() returns everything up to but not including the last component. getRoot() returns the root (/ on Unix, C:\ on Windows) for absolute paths and null for relative ones.
A relative path behaves the same way but getRoot() is null:
Path is immutable. Every method that looks like a mutator (resolve, relativize, normalize) returns a new Path and leaves the original alone, the same way String works.
PathThere are three ways to build a Path, but modern code almost always uses the first one.
Output (Linux/macOS):
Path.of was added in Java 11 and is the preferred form. It does exactly what Paths.get does (Paths.get is now a thin wrapper around it), but reads more naturally and matches the pattern used by List.of, Map.of, and similar factories. FileSystems.getDefault().getPath(...) is what both of them call internally. That form is only needed for non-default file systems, like a zip file system, which is covered later in this lesson.
The multi-argument overloads (Path.of("logs", "2024-12-01.log")) join the parts with the platform separator. This is safer than string concatenation because no trailing slash needs to be added manually, and the result will be correct on Windows too.
Path.of(...) is cheap. It parses the string into name elements but doesn't touch the file system. No I/O happens until the path is passed to a method on Files.
Once a Path exists, four methods cover most of the structural work: resolve, relativize, normalize, and toAbsolutePath.
`resolve` joins one path to another. If the argument is absolute, it replaces the receiver entirely. If it's relative, it gets appended.
The third line is a surprise. resolve with an absolute argument doesn't append, it returns the argument. To force appending, use resolve(other.getFileName()) to take only the last component.
`relativize` is the inverse: given two paths, compute the relative path from one to the other.
The result is the path used to refer to file from base. relativize works only when both paths are the same kind (both absolute or both relative); mixing them throws IllegalArgumentException.
`normalize` removes redundant . and .. segments.
normalize is a pure string-level operation. It does not consult the file system, so it won't follow symbolic links. For that, use toRealPath, covered next.
`toAbsolutePath` converts a relative path to an absolute one by anchoring it to the current working directory. It also doesn't touch the file system.
Output (when run from `/home/shop`):
`toRealPath` is the heavyweight version. It hits the file system: it resolves symbolic links, normalizes the result, and requires the file to actually exist (otherwise it throws NoSuchFileException). Use it for the canonical absolute path, for example to compare two paths that might point to the same physical file through different routes.
Output (paths vary by system):
On macOS, /tmp is itself a symlink to /private/tmp, so toRealPath resolves it. On Linux there might be no change. The key point is that toRealPath returns the canonical path the OS would use internally.
Last, equality. Path.equals compares paths lexically. Two paths that point to the same file but are written differently are not equal:
For semantic comparison ("do these point to the same actual file?"), use Files.isSameFile(a, b), which resolves both paths against the file system.
Files API at a GlanceFiles is a static utility class. Almost every method takes a Path (or two) and either returns useful data or performs an operation. The class has dozens of methods, but they group into a small number of categories.
The cyan node is Files itself. The five branches are the categories covered here. Methods in the same branch share a vocabulary, so familiarity with one carries over to the rest. The rest of this lesson walks through them in roughly that order.
Two things to know up front. First, most Files methods throw IOException, which is checked. The options are declaring throws IOException on main (fine for examples) or wrapping the call in try/catch (what production code does). Second, methods that return a Stream (Files.lines, Files.list, Files.walk, Files.find) hold a file handle open and must be closed. Always use them with try-with-resources.
Before reading or writing, code often asks "does this file exist?" or "is this a directory?". Files has predicates for each.
Output (modification time will vary):
Files.exists returns true if the file exists, false if it doesn't, and also false if the JVM can't tell (for example, the path is unreadable due to permissions). There's also Files.notExists, which is not the same as !Files.exists: it returns true only if the file is confirmed to not exist. false from both means "unknown".
Files.isRegularFile and Files.isDirectory answer the direct questions. Files.size returns the file size in bytes as a long. Files.getLastModifiedTime returns a FileTime, which has toInstant() to get a java.time.Instant.
Each of these calls hits the file system. To fetch several properties efficiently, use Files.readAttributes(path, BasicFileAttributes.class), which makes one syscall instead of one per question.
Output (timestamps vary):
One call, one round trip to the OS, every common attribute returned. For loops that touch thousands of files (a directory walk that filters by size, for example), this is faster than calling each predicate separately.
Files has four ways to read a file, and the choice depends on file size and the desired shape of the data.
Five approaches, one file. When to pick each:
| Method | Returns | When to use |
|---|---|---|
Files.readString | String | Small text file, full content in memory |
Files.readAllLines | List<String> | Small text file, lines as a list |
Files.readAllBytes | byte[] | Small binary file, or text as raw bytes |
Files.lines | Stream<String> | Big text file, process lines lazily, can short-circuit |
Files.newBufferedReader | BufferedReader | Full control, custom parsing, mix of read patterns |
Files.readString, Files.readAllLines, and Files.readAllBytes load the entire file into memory. For a 10GB log file, that's a 10GB allocation (or an OutOfMemoryError). Use Files.lines with a stream pipeline for anything that doesn't comfortably fit in heap.
The stream returned by Files.lines holds an open file handle. The try-with-resources is not optional. Leaving it out leaks file descriptors, and on a long-running server that eventually fails with "too many open files".
The writing side mirrors the reading side. Four common methods, picked by what is being written and how large it is.
The last write wins, because each call to Files.write without options truncates the file first. That's a critical default to understand. The order in the example is intentional: the first three writes are all overwritten by the buffered writer at the end. To append instead, pass StandardOpenOption.APPEND.
| Method | Takes | When to use |
|---|---|---|
Files.writeString | String | Small text file, one shot |
Files.write(p, Iterable) | Iterable<String> | List of lines, one shot, automatic line endings |
Files.write(p, byte[]) | byte[] | Binary data or pre-encoded text |
Files.newBufferedWriter | (returns writer) | Large output written in many small pieces, custom format |
Files.write with a List<String> adds a newline after each line (using the platform line separator). Files.writeString writes the string verbatim with no added newlines, leaving line endings up to the caller.
StandardOpenOption.APPEND makes each write tack onto the end instead of truncating.
StandardOpenOption FlagsStandardOpenOption is an enum of flags that control how a file is opened. They're varargs on most Files methods, so as many as needed can be passed. The common ones:
| Option | Effect |
|---|---|
CREATE | Create the file if it doesn't exist |
CREATE_NEW | Create the file, fail if it already exists |
APPEND | Open in append mode (writes go to the end) |
TRUNCATE_EXISTING | Truncate the file to zero length on open |
READ | Open for reading |
WRITE | Open for writing |
DELETE_ON_CLOSE | Delete the file when the stream is closed (good for temp files) |
SYNC / DSYNC | Force writes through the OS cache to disk |
The defaults for Files.writeString and Files.write(Path, byte[]) are CREATE, TRUNCATE_EXISTING, and WRITE. This is why a second call to writeString overwrites the file. For different behavior, pass the options explicitly.
The difference between CREATE and CREATE_NEW matters in concurrent code. CREATE_NEW is atomic at the OS level: either this caller created the file, or another caller did. There's no race. Use it to guarantee unique creation (lock files, "claim this slot" patterns).
The lifecycle methods come in matched pairs that should feel familiar.
Output (paths vary):
Three things worth noting.
createDirectories creates every missing directory in the path, like mkdir -p on Unix. createDirectory only creates the leaf and fails if any parent is missing. Prefer createDirectories in almost every case.
copy and move both fail by default if the target exists. To overwrite, pass StandardCopyOption.REPLACE_EXISTING. To preserve timestamps and permissions on copy, pass StandardCopyOption.COPY_ATTRIBUTES. To require an atomic rename (so observers either see the old file or the new one, never a half-finished state), pass StandardCopyOption.ATOMIC_MOVE.
delete fails with NoSuchFileException if the file is missing. deleteIfExists returns false instead, which suits cleanup code.
The ATOMIC_MOVE option matters for production workflows. Writing a file to a temp location and atomically renaming it into place is a standard pattern for safe file updates: other processes never see a partially-written file.
The cyan source on the left feeds three branches: the default behavior (red, throws an exception when the target exists), REPLACE_EXISTING (green, overwrites), and ATOMIC_MOVE (teal, atomic rename within the same filesystem). The defaults are intentionally conservative because clobbering a file with copy is usually a bug; the destructive behavior must be opted into.
ATOMIC_MOVE only works when source and target are on the same filesystem. Across filesystems, the OS can't do a single rename and the JVM throws AtomicMoveNotSupportedException. Cross-device moves degrade to copy-then-delete, which is not atomic.
Three methods walk a directory and produce a Stream<Path> you can pipe through filter, map, and the rest of the stream API. Each one suits a different use case.
The cyan top node is the choice point. The green branch is the simplest case: one directory, one level deep, like ls without -R. The orange branch is the most common case for recursion: descend the whole tree and process every entry. The teal branch is similar to walk but folds the filter into the call. The red branch is the heaviest tool, only needed when something must happen before entering a directory and something else after leaving it (like recursive delete).
Start with Files.list, which returns only the immediate children of a directory:
Output (paths vary, order is filesystem-dependent):
Files.list does not recurse. The processed directory shows up as a Path, but its contents do not. The order of entries is whatever the OS returns; don't rely on it.
Files.walk recurses to any depth and returns every file and directory in the tree, starting with the root directory itself:
Output (paths vary):
Files.walk(root) produces a stream where each element is a Path. The filtering with Files::isRegularFile weeds out directories. The .endsWith(".jpg") filter is on the string form of the path; for richer patterns, use path.getFileName().toString() or FileSystems.getDefault().getPathMatcher("glob:**.jpg").
The cleanup pattern at the bottom is a useful idiom. Files.walk returns entries in pre-order: a directory comes before its children. To delete a tree, the children must be deleted first, so the order is reversed. Sorting by Comparator.reverseOrder() on Path (which is Comparable<Path>) puts deeper paths first.
Files.find is walk plus a predicate baked into the call. It's slightly more efficient when the filter is known upfront, because the JDK can apply it without building the intermediate stream:
Output (paths vary):
The signature is find(Path start, int maxDepth, BiPredicate<Path, BasicFileAttributes> matcher). maxDepth controls how far to recurse (use Integer.MAX_VALUE for unlimited). The matcher receives both the path and pre-fetched attributes, which saves an extra syscall per entry compared to the equivalent walk + filter.
Streams from Files.list, Files.walk, and Files.find hold an open directory handle. Always close them with try-with-resources. They also walk the directory eagerly enough that very large trees (millions of files) build up state; for those, prefer walkFileTree with a custom FileVisitor.
For full control there's Files.walkFileTree, which uses the visitor pattern. It calls four callbacks: before entering a directory, after visiting a file, on failure, and after leaving a directory. The most common pattern is recursive deletion, where the directory must be deleted _after_ its contents are gone:
Output (paths vary):
SimpleFileVisitor provides default implementations that just return CONTINUE, so only the relevant hooks need to be overridden. The four hooks are preVisitDirectory, visitFile, visitFileFailed, and postVisitDirectory. Each one can return CONTINUE, SKIP_SUBTREE (don't recurse further), SKIP_SIBLINGS (skip the rest of the current directory), or TERMINATE (stop the walk).
walkFileTree is heavier syntactically than walk, but it handles error cases visitors can react to (visitFileFailed) and provides the pre/post hooks needed for tree-shaped operations.
java.io StreamsSome libraries take an InputStream or OutputStream rather than a Path. Files.newInputStream and Files.newOutputStream bridge the gap:
These two methods are the bridge between the modern NIO.2 API and the older java.io ecosystem. Use them when a library requires a stream, but prefer Files.readString / Files.writeString for everyday text work because they're shorter and clearer.
FileSystemEvery Path belongs to a FileSystem. By default that's the default (operating system) filesystem, but Java has a pluggable provider model that lets a JAR file or ZIP file behave as a filesystem. Open it with FileSystems.newFileSystem(Path, Map), get a FileSystem back, and ask it for paths that look just like file paths but read entries inside the archive.
The same Files calls work against entries inside the zip. That's the power of the abstraction: code that uses Path and Files is portable between operating systems and between filesystems. Treat zip filesystems as a useful tool rather than a daily driver; they're handy for reading or modifying a small archive without a separate library.
WatchService asks the OS to notify the application when files in a directory change. It's not push-style: register a directory, then poll the service for events.
The flow is: create a WatchService, register a directory with the kinds of events of interest (ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE), then call watcher.take() (blocks until an event) or watcher.poll(timeout, unit) (waits up to a timeout). Each WatchKey provides a batch of events; after processing, call key.reset() to keep the registration alive.
WatchService watches one directory level. It does not recurse. To watch a tree, walk it once, register every directory, and add registrations for new subdirectories as they're created. On some platforms it's also relatively slow to detect changes (Java polls in the background), so don't use it as a hard real-time signal. It works well for "process new files in this drop folder", not for "alert me within a millisecond".
A short program that combines several of the topics covered above: it creates a workspace, drops a few order files in an incoming folder, walks them, archives processed ones, and prints a summary.
Output (sizes are illustrative):
Nine Files methods, one Stream<Path>, no java.io.File in sight. That's the day-to-day shape of file work in modern Java: assemble paths with Path.of and resolve, do work with Files, close stream-returning calls with try-with-resources, and let exceptions propagate rather than checking booleans.
10 quizzes