File Handling in Java with Path and Files

Path names a location and Files performs the operations. Together they replaced the older File class for almost every purpose.

Path and Files

java.nio.file.Path represents a location in a file system. It is only a name: creating one touches nothing on disk. java.nio.file.Files is the utility class that actually reads, writes, copies and deletes.

Path path = Path.of("data", "notes", "export.txt");
Path absolute = path.toAbsolutePath();
Path home = Path.of(System.getProperty("user.home"));

System.out.println(path.getFileName());     // export.txt
System.out.println(path.getParent());       // data/notes
System.out.println(path.getNameCount());    // 3
System.out.println(path.resolve("v2.txt")); // data/notes/export.txt/v2.txt
System.out.println(path.resolveSibling("backup.txt"));
System.out.println(path.normalize());
Path.of builds a platform correct path from its parts, so no separator is ever hard coded. That alone removes a large class of portability bugs.

Reading a whole file

Path path = Path.of("notes.txt");

String content = Files.readString(path);                    // Java 11, UTF-8
List<String> lines = Files.readAllLines(path);
byte[] bytes = Files.readAllBytes(path);

These load everything into memory, which is fine for configuration and small documents and wrong for large files.

Reading line by line

try (BufferedReader reader = Files.newBufferedReader(path)) {
    String line;
    while ((line = reader.readLine()) != null) {
        process(line);
    }
}

try (Stream<String> lines = Files.lines(path)) {
    long blanks = lines.filter(String::isBlank).count();
}

Files.lines is lazy and holds an open file handle, so it must be closed. Using it inside try with resources is not optional.

Writing

Files.writeString(path, "First line" + System.lineSeparator());
Files.write(path, List.of("a", "b", "c"));
Files.write(path, bytes);

Files.writeString(path, "appended
", StandardOpenOption.CREATE,
        StandardOpenOption.APPEND);

try (BufferedWriter writer = Files.newBufferedWriter(path)) {
    for (Note note : notes) {
        writer.write(note.title());
        writer.newLine();
    }
}
Open optionEffect
CREATECreate the file if it does not exist
CREATE_NEWCreate, and fail if it already exists
APPENDWrite at the end
TRUNCATE_EXISTINGEmpty the file first, the default for writing
DELETE_ON_CLOSERemove the file when the stream closes

Checking and inspecting

System.out.println(Files.exists(path));
System.out.println(Files.notExists(path));      // not the same as !exists
System.out.println(Files.isDirectory(path));
System.out.println(Files.isReadable(path));
System.out.println(Files.size(path));
System.out.println(Files.getLastModifiedTime(path));
System.out.println(Files.probeContentType(path));

exists and notExists can both be false when the status cannot be determined, for example because of permissions.

Creating, copying, moving, deleting

Files.createDirectories(Path.of("data", "exports"));   // creates missing parents
Files.createFile(Path.of("data", "exports", "run.log"));

Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);

Files.delete(path);              // throws if missing
Files.deleteIfExists(path);      // returns false if missing

Walking a directory tree

try (Stream<Path> entries = Files.list(directory)) {        // one level only
    entries.filter(Files::isRegularFile).forEach(System.out::println);
}

try (Stream<Path> tree = Files.walk(directory)) {           // recursive
    long javaFiles = tree
            .filter(Files::isRegularFile)
            .filter(p -> p.toString().endsWith(".java"))
            .count();
}

try (Stream<Path> found = Files.find(directory, 5,
        (p, attrs) -> attrs.isRegularFile() && attrs.size() > 1_000_000)) {
    found.forEach(System.out::println);
}

All three return streams that hold operating system resources and must be closed.

Character encoding

String content = Files.readString(path);                          // UTF-8
String legacy = Files.readString(path, StandardCharsets.ISO_8859_1);

Files.writeString(path, text, StandardCharsets.UTF_8);

The java.nio.file methods default to UTF-8. Older java.io classes used the platform default charset, which is why the same code could produce different results on different machines. Since Java 18 UTF-8 is the platform default for those too, but stating the charset explicitly is still the safer habit.

A worked example

public static Map<String, Integer> countWords(Path path) throws IOException {
    Map<String, Integer> counts = new TreeMap<>();

    try (Stream<String> lines = Files.lines(path)) {
        lines.flatMap(line -> Arrays.stream(line.toLowerCase().split("[^a-z]+")))
             .filter(word -> !word.isBlank())
             .forEach(word -> counts.merge(word, 1, Integer::sum));
    }
    return counts;
}

Writing safely

public static void writeAtomically(Path target, String content) throws IOException {
    Path temporary = target.resolveSibling(target.getFileName() + ".tmp");

    Files.writeString(temporary, content);
    Files.move(temporary, target,
            StandardCopyOption.REPLACE_EXISTING,
            StandardCopyOption.ATOMIC_MOVE);
}

Writing to a temporary file and then moving it means a reader never sees a half written file. This pattern is worth knowing for configuration and export files.

The old File class

File old = new File("notes.txt");
Path modern = old.toPath();          // convert when working with legacy APIs
File back = modern.toFile();

java.io.File reports failures by returning false rather than explaining what went wrong. Files throws a specific exception with a message. Use Path and Files in new code and convert only at the boundary of an older library.

Common mistakes

  • Not closing a Files.lines, walk or list stream.
  • Reading a large file entirely into memory.
  • Hard coding path separators instead of using Path.of or resolve.
  • Ignoring the charset and getting different results on different systems.
  • Assuming Files.write appends. It truncates unless APPEND is given.
  • Using File.delete() and ignoring the boolean result.

Best practices

  • Use Path and Files rather than File.
  • Wrap every stream returning method in try with resources.
  • State the charset, or rely on the UTF-8 default of the Files methods.
  • Stream large files rather than loading them.
  • Use createDirectories before writing into a new folder.
  • Write to a temporary file and move it when the result must never be partial.

Practice

  1. Count the lines in a file without loading it into memory.
  2. Why must a Files.lines stream be closed when a List from readAllLines need not be?
  3. Copy every file with a given extension from one folder into another.
  4. Explain the difference between Files.write with and without StandardOpenOption.APPEND.
  5. Implement an atomic write and describe the failure it prevents.

Conclusion

Path names a location and Files does the work. Prefer them to File, close every stream that touches the file system, be explicit about encoding, and stream anything large.

Useful resources

Hand picked references for this topic
Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Java notes →
Java

Introduction to Java

Java is a statically typed, object oriented language that compiles to bytecode and runs on a virtual machine, which is what makes it portable.

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.