Write a du-style disk usage summary
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
totalline. -Hprints human-readable sizes with 1024-based units (B,K,M,G,T), one decimal place above bytes.--top Nshows only theNlargest 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
PATHthat is not a directory exits 2.
Use apparent file sizes (st_size). The example tree:
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 bytesExamples
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
- Validate the input. If
PATHis not a directory, print one line to stderr and exit 2 before doing any work. - One row per top-level entry.
os.scandir(PATH)lists the children (an unreadablePATHalso exits 2). Files use their ownst_size; directories go throughtree_size. tree_sizepops directories from a stack, scans each one, pushes subdirectories and adds file sizes. Every check passesfollow_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.- 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.--topslices the rows but the total still covers everything, so the percentages stay meaningful. - 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.
O(F + k log k)Space O(D + k)import argparseimport osimport 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.*'withfnmatchpatterns. - Stay on one filesystem (like
du -x) by comparingst_devwith 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.