File I/O & NIO.2

Files, Paths & the File System

15 min Lesson 1 of 13

Files, Paths & the File System

Java has two generations of file system APIs. The original java.io.File class has existed since Java 1.0. The modern java.nio.file package — often called NIO.2 — arrived in Java 7 and completely supersedes it. Understanding both generations, and why the older one falls short, is the foundation for everything else in this tutorial.

The Legacy java.io.File Class

Before NIO.2, every file system operation went through java.io.File. A File object is essentially just a string wrapper around a path — it does not check whether the path actually exists when you construct it.

import java.io.File; File f = new File("/home/alice/notes.txt"); System.out.println(f.exists()); // false if the file is not there System.out.println(f.getAbsolutePath()); // /home/alice/notes.txt System.out.println(f.length()); // 0 if the file does not exist System.out.println(f.isDirectory()); // false File dir = new File("/home/alice"); String[] names = dir.list(); // null if not a directory or I/O error

The API looks simple, but it has several serious design problems that trip up almost every developer who uses it long enough:

  • Boolean-return error reporting. Most mutating methods (mkdir(), delete(), renameTo()) return false on failure without any reason. You have no idea whether the operation failed because of permissions, a missing parent directory, a race condition, or something else entirely.
  • Platform-dependent path separators. Using hard-coded "/" or "\\" breaks portability. File.separator helps, but it is easy to forget.
  • No symbolic link awareness. File silently follows symbolic links in ways that can surprise you.
  • Incomplete directory listing. list() returns a plain String[] or a File[] — fine for small directories, but it loads everything into memory at once, which is problematic for directories with millions of entries.
  • No metadata access. You cannot read file permissions, creation time, or owner in a portable way.
renameTo() is notoriously unreliable. It can silently fail when source and destination are on different file system volumes, or when the destination already exists. On some JVMs it also fails across network-mounted drives. Always check the return value, and prefer NIO.2's Files.move() instead.

The Modern NIO.2 API: Path and Files

NIO.2 splits the concept into two clean abstractions:

  • java.nio.file.Path — a pure value object representing a path string. It knows about the file system's syntax (separators, roots, relative vs absolute) but does not perform I/O itself.
  • java.nio.file.Files — a utility class of static methods that perform the actual I/O operations, throwing checked IOException (or its subtypes) on failure so you always know what went wrong.

You obtain a Path through the factory method Path.of() (Java 11+) or the older Paths.get():

import java.nio.file.Path; // Absolute path Path absolute = Path.of("/home/alice/notes.txt"); // Relative path Path relative = Path.of("docs", "readme.txt"); // docs/readme.txt // From a URI Path fromUri = Path.of(URI.create("file:///tmp/data.csv")); System.out.println(absolute.getFileName()); // notes.txt System.out.println(absolute.getParent()); // /home/alice System.out.println(absolute.isAbsolute()); // true System.out.println(relative.isAbsolute()); // false
Path.of() vs Paths.get(): Both return the same object. Path.of() was added in Java 11 as a convenience method directly on the interface. In modern codebases (Java 11+) prefer Path.of() — it is more readable and does not require importing a separate class.

Path Navigation and Resolution

One of Path's biggest advantages over raw strings is its built-in path arithmetic. You can navigate the file system tree without string concatenation:

Path base = Path.of("/home/alice"); Path config = base.resolve("config/app.yaml"); // /home/alice/config/app.yaml Path parent = config.getParent(); // /home/alice/config Path name = config.getFileName(); // app.yaml Path root = config.getRoot(); // / // Relativize — the reverse of resolve Path a = Path.of("/home/alice/docs/report.pdf"); Path b = Path.of("/home/alice"); System.out.println(b.relativize(a)); // docs/report.pdf // Normalize removes redundant . and .. Path messy = Path.of("/home/alice/../alice/./docs"); System.out.println(messy.normalize()); // /home/alice/docs // toAbsolutePath resolves against the current working directory Path rel = Path.of("notes.txt"); System.out.println(rel.toAbsolutePath()); // e.g. /home/alice/notes.txt

Checking Existence and File Attributes

The Files class provides predicate methods that correspond to the old File methods, plus richer attribute access:

import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.BasicFileAttributes; import java.io.IOException; Path p = Path.of("/home/alice/notes.txt"); System.out.println(Files.exists(p)); // true / false System.out.println(Files.isDirectory(p)); // false System.out.println(Files.isReadable(p)); // true System.out.println(Files.isWritable(p)); // true System.out.println(Files.size(p)); // file size in bytes BasicFileAttributes attrs = Files.readAttributes(p, BasicFileAttributes.class); System.out.println(attrs.creationTime()); // e.g. 2024-03-15T10:22:00Z System.out.println(attrs.lastModifiedTime()); // last write time System.out.println(attrs.isSymbolicLink()); // link awareness
Prefer Files.exists() over catching exceptions for control flow. If you expect a file might not exist, check with Files.exists() first. Reserve IOException handling for truly exceptional I/O failures — disk full, permissions errors — not routine path-not-found checks.

Converting Between File and Path

Legacy code that uses java.io.File is everywhere — old libraries, third-party APIs, Android pre-NIO wrappers. You will frequently need to bridge both worlds:

import java.io.File; import java.nio.file.Path; // File -> Path File legacyFile = new File("/home/alice/notes.txt"); Path modern = legacyFile.toPath(); // Path -> File Path nioPath = Path.of("/home/alice/notes.txt"); File asFile = nioPath.toFile(); // works only for default-FS paths // Both refer to the same file system entry System.out.println(modern.equals(nioPath)); // true

The FileSystem and FileSystems

Behind both Path and Files sits a java.nio.file.FileSystem — an abstraction that lets you work with ZIP archives, in-memory file systems (for testing), and remote file systems through the same API. The default file system (your OS disk) is obtained via FileSystems.getDefault(). You will not need this directly in most application code, but it explains why Path.of() is actually a shorthand for FileSystems.getDefault().getPath().

Summary

The legacy java.io.File class is a fragile path wrapper with silent failure modes, no rich metadata, and no symbolic link control. The modern NIO.2 pair — Path (value object for paths) and Files (I/O operations with proper exceptions) — solves every one of those shortcomings. In all new code, use Path.of() and Files.*. When maintaining old code that exposes File, convert immediately with file.toPath() and work in NIO.2 from that point forward.