Practical infra coding

Write a du-style disk usage summary

mediumCLI tools

Problem statement

A disk alert fired on a build host and you want to know which directory under data/ is to blame. Write du.py PATH [-H] [--top N] that prints, for each entry directly inside PATH, its total size: a file's own size, or for a directory the sum of every file below it at any depth.

  • Sort largest first; break ties by name so the output is stable. Mark directories with a trailing /.
  • Show each entry's share of the total as a percentage, then a total line.
  • -H prints human-readable sizes with 1024-based units (B, K, M, G, T), one decimal place above bytes.
  • --top N shows only the N largest entries (the total still covers everything).
  • Never follow symlinks: a link to / inside the tree must not make the scan walk the whole disk.
  • An unreadable directory prints a warning to stderr, is skipped, and makes the exit code 1. A PATH that is not a directory exits 2.

Use apparent file sizes (st_size). The example tree:

TEXT
data/
README.md 1,200 bytes
backups/db-2026-09-23.sql.gz 9,437,184 bytes
cache/blob-a 2,097,152 bytes
cache/blob-b 2,097,152 bytes
cache/tmp/part-0001 700 bytes
empty/.keep 0 bytes
logs/api.log 1,572,864 bytes
logs/api.log.1 3,145,728 bytes
logs/worker/worker.log 524,288 bytes

Examples

Example 1

Input: python du.py data -H

Output: 9.0M 50.0% backups/ 5.0M 27.8% logs/ 4.0M 22.2% cache/ 1.2K 0.0% README.md 0B 0.0% empty/ 18.0M total

Explanation: logs/ is 1.5M + 3M + 0.5M (the last one in a nested directory) = 5.0M. cache/ includes the 700-byte file two levels down.

Example 2

Input: python du.py data --top 2

Output: 9437184 50.0% backups/ 5242880 27.8% logs/ 18876268 total

Example 3

Input: python du.py data/README.md; echo "exit: $?"

Output: du.py: data/README.md: not a directory exit: 2

Hints

Approach

  1. Validate the input. If PATH is not a directory, print one line to stderr and exit 2 before doing any work.
  2. One row per top-level entry. os.scandir(PATH) lists the children (an unreadable PATH also exits 2). Files use their own st_size; directories go through tree_size.
  3. tree_size pops directories from a stack, scans each one, pushes subdirectories and adds file sizes. Every check passes follow_symlinks=False, so symlinks are neither followed nor counted. Errors are collected in a list at two levels (opening a directory, and inspecting one entry) so a single permission problem costs one warning, not the whole report.
  4. Sort and print. Sorting by (-size, name) gives largest first with a deterministic tie-break. Percentages are relative to the grand total, guarded against division by zero for an empty directory. --top slices the rows but the total still covers everything, so the percentages stay meaningful.
  5. Exit code. 0 if everything was readable, 1 if some entries were skipped (the numbers are then a lower bound), 2 for bad usage.

human() divides by 1024 until the number is below 1024 and prints bytes without decimals.

F is the number of files scanned, D the number of directories (the worst case for the stack) and k the number of top-level entries.

ComplexityTime O(F + k log k)Space O(D + k)
Python
import argparse
import os
import sys
def human(n):
for unit in ["B", "K", "M", "G", "T"]:
if n < 1024 or unit == "T":
return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}"
n /= 1024
def tree_size(path, errors):
# Total bytes under path. Does not follow symlinks; records unreadable dirs.
total = 0
stack = [path]
while stack:
current = stack.pop()
try:
with os.scandir(current) as it:
for entry in it:
try:
if entry.is_dir(follow_symlinks=False):
stack.append(entry.path)
elif entry.is_file(follow_symlinks=False):
total += entry.stat(follow_symlinks=False).st_size
except OSError as e:
errors.append(f"{entry.path}: {e.strerror}")
except OSError as e:
errors.append(f"{current}: {e.strerror}")
return total
def main(argv=None):
p = argparse.ArgumentParser(prog="du.py", description="Size of each entry directly under PATH.")
p.add_argument("path")
p.add_argument("-H", "--human", action="store_true", help="print sizes like 1.5M")
p.add_argument("--top", type=int, default=0, help="only show the N largest entries")
args = p.parse_args(argv)
if not os.path.isdir(args.path):
print(f"du.py: {args.path}: not a directory", file=sys.stderr)
return 2
errors, rows = [], []
try:
with os.scandir(args.path) as it:
entries = list(it)
except OSError as e:
print(f"du.py: {args.path}: {e.strerror}", file=sys.stderr)
return 2
for entry in entries:
try:
if entry.is_dir(follow_symlinks=False):
rows.append((tree_size(entry.path, errors), entry.name + "/"))
elif entry.is_file(follow_symlinks=False):
rows.append((entry.stat(follow_symlinks=False).st_size, entry.name))
except OSError as e:
errors.append(f"{entry.path}: {e.strerror}")
rows.sort(key=lambda r: (-r[0], r[1])) # biggest first, ties by name
total = sum(size for size, _ in rows)
fmt = human if args.human else str
for size, name in rows[:args.top or None]:
pct = 100 * size / total if total else 0
print(f"{fmt(size):>9} {pct:5.1f}% {name}")
print(f"{fmt(total):>9} total")
for e in errors:
print(f"du.py: warning: {e}", file=sys.stderr)
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())

Follow-up questions

  • Count each inode once so hard-linked files are not double-counted.
  • Add --exclude '*.log.*' with fnmatch patterns.
  • Stay on one filesystem (like du -x) by comparing st_dev with the starting directory.

Frequently asked questions

"The disk is full, find out why" is one of the most common on-call tasks. Writing the tool yourself shows you know about symlinks, permission errors and huge trees, and that a report should put the answer (the biggest entry) first.

du reports disk blocks actually allocated, while this script sums apparent sizes (st_size). They differ for sparse files, which use less disk than their size, and for many small files, each of which takes at least one block. GNU du --apparent-size matches this script's approach.

A file with two hard links in the tree is counted twice here. du counts each inode once; you can do the same by remembering (st_dev, st_ino) pairs you have already added.