Run du -sh on a folder and compare it with Finder's size, and the two often disagree, sometimes by gigabytes. Neither is wrong. They answer different questions: how long the files are, and how much of the disk they occupy. When the disk is full only the second one matters, and getting it right takes more than adding up st_size.

The rules below are the ones the Disk screen in the osFoundry desktop app follows. Its scanner is a short Rust function over std::fs, and the same logic fits in about 40 lines of Python. All the numbers come from a test folder on an APFS volume with 4 KB allocation blocks.

A disk map of a sample home folder, sized by allocated blocks

Count blocks, not bytes

st_size is the file's length. st_blocks is how many 512-byte units the file system has allocated to it, whatever the volume's real block size is. The app's scanner uses the second by default:

fn allocated_bytes(meta: &fs::Metadata, apparent: bool) -> u64 { if apparent { return meta.len(); } #[cfg(unix)] { use std::os::unix::fs::MetadataExt; meta.blocks().saturating_mul(512) } #[cfg(not(unix))] { meta.len() } } 

Three files in the test folder show why:

File st_size st_blocks × 512
small.txt (2 bytes of text) 2 4,096
sparse.img (truncated to 1 GiB, never written) 1,073,741,824 0
big.bin (200 MiB of data) 209,715,200 209,715,200

A tiny file still takes a full block, so a folder of 100,000 small files is several hundred megabytes bigger on disk than its byte count. A sparse file is the reverse: VM disks, database files and some downloads reserve a large length and only use blocks for the parts that have been written. macOS's du counts blocks; du -A prints the apparent size instead. The Apparent size box in the app does the same switch.

The Size, Files and Age modes and the Apparent size switch

Don't follow links, don't leave the volume

A scanner that follows symbolic links can count the same folder twice, or loop forever on a link that points to its own parent. One that walks into a mounted drive adds a different disk's files to this disk's total. The app reads each entry with symlink_metadata, which describes the link rather than its target, and compares device IDs with the folder the scan started from:

if child_meta.file_type().is_symlink() || !same_device(root_meta, &child_meta) { continue; } 

same_device compares st_dev, which is the same check as du -x or find -xdev.

Hard links and clones

Hard links are one file with several names. On the test folder, ln big.bin hard.bin makes ls -l show two 200 MiB files, but du counts the data once because it remembers each (st_dev, st_ino) pair it has seen whenever st_nlink is above 1. Any scanner that sums blocks should do the same. Time Machine backups on HFS+ disks and some package managers rely on hard links heavily.

APFS clones are harder. cp -c big.bin clone.bin (or Duplicate in Finder) creates a second file that shares the original's blocks until one of them changes. Each file has its own inode, its own st_blocks and a link count of 1, so du counts 400 MiB for the pair. Measured with df before and after, the clone took 0 KB of free space, while a normal cp of the same file took 204,984 KB. No POSIX field shows the sharing, so a block-based total overstates what deleting a clone gives back. For folders full of duplicated projects or photo libraries, df before and after is the only reliable check.

A Python version

The same rules with only the standard library:

import os import stat import sys def usage(root): root_dev = os.lstat(root).st_dev seen = set() allocated = apparent = 0 skipped = 0 stack = [root] while stack: path = stack.pop() try: entries = list(os.scandir(path)) except OSError: skipped += 1 continue for e in entries: try: st = e.stat(follow_symlinks=False) except OSError: skipped += 1 continue if stat.S_ISLNK(st.st_mode) or st.st_dev != root_dev: continue if stat.S_ISDIR(st.st_mode): stack.append(e.path) continue if st.st_nlink > 1: key = (st.st_dev, st.st_ino) if key in seen: continue seen.add(key) allocated += st.st_blocks * 512 apparent += st.st_size return allocated, apparent, skipped if __name__ == "__main__": alloc, appar, skipped = usage(sys.argv[1]) print(f"on disk {alloc / 2**30:8.2f} GiB") print(f"apparent {appar / 2**30:8.2f} GiB") if skipped: print(f"skipped {skipped} unreadable entries") 

On the test folder it prints 0.39 GiB on disk and 1.39 GiB apparent, matching du -sk (409,604 KB) and du -sAk. On a 14 GiB source tree it agrees with du -sk to two decimal places. Keep the count of skipped entries: on macOS, folders protected by privacy permissions (Mail, Messages, Safari) can't be read without Full Disk Access, and a total that silently leaves them out looks more complete than it is.

The app keeps the result small enough to draw. Each folder keeps its 36 largest children, and the rest are summed into a single "Other items" node, so opening a node_modules folder with a thousand packages shows 36 boxes and one more for everything else, not a thousand slivers.

Deleting safely

A disk tool's real risk is in the delete step. The app moves items to the system Trash with the trash crate rather than unlinking them, and checks every path before it moves anything:

if !target.starts_with(&root) || target == root || home.as_ref() == Some(&target) { return Err("Cannot move a scan root or home folder to Trash".into()); } if mount_points .iter() .any(|mount| target == *mount || target.parent() == Some(mount.as_path())) { return Err("Cannot move a volume or its top-level items to Trash".into()); } 

Paths are canonicalized first, so .. or a symlink can't sneak a target outside the scanned folder. A selection where one marked folder contains another is rejected as well, because once the parent is in the Trash the child's path no longer exists, and the batch would fail halfway.

The review step: every marked path and size before Move to Trash

The desktop app is free from the osFoundry download page.