← back to latest

Find out how much disk a file really uses, not how big it claims to be

ls -lsh *.img

anatomy

ls
List directory contents. Every number it prints comes from a single `stat()` call per file, and that call returns two different sizes: `st_size`, the length of the file, and `st_blocks`, how many blocks the filesystem actually allocated to hold it. Most of the time they agree. When they don't, the file has holes.
-l
Long listing. The size column here is `st_size`, the apparent size: the offset of the last byte, whether or not anything was ever written before it. A 100G disk image that a VM has barely touched still reports 100G in this column.
-s
Prefix each line with the allocated size, built from `st_blocks`. This is what the file costs the disk. Ranges that were never written, the holes, cost nothing and read back as zeros, so a sparse file shows a small number here next to a large one in the -l column.
-h
Human-readable sizes in powers of 1024, applied to both columns at once on GNU ls. Without it GNU ls counts the -s column in 1 KiB units and the -l column in bytes, which puts a seven-digit number next to an eleven-digit one and makes the comparison you came for harder to read.
*.img
The shell expands the glob before ls runs, so ls lists exactly those files. With file arguments instead of a directory, ls skips the `total` line it normally prints first. Disk images, database files, core dumps, and VM swap files are the usual sparse suspects.

Sample output

1.3G -rw-r--r-- 1 ada ada  40G Oct 14 09:12 build-runner.img
7.8G -rw-r--r-- 1 ada ada  20G Oct 14 09:40 db-replica.img
   0 -rw-r--r-- 1 ada ada 100G Oct 14 09:41 scratch.img

Three files that claim 160G between them occupy 9.1G of real disk. The first column is the one df cares about. scratch.img has never been written to at all: it is a 100G promise with zero blocks behind it, and it only starts costing space when something writes inside it.

When you would reach for it

du and ls disagree by an order of magnitude and you want to know which one is lying. A backup of a VM directory came out five times larger than the directory itself. A disk image you copied to a new server filled the target disk even though it barely used anything on the old one. In every case the answer is the same: apparent size and allocated size are two separate numbers, and ls -ls puts them next to each other.

Gotchas

  • Copying can quietly fill the holes. scp, cat a.img > b.img, and most upload tools read the zeros and write them out as real blocks, so a 1.3G image arrives as a 40G one. GNU cp preserves holes by default (--sparse=auto), but rsync needs -S (--sparse) and GNU tar needs -S when creating the archive. If the copy already happened, cp --sparse=always rebuilds a sparse version.
  • Sparse files let you overcommit a disk. df counts allocated blocks, so three 100G images can sit on a 50G filesystem without complaint. The bill arrives later, when the guest writes into a hole and the host has no block to give it. The VM sees an I/O error on a disk it believes is mostly empty.
  • On ZFS and btrfs with compression, a small -s number does not necessarily mean holes. Compressed blocks also shrink st_blocks, and ZFS stores runs of zeros as holes even if you wrote them explicitly. ZFS also reports freshly written data late: allocation can show near zero for several seconds after a write, until the transaction group commits. Run sync before trusting the number.
  • Without -h the units are not portable. GNU ls counts the -s column in 1 KiB blocks (512 bytes if POSIXLY_CORRECT is set), and BSD ls on macOS uses 512-byte blocks, so the same file prints different raw numbers on the two systems. Keep -h, or pass -k on either to force KiB. macOS lacks truncate and GNU find too, so the variants below need brew install coreutils findutils (as gtruncate and gfind).

Variants

$ truncate -s 20G test.img && ls -lsh test.img

Make a sparse file to experiment with. truncate extends the length without writing anything, so the -s column reads 0, or a single block on some filesystems, next to 20G

$ find . -type f -printf '%S\t%p\n' | awk '$1 < 0.5'

GNU find. List every file whose allocation is less than half its length. %S is the sparseness ratio, allocated bytes divided by apparent bytes: 1.0 is fully allocated, near 0 is mostly hole, and small files often show above 1.0 because they round up to a whole block

$ cp --sparse=always bloated.img slim.img && ls -lsh bloated.img slim.img

GNU cp. Write a new copy that turns every all-zero block back into a hole, which reclaims the space a careless scp filled in. Check that the copy works before you delete the original

lineage

ls is one of the commands in the First Edition of Unix in 1971, and its name is a shortening of the Multics command list. The -s flag is nearly as old, because the gap it reveals is built into the classic Unix filesystem design: an inode stores pointers to data blocks, and a pointer that was never filled in reads back as a block of zeros without occupying space. Seek past the end of a file, write one byte, and everything in between is a hole. For decades the only way to find the holes was to compare the two numbers ls prints. Solaris added SEEK_HOLE and SEEK_DATA to lseek alongside ZFS in the mid-2000s so programs could ask where the data actually lives, and Linux picked up the same interface in kernel 3.1 in 2011. GNU cp, tar, and rsync use it today to skip the empty regions instead of copying gigabytes of zeros.