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.
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.
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 desktop app is free from the osFoundry download page.



Top comments (0)