---
title: 'File System API'
source: 'https://academia.sh/en/courses/java-standard-library/file-system-api'
course: 'The Standard Library and Streams'
language: en
updated: '2026-08-17T18:09:43+00:00'
license: 'CC BY-SA 4.0'
---

# File System API

That a path is a parsed value, and that normalization and resolution answer without ever touching the file system, is shown by running it on two separate providers. It is measured that directory traversal order is unspecified, and that existence-checking and opening are two separate operations.

The previous lesson concerned content passing through an input/output stream. But there is
one more question before a stream is even opened: does the thing at the stream's end really
exist, and what does the path leading to it say? This lesson looks at the file system API —
it measures that a path is itself a parsed value, which operations on that value answer
without ever touching the file system, and which ones really reach down to disk.

A `Path` is not a string. The text `"a/../b"` looks like something held inside a string, but
the methods called on it follow the rules not of a text-processing library, but of a **file
system provider**. This lesson's question is asked the same way as the course's: which
behavior is independent of provider choice — that is, the interface's promise; which cannot
be answered without asking the provider's own storage — that is, a storage query; and which
guarantee no method gives at all, one obtained only by making calls in the right order.

Four sections proceed in order. The first two separate two kinds of methods called on the same
path object: those that never touch storage, and those that really ask storage. The third
measures where a directory's listing order comes from, and what it does not guarantee. The
fourth shows the cost of splitting the promise a single method gives into two separate calls.
All four land on the same principle: it is not a method's name, it is when and which resource
it asks, that decides the promise that method gives.

## Path Algebra: Never Touching the File System

- **IO10** — The same three operations — normalization, resolution, file-name separation —
  are run on two separate file system providers: the default local provider, and the standard
  library's own zip provider (a provider, part of the standard library, that opens a zip
  archive like a file system).
- **IO11** — What is compared is the **equality** of two results; no path string on its own
  needs interpretation, because the results are already short, human-readable strings. The
  same measurement is repeated for `equals` and `compareTo`, and it is also checked what
  `equals` says when two paths, textually identical, come from different providers.

For this measurement, what is compared is not two separate classes, it is two separate
**providers**: the default local provider, and the standard library's own zip provider, a
`FileSystemProvider` implementation that opens a zip archive like a file system. In the
course's previous lessons, "implementation" meant choosing between a `List` or a `Map`; here,
provider choice plays the same role — which file system you are running behind does not
change the methods you call on the `Path` interface.

```java
// ProviderTrial.java — is path algebra the same across two file system providers
import java.net.URI;
import java.nio.file.*;
import java.util.Map;

public class ProviderTrial {
    public static void main(String[] args) throws Exception {
        Path archive = Path.of("archive.zip");
        Files.deleteIfExists(archive);
        try (FileSystem zip = FileSystems.newFileSystem(
                URI.create("jar:" + archive.toUri()), Map.of("create", "true"))) {

            System.out.printf("%-14s%-20s%-20s%s%n", "operation", "local", "zip", "same");

            String localNorm = Path.of("a/./b/../c").normalize().toString();
            String zipNorm = zip.getPath("a/./b/../c").normalize().toString();
            System.out.printf("%-14s%-20s%-20s%s%n", "normalize", localNorm, zipNorm,
                    localNorm.equals(zipNorm));

            String localResolve = Path.of("root").resolve("sub/file.txt").toString();
            String zipResolve = zip.getPath("root").resolve("sub/file.txt").toString();
            System.out.printf("%-14s%-20s%-20s%s%n", "resolve", localResolve, zipResolve,
                    localResolve.equals(zipResolve));

            String localName = Path.of("dir/file.txt").getFileName().toString();
            String zipName = zip.getPath("dir/file.txt").getFileName().toString();
            System.out.printf("%-14s%-20s%-20s%s%n", "getFileName", localName, zipName,
                    localName.equals(zipName));

            String localRelative = Path.of("a/b/c").relativize(Path.of("a/x")).toString();
            String zipRelative = zip.getPath("a/b/c").relativize(zip.getPath("a/x")).toString();
            System.out.printf("%-14s%-20s%-20s%s%n", "relativize", localRelative, zipRelative,
                    localRelative.equals(zipRelative));

            boolean localEqual = Path.of("a/b").equals(Path.of("a/b"));
            boolean zipEqual = zip.getPath("a/b").equals(zip.getPath("a/b"));
            System.out.printf("%-14s%-20s%-20s%s%n", "equals (own)", localEqual, zipEqual,
                    localEqual == zipEqual);

            boolean localOrder = Path.of("a/b").compareTo(Path.of("a/c")) < 0;
            boolean zipOrder = zip.getPath("a/b").compareTo(zip.getPath("a/c")) < 0;
            System.out.printf("%-14s%-20s%-20s%s%n", "compareTo<0", localOrder, zipOrder,
                    localOrder == zipOrder);

            System.out.println("local a/b equal to zip a/b (same text, different provider): "
                    + Path.of("a/b").equals(zip.getPath("a/b")));
        }
        Files.deleteIfExists(archive);
    }
}
```

```
operation     local               zip                 same
normalize     a/c                 a/c                 true
resolve       root/sub/file.txt   root/sub/file.txt   true
getFileName   file.txt            file.txt            true
relativize    ../../x             ../../x             true
equals (own)  true                true                true
compareTo<0   true                true                true
local a/b equal to zip a/b (same text, different provider): false
```

All six of the six operations give byte-for-byte identical results on both providers.
`normalize` collapses `.` and `..` segments by the same rule, `resolve` appends one path onto
another the same way, `relativize` computes the relative path between two paths the same way,
and `equals` and `compareTo` also compare the segment list. **None** of these six operations,
while running, looks at the provider's storage — the zip archive is still empty, the local
directory has nothing under these names either, and yet all six produce a result. Because the
result is provider-independent, this behavior is `Path`'s own interface promise: path algebra
is a pure computation working on a list of segments, and never asks about the storage's
content.

The last line shows this promise's boundary. Two paths, textually identical — one from the
local provider, one from the zip provider — are **not equal** when compared with `equals`.
`Path.equals` looks not only at the segments, but also at which provider they belong to; this,
too, is a provider-independent rule, because both providers enforce this restriction the same
way. The promise path algebra gives is not "the same segments produce the same result," it is
"the same segments on the same provider produce the same result."

This purity has a measurable consequence: a rule built on top of path algebra — validating an
extension, blocking an escape into a parent directory, computing a relative path — can be
tested without creating a single file, without setting up a single directory. The measurement
above shows this itself: the zip archive was opened empty, the local directory had nothing
under the relevant names, and all six of the six comparisons completed without error. Had this
been an operation touching storage, the same measurement would first have required a setup.

## The Same Question's Answer That Touches Storage

- **IO12** — On the same two providers, `Files.exists` is called this time. A record is
  written first to the path on the zip provider, while the same-named file on the local
  provider is never created at all.

```java
// TouchingStorage.java — operations touching storage ask the provider, path algebra never does
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.Map;

public class TouchingStorage {
    public static void main(String[] args) throws Exception {
        Path archive = Path.of("archive.zip");
        Files.deleteIfExists(archive);
        try (FileSystem zip = FileSystems.newFileSystem(
                URI.create("jar:" + archive.toUri()), Map.of("create", "true"))) {

            Path zipFile = zip.getPath("record.txt");
            Files.writeString(zipFile, "data", StandardCharsets.UTF_8);

            Path localFile = Path.of("record.txt");
            Files.deleteIfExists(localFile);

            System.out.printf("%-14s%s%n", "provider", "Files.exists result");
            System.out.printf("%-14s%s%n", "local", Files.exists(localFile));
            System.out.printf("%-14s%s%n", "zip", Files.exists(zipFile));
        }
        Files.deleteIfExists(archive);
    }
}
```

```
provider      Files.exists result
local         false
zip           true
```

The same name gives two separate answers on two separate providers — and this is not a
defect, it is proof of what `Files.exists` really does. Unlike the six operations in the
previous section, `exists` really asks the provider's own storage; the answer can be "no" or
"yes," and that answer depends on what is in storage at that moment. `Files.newInputStream`,
`Files.size`, `Files.readAllBytes`, `Files.isDirectory` belong to the same family: all of them
go to the provider's storage the moment they are called. A shortcut for telling whether a
method called on a path object touches storage: if the result can be produced even while
storage is empty, it is algebra; if it requires storage to hold something, it is a storage
query.

This distinction has a practical consequence: a path object can safely be built, stored, and
combined with other paths even while the file it points to does not exist at all. Naming an
output directory in advance with `resolve`, before it is ever written, blows up nothing at
all, because algebra never asks storage. A problem can only surface once a call touching
storage arrives — `Files.createDirectories` or `Files.newOutputStream`, say — and at that
point, exactly where to look for the error is clear, because the methods touching storage form
a bounded, named set.

Every answer that touches storage also has a time dimension: `exists` gives an answer that is
correct at the **moment** it is called; it gives no promise that the answer stays correct
after the call ends. Path algebra, in this respect, carries a stronger guarantee — once
`normalize` is computed, it never becomes invalid again, because it never looked at storage at
all. A storage query's result, in turn, is a snapshot, and this lesson's last section measures
exactly how short-lived that snapshot is.

## Directory Traversal Order Is Unspecified

- **IO13** — Four files are created in a directory in a given order. The traversal order
  obtained with `Files.list` is compared against both the creation order and alphabetical
  order.
- **IO14** — The traversal order itself is not printed; only the two comparisons' results and
  whether two separate traversal methods agree with each other are printed. What `Files.list`
  returns is also a resource — it holds a directory handle behind it — and is opened with
  try-with-resources (Object-Oriented Java, `java-generics/04`).

```java
// TraversalOrder.java — directory traversal order is unspecified, ordering is up to the caller
import java.nio.file.*;
import java.util.*;
import java.util.stream.Collectors;

public class TraversalOrder {
    public static void main(String[] args) throws Exception {
        Path dir = Path.of("traversal");
        Files.createDirectories(dir);
        List<String> creationOrder = List.of("many.txt", "few.txt", "mid.txt", "one.txt", "top.txt", "low.txt");
        for (String name : creationOrder) Files.createFile(dir.resolve(name));

        List<String> traversalList;
        try (var stream = Files.list(dir)) {
            traversalList = stream.map(p -> p.getFileName().toString()).collect(Collectors.toList());
        }
        List<String> traversalDirStream = new ArrayList<>();
        try (DirectoryStream<Path> dirStream = Files.newDirectoryStream(dir)) {
            for (Path p : dirStream) traversalDirStream.add(p.getFileName().toString());
        }
        List<String> alphabetical = traversalList.stream().sorted().collect(Collectors.toList());

        System.out.println("traversal order same as creation order: "
                + traversalList.equals(creationOrder));
        System.out.println("traversal order same as alphabetical order: "
                + traversalList.equals(alphabetical));
        System.out.println("does Files.list give the same order as the directory stream: "
                + traversalList.equals(traversalDirStream));

        for (String name : creationOrder) Files.deleteIfExists(dir.resolve(name));
        Files.deleteIfExists(dir);
    }
}
```

```
traversal order same as creation order: false
traversal order same as alphabetical order: false
does Files.list give the same order as the directory stream: true
```

Traversal order matches neither creation order nor alphabetical order; the provider lists its
own storage according to its own internal layout, and this layout is not a documented promise
of `Files.list`. In contrast, `Files.list` (a method returning a stream) and
`Files.newDirectoryStream` (an iterator interface, in the input/output-stream sense, returning
a directory stream) give the **same** order on the same traversal — because both wrap the same
underlying listing call from the same provider. The two APIs agreeing with each other does not
mean traversal has a **documented** order; it only shows they rest on the same provider call. A
caller wanting a sorted result has to sort the list itself; the API gives no such promise
anywhere.

The source of the uncertainty is the provider's own storage structure. A directory entry is
mostly held not by name, but by the file system's own internal indexing scheme, and listing
walks that internal layout as is. `Files.list`'s documentation never commits to this layout at
all; the only thing it commits to is that every entry appears in the list **exactly once**.
This has the same shape as the previous lesson's buffering measurement: silently assuming a
promise the API never gave builds a dependency that goes unnoticed as long as the code runs,
and surfaces only once it is moved elsewhere.

## Existence-Checking and Opening Are Two Separate Operations

This section turns the previous section's last sentence into a numeric measurement.
`Files.exists` and `Files.newInputStream` are two separate storage queries, and the time
passing between them is not zero; code running in that gap can change storage's state. The
measurement sets this up with a real deletion, but the source does not matter — the same gap
could just as well be filled by another process, another thread, or the user themselves.

- **IO15** — A file is written, its existence is checked with `Files.exists`, and right after,
  the file is deleted — standing in for another code path stepping in. After the check, an
  attempt is made to open the file.
- **IO16** — For comparison, the same open call is also tried directly on a file that never
  existed at all; it is seen that both errors come from the same class.

```java
// TwoSteps.java — checking existence and opening are two separate operations, a gap sits between them
import java.nio.file.*;

public class TwoSteps {
    public static void main(String[] args) throws Exception {
        Path file = Path.of("temp-record.txt");
        Files.writeString(file, "record");

        boolean checkResult = Files.exists(file);
        Files.delete(file);

        String openResult;
        try {
            Files.newInputStream(file).close();
            openResult = "opened";
        } catch (NoSuchFileException e) {
            openResult = "could not open: " + e.getClass().getSimpleName();
        }
        System.out.printf("%-18s%s%n", "check result", checkResult);
        System.out.printf("%-18s%s%n", "open result", openResult);

        String directResult;
        try {
            Files.newInputStream(Path.of("does-not-exist.txt")).close();
            directResult = "opened";
        } catch (NoSuchFileException e) {
            directResult = "could not open: " + e.getClass().getSimpleName();
        }
        System.out.printf("%-18s%s%n", "direct open", directResult);
    }
}
```

```
check result      true
open result       could not open: NoSuchFileException
direct open       could not open: NoSuchFileException
```

Right after the check returns `true`, opening fails, because the file was deleted in between.
This defect does not come from `Files.exists` lying — the check gave the correct answer at the
moment it was called. The defect comes from the check and the open being **two separate
calls**, and from something else being able to change storage in the time passing between
them. The last line confirms this: trying to open a file that never existed at all directly
also drops the same exception, from the same class. The API already offers the promise "open
or give an error" in a **single call**; it is the caller who adds the checking step in front
of it, and that extra step buys no safety, it only widens the gap. The right order is to open
without checking, and catch the open's own exception.

This is not a gap in the library — the interface is already designed to carry both the result
and the error in a single call. The gap only appears once the caller separates the two calls
and puts other code between them; no matter how short it looks, every line sitting between the
two steps widens that gap. This is the plainest form of what the course calls the "caller's
obligation": the library said nothing wrong, the caller used correct information at the wrong
time.

## Summary

- `Path` is not a string, it is a parsed value; `normalize`, `resolve`, `getFileName`,
  `relativize`, `equals`, and `compareTo` run without ever touching the file system, and this
  behavior is the same on two separate providers.
- Two textually identical paths from different providers are not equal with `equals`; equality
  looks not just at the segments, but at the segments plus the provider.
- Operations like `Files.exists`, `Files.size`, `Files.readAllBytes` really ask the provider's
  storage; the answer depends on storage's real state and can change with the provider.
- Directory traversal order matches neither creation order nor alphabetical order, and the API
  documents this order nowhere; a caller wanting a sorted result has to sort the list itself.
- Two separate traversal methods giving the same order does not mean the order is documented —
  it only shows they rest on the same provider call.
- Bounding measurement: existence-checking and opening are two separate operations. If the
  file is deleted in between, the check passes and the open falls; the API already offers
  "open or give an error" in a single call, and the extra check is a gap the caller added
  themselves.

## Next Step

This lesson measured whether a file exists, and which bytes it carries — but never asked what
happens once an object itself is turned into bytes. The next lesson looks at serialization: it
measures that when an object is dumped to a byte array and read back, the object's constructor
never runs at all, and that the invariants a class establishes can, for this reason, be broken
by bytes given from outside.
