APFS Firmlinks and the Split Volume Design

Background: why the split exists

Starting with macOS Catalina (10.15), Apple split the OS into two APFS volumes:

  • System volume (Macintosh HD): read-only, contains the OS itself
  • Data volume (Macintosh HD - Data): read-write, contains everything mutable

The motivation was the APFS - Sealed System Volume (SSV): Apple wanted to cryptographically verify the integrity of every OS file at boot. You can only do that with a read-only volume. But users and apps need /Applications, /Users, /Library, /var etc. to be writable. The solution was to put the OS on one volume and all mutable content on another, then stitch them together so it looks like one filesystem.

A firmlink is a kernel-level path redirection, implemented inside the APFS VFS layer. It is stored as a special inode type on the System volume. When the kernel resolves a path that hits a firmlink inode, it transparently redirects the traversal to the corresponding path on the Data volume.

It is not a symlink:

  • A symlink is a file containing a path string; any process resolving it sees a redirect at the readlink level
  • A firmlink has no path string visible to userspace; the redirection happens inside vfs_lookup before it ever returns to userspace

It is not a bind mount:

  • A bind mount creates a new vfsmount entry; tools like mount can see it; st_dev changes at the mount point
  • A firmlink does not create a new mount point; mount doesn’t see it; st_dev does not change when you cross a firmlink (this is the critical fact that breaks du)

The firmlink table is stored at /usr/share/firmlinks on the System volume:

/Applications    Applications
/Users           Users
/Library         Library
/System/Library/Staged Extensions    System/Library/Staged Extensions
...

Left side = path on the System volume. Right side = path on the Data volume (relative to its root).

du detects filesystem boundaries by comparing st_dev (device number) of inodes. When it crosses a mount point, st_dev changes, and du -x stops there.

Firmlinks do not change st_dev. When du walks /Users from the System volume, it is actually reading the Data volume’s /Users directory — but st_dev stays the same as the System volume’s device number, because no mount point was crossed. So du -x / will walk into Data volume content as if it were on the System volume.

Then if you also du -x /System/Volumes/Data, you walk the same /Users directory again, this time from the Data volume’s root. You’ve counted it twice.

Why du fails at the Data volume root

Firmlinks are bidirectional: /Users on the root points into the Data volume, and /System/Volumes/Data/Users points back. When du encounters a firmlink target at /System/Volumes/Data/, it treats it as a boundary and refuses to descend. This is not a permissions issue — it’s structural.

# This returns almost nothing — firmlinks block traversal
sudo du -hd1 /System/Volumes/Data/
 
# This works — you enter the firmlink target directly
sudo du -sh /System/Volumes/Data/Users/
sudo du -sh /System/Volumes/Data/Library/

To see which directories are firmlinked:

cat /usr/share/firmlinks

Correct approach for disk accounting

Enumerate each top-level directory on the Data volume individually:

sudo du -sh /System/Volumes/Data/Applications/ \
           /System/Volumes/Data/Library/ \
           /System/Volumes/Data/Users/ \
           /System/Volumes/Data/private/ \
           /System/Volumes/Data/opt/ \
           /System/Volumes/Data/usr/ \
           /System/Volumes/Data/System/ \
           /System/Volumes/Data/.DocumentRevisions-V100/ \
           /System/Volumes/Data/.Spotlight-V100/ \
           /System/Volumes/Data/.fseventsd/ \
           /System/Volumes/Data/cores/ 2>/dev/null

Commands that do NOT work

  • du -hd1 /System/Volumes/Data/ — firmlinks block traversal at root
  • du -hxd2 /System/Volumes/Data/-x does not help with firmlinks
  • Any du that starts at the Data volume root and expects to recurse

The correct mental model

Think of the System volume and Data volume as two overlay layers, with the System volume providing the immutable OS skeleton and the Data volume providing the mutable flesh. When you’re at a terminal, / is the stitched-together view. But for disk accounting purposes, they are separate volumes.

Even after correctly enumerating all directories, the total from du may be far less than what diskutil apfs list reports. The gap can be deleted-but-open files (processes holding file descriptors to deleted files), snapshot-retained blocks, or APFS metadata.

See also