← Back to Wiki
Linux / Migrations

du -sh ~/*/ Lied About My Home Directory by a Factor of 3.5

Moving a home directory to a new machine, I sized the job with du -sh ~/*/. It reported roughly 149G. The real total was 521G. The destination had 86G free. The transfer started, and the only reason it did not fill a root filesystem is that somebody happened to be watching it.

Share on X

The glob is the bug

~/*/ is expanded by the shell, and the shell does not match hidden entries. Every dotdir is silently absent from the output. Not shown as zero. Not shown at all.

350G  ~/.local          <-- invisible to du -sh ~/*/
120G  ~/UnrealEngine-src
 13G  ~/Downloads
9.6G  ~/.var            <-- also invisible
8.1G  ~/Overlays
---
521G  TOTAL

.local alone was more than twice the entire estimate. On a desktop that is where Steam and flatpak payloads live, so it is often the largest thing in a home directory and reliably the thing nobody counts.

Ways to actually measure it

# the whole thing, one number, no glob involved
du -sh ~

# top 20 entries including hidden ones, largest first
du -sh ~/.[!.]* ~/* 2>/dev/null | sort -rh | head -20

# or let find do the walking, no shell globbing at all
du -h --max-depth=1 ~ | sort -rh | head -20

du -h --max-depth=1 is the one to default to. It takes a path rather than a glob, so the shell never gets the chance to hide anything from it.

BE WARNED: a wrong number here is silent in both directions. Nothing warns you that a glob matched fewer things than you meant. The command succeeds, prints a plausible total, and you plan around it. The same trap catches cp ~/* dest/, rsync ~/*, tar cf a.tar ~/* and any exclude list built by reading that output. If your backup was scoped from a glob, it may not contain .ssh, .gnupg or .config.

The fix is a preflight, not a better exclude list

The tempting conclusion is "add .local to the excludes". That fixes this transfer and none of the next ones, because the actual failure was starting a copy without knowing whether it could finish.

Measure both ends and refuse to run:

SRC_GB=$(du -sBG --exclude-from="$EXCLUDES" "$SRC" | awk '{print int($1)}')
DST_GB=$(ssh "$DEST_HOST" "df -PBG '$DEST_PATH' | tail -1 | awk '{print int(\$4)}'")
HEADROOM=20

if (( DST_GB < SRC_GB + HEADROOM )); then
  echo "ABORT: need $((SRC_GB + HEADROOM))G at the destination, have ${DST_GB}G." >&2
  exit 1
fi

Three details make that guard worth having. It measures the source after exclusions, so it checks what you are really sending. It asks the destination rather than assuming, because the destination is the one that runs out. And it keeps headroom, because filling a filesystem to exactly 100% is its own outage.

Check what the destination path really is

Point df at the actual target directory, not at the host generally. A machine with a small root disk and a large mounted volume gives completely different answers depending on which path you ask about, and the failure mode is copying onto the root disk because your target directory was not where you thought.

ssh dest 'df -h /mnt/bigdisk; findmnt -no SOURCE,TARGET /mnt/bigdisk'

Kill it from the receiving end

Worth knowing before you need it. When a bulk transfer has to stop, killing the sender can leave the receiver writing whatever is already in flight. Stop the process actually creating files:

ssh dest "pkill -f 'rsync --server'"

rsync is restartable, so an interrupted run costs you nothing but time. That is exactly why the preflight is worth more than the exclude list: the expensive failure is not a stopped copy, it is a filled disk on a machine doing other work.

Look for the surprise before you plan around the estimate

Whenever a size estimate informs a decision, spend one command finding the biggest thing you did not expect:

du -h --max-depth=2 ~ 2>/dev/null | sort -rh | head -30

Two levels deep is usually where the answer lives. It is what separates "my home directory is 149G of documents" from "my home directory is 350G of game data and some documents", and those two facts lead to completely different plans.

When this isn't your problem