The disk is 95% full and du -sh * took ten minutes. Here is a single-pass Ruby report that tells you which directories, files and extensions ate the space, and which giants nobody has touched in months.
Step through the build below:
A full disk is one of the most common pages a sysadmin gets, and the usual response is to run du repeatedly, drilling down one directory at a time. Each run re-walks the same tree.
This script walks the tree exactly once with Find.find, rolls each file’s size up into all of its ancestor directories, and then answers four questions at once: which directories are biggest, which files are biggest, which file types dominate, and which large files have not been modified for months (the safest candidates to archive).
Because it can emit JSON, the same script can feed a dashboard or a cron job that alerts when a directory grows past a limit.
# disk_usage_report.rb – complete script
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# disk_usage_report.rb - Find out where the disk space went.
# Walks a directory tree once (no shelling out to du) and reports:
# * the biggest directories (cumulative size, rolled up from children)
# * the biggest individual files
# * usage grouped by file extension
# * "stale giants": big files nobody has touched in N days
# Pure Ruby stdlib. Linux/macOS (works on Windows too, but ignores inodes).
require 'find'
require 'optparse'
require 'json'
require 'time'
Options = Struct.new(:path, :top, :stale_days, :min_stale_mb, :json, :one_fs)
def parse_options(argv)
o = Options.new('.', 10, 180, 50, false, true)
OptionParser.new do |op|
op.banner = 'Usage: disk_usage_report.rb [options] [PATH]'
op.on('-n', '--top N', Integer, 'rows per section (default 10)') { |v| o.top = v }
op.on('--stale-days N', Integer, 'age threshold for stale giants (default 180)') { |v| o.stale_days = v }
op.on('--min-stale-mb N', Integer, 'minimum size for a stale giant (default 50)') { |v| o.min_stale_mb = v }
op.on('--json', 'emit JSON instead of text') { o.json = true }
op.on('--cross-fs', 'descend into other filesystems') { o.one_fs = false }
end.parse!(argv)
o.path = argv.first || '.'
o
end
# 1_536 -> "1.5 KiB"
def human(bytes)
units = %w[B KiB MiB GiB TiB]
size = bytes.to_f
i = 0
while size >= 1024 && i < units.size - 1
size /= 1024
i += 1
end
i.zero? ? "#{bytes} B" : format('%.1f %s', size, units[i])
end
def scan(opts)
root = File.expand_path(opts.path)
root_dev = File.stat(root).dev
dir_sizes = Hash.new(0) # directory => cumulative bytes
files = [] # [size, path, mtime]
by_ext = Hash.new { |h, k| h[k] = [0, 0] } # ext => [count, bytes]
errors = 0
Find.find(root) do |path|
begin
st = File.lstat(path)
if st.directory?
# Don't cross mount points (e.g. /proc, NFS) unless asked to.
Find.prune if opts.one_fs && st.dev != root_dev
next
end
next unless st.file? # skip symlinks, sockets, devices
# st.blocks * 512 is *allocated* space (matches du); falls back to size.
size = st.blocks ? st.blocks * 512 : st.size
files << [size, path, st.mtime]
ext = File.extname(path).downcase
ext = '(none)' if ext.empty?
by_ext[ext][0] += 1
by_ext[ext][1] += size
# Roll the size up into every ancestor directory.
dir = File.dirname(path)
loop do
dir_sizes[dir] += size
break if dir == root || dir == File.dirname(dir)
dir = File.dirname(dir)
end
rescue Errno::EACCES, Errno::ENOENT, Errno::EPERM
errors += 1 # unreadable or vanished mid-scan; keep going
end
end
cutoff = Time.now - opts.stale_days * 86_400
min_bytes = opts.min_stale_mb * 1024 * 1024
{
root: root,
total: dir_sizes[root],
file_count: files.size,
errors: errors,
top_dirs: dir_sizes.reject { |d, _| d == root }.sort_by { |_, s| -s }.first(opts.top),
top_files: files.sort_by { |s, _, _| -s }.first(opts.top),
by_ext: by_ext.sort_by { |_, (_, b)| -b }.first(opts.top),
stale: files.select { |s, _, m| s >= min_bytes && m < cutoff }.sort_by { |s, _, _| -s }.first(opts.top)
}
end
def print_text(r, opts)
puts "Disk usage report for #{r[:root]}"
puts "Total: #{human(r[:total])} in #{r[:file_count]} files (#{r[:errors]} unreadable)"
puts "\n== Biggest directories =="
r[:top_dirs].each { |d, s| puts format('%10s %5.1f%% %s', human(s), r[:total].zero? ? 0 : s * 100.0 / r[:total], d) }
puts "\n== Biggest files =="
r[:top_files].each { |s, p, _| puts format('%10s %s', human(s), p) }
puts "\n== By extension =="
r[:by_ext].each { |e, (c, b)| puts format('%10s %6d files %s', human(b), c, e) }
puts "\n== Stale giants (>= #{opts.min_stale_mb} MiB, untouched #{opts.stale_days}+ days) =="
if r[:stale].empty?
puts '(none)'
else
r[:stale].each { |s, p, m| puts format('%10s %s %s', human(s), m.strftime('%Y-%m-%d'), p) }
end
end
def print_json(r)
puts JSON.pretty_generate(
root: r[:root], total_bytes: r[:total], files: r[:file_count], errors: r[:errors],
top_dirs: r[:top_dirs].map { |d, s| { path: d, bytes: s } },
top_files: r[:top_files].map { |s, p, m| { path: p, bytes: s, mtime: m.iso8601 } },
by_extension: r[:by_ext].map { |e, (c, b)| { ext: e, files: c, bytes: b } },
stale_giants: r[:stale].map { |s, p, m| { path: p, bytes: s, mtime: m.iso8601 } }
)
end
if $PROGRAM_NAME == __FILE__
opts = parse_options(ARGV)
abort "No such directory: #{opts.path}" unless File.directory?(opts.path)
res = scan(opts)
opts.json ? print_json(res) : print_text(res, opts)
end
**Find.find and one-filesystem pruning** Find.find yields every path under the root. We call Find.prune when a directory’s device number differs from the root’s, so scanning / will not wander into /proc or a network mount unless you pass --cross-fs.
**Allocated size, not apparent size** st.blocks * 512 is the space actually allocated on disk, which is what du reports. Sparse files and tiny files rounded up to a block are therefore counted honestly.
**Rolling sizes up the tree** For every file we loop from its directory up to the root, adding the size to each ancestor in a Hash. That makes directory totals cumulative without a second pass.
**Stale giants** Files above --min-stale-mb whose mtime is older than --stale-days are listed separately: big, cold and probably safe to compress or move.
**Failing soft** Errno::EACCES, ENOENT and EPERM are rescued per file and counted, so one unreadable directory or a file deleted mid-scan never aborts the run.
$ ruby disk_usage_report.rb -n 5 /srv/data
Disk usage report for /home/claude/w/tree Total: 100.2 MiB in 6 files (0 unreadable) == Biggest directories == 57.2 MiB 57.1% /home/claude/w/tree/cache 28.6 MiB 28.6% /home/claude/w/tree/media/video 28.6 MiB 28.6% /home/claude/w/tree/media 14.3 MiB 14.3% /home/claude/w/tree/logs 8.0 KiB 0.0% /home/claude/w/tree/src == Biggest files == 57.2 MiB /home/claude/w/tree/cache/old_dump.sql 28.6 MiB /home/claude/w/tree/media/video/demo.mp4 11.4 MiB /home/claude/w/tree/logs/app.log 2.9 MiB /home/claude/w/tree/logs/app.log.1.gz 8.0 KiB /home/claude/w/tree/src/main.rb == By extension == 57.2 MiB 1 files .sql 28.6 MiB 1 files .mp4 11.4 MiB 1 files .log 2.9 MiB 1 files .gz 8.0 KiB 1 files .rb == Stale giants (>= 50 MiB, untouched 180+ days) == 57.2 MiB 2025-11-01 /home/claude/w/tree/cache/old_dump.sql
Full script + README on GitHub: ruby-devops-toolkit/disk-usage-hotspots
The problem
A full disk is one of the most common pages a sysadmin gets, and the usual response is to run du repeatedly, drilling down one directory at a time. Each run re-walks the same tree.
This script walks the tree exactly once with Find.find, rolls each file’s size up into all of its ancestor directories, and then answers four questions at once: which directories are biggest, which files are biggest, which file types dominate, and which large files have not been modified for months (the safest candidates to archive).
Because it can emit JSON, the same script can feed a dashboard or a cron job that alerts when a directory grows past a limit.
Prerequisites
- Ruby 3.0+ (tested on 3.3.6); standard library only (
find,optparse,json) - Linux or macOS; read access to the tree you scan (unreadable entries are counted, not fatal)
- No gems required
The complete code
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# disk_usage_report.rb - Find out where the disk space went.
# Walks a directory tree once (no shelling out to du) and reports:
# * the biggest directories (cumulative size, rolled up from children)
# * the biggest individual files
# * usage grouped by file extension
# * "stale giants": big files nobody has touched in N days
# Pure Ruby stdlib. Linux/macOS (works on Windows too, but ignores inodes).
require 'find'
require 'optparse'
require 'json'
require 'time'
Options = Struct.new(:path, :top, :stale_days, :min_stale_mb, :json, :one_fs)
def parse_options(argv)
o = Options.new('.', 10, 180, 50, false, true)
OptionParser.new do |op|
op.banner = 'Usage: disk_usage_report.rb [options] [PATH]'
op.on('-n', '--top N', Integer, 'rows per section (default 10)') { |v| o.top = v }
op.on('--stale-days N', Integer, 'age threshold for stale giants (default 180)') { |v| o.stale_days = v }
op.on('--min-stale-mb N', Integer, 'minimum size for a stale giant (default 50)') { |v| o.min_stale_mb = v }
op.on('--json', 'emit JSON instead of text') { o.json = true }
op.on('--cross-fs', 'descend into other filesystems') { o.one_fs = false }
end.parse!(argv)
o.path = argv.first || '.'
o
end
# 1_536 -> "1.5 KiB"
def human(bytes)
units = %w[B KiB MiB GiB TiB]
size = bytes.to_f
i = 0
while size >= 1024 && i < units.size - 1
size /= 1024
i += 1
end
i.zero? ? "#{bytes} B" : format('%.1f %s', size, units[i])
end
def scan(opts)
root = File.expand_path(opts.path)
root_dev = File.stat(root).dev
dir_sizes = Hash.new(0) # directory => cumulative bytes
files = [] # [size, path, mtime]
by_ext = Hash.new { |h, k| h[k] = [0, 0] } # ext => [count, bytes]
errors = 0
Find.find(root) do |path|
begin
st = File.lstat(path)
if st.directory?
# Don't cross mount points (e.g. /proc, NFS) unless asked to.
Find.prune if opts.one_fs && st.dev != root_dev
next
end
next unless st.file? # skip symlinks, sockets, devices
# st.blocks * 512 is *allocated* space (matches du); falls back to size.
size = st.blocks ? st.blocks * 512 : st.size
files << [size, path, st.mtime]
ext = File.extname(path).downcase
ext = '(none)' if ext.empty?
by_ext[ext][0] += 1
by_ext[ext][1] += size
# Roll the size up into every ancestor directory.
dir = File.dirname(path)
loop do
dir_sizes[dir] += size
break if dir == root || dir == File.dirname(dir)
dir = File.dirname(dir)
end
rescue Errno::EACCES, Errno::ENOENT, Errno::EPERM
errors += 1 # unreadable or vanished mid-scan; keep going
end
end
cutoff = Time.now - opts.stale_days * 86_400
min_bytes = opts.min_stale_mb * 1024 * 1024
{
root: root,
total: dir_sizes[root],
file_count: files.size,
errors: errors,
top_dirs: dir_sizes.reject { |d, _| d == root }.sort_by { |_, s| -s }.first(opts.top),
top_files: files.sort_by { |s, _, _| -s }.first(opts.top),
by_ext: by_ext.sort_by { |_, (_, b)| -b }.first(opts.top),
stale: files.select { |s, _, m| s >= min_bytes && m < cutoff }.sort_by { |s, _, _| -s }.first(opts.top)
}
end
def print_text(r, opts)
puts "Disk usage report for #{r[:root]}"
puts "Total: #{human(r[:total])} in #{r[:file_count]} files (#{r[:errors]} unreadable)"
puts "\n== Biggest directories =="
r[:top_dirs].each { |d, s| puts format('%10s %5.1f%% %s', human(s), r[:total].zero? ? 0 : s * 100.0 / r[:total], d) }
puts "\n== Biggest files =="
r[:top_files].each { |s, p, _| puts format('%10s %s', human(s), p) }
puts "\n== By extension =="
r[:by_ext].each { |e, (c, b)| puts format('%10s %6d files %s', human(b), c, e) }
puts "\n== Stale giants (>= #{opts.min_stale_mb} MiB, untouched #{opts.stale_days}+ days) =="
if r[:stale].empty?
puts '(none)'
else
r[:stale].each { |s, p, m| puts format('%10s %s %s', human(s), m.strftime('%Y-%m-%d'), p) }
end
end
def print_json(r)
puts JSON.pretty_generate(
root: r[:root], total_bytes: r[:total], files: r[:file_count], errors: r[:errors],
top_dirs: r[:top_dirs].map { |d, s| { path: d, bytes: s } },
top_files: r[:top_files].map { |s, p, m| { path: p, bytes: s, mtime: m.iso8601 } },
by_extension: r[:by_ext].map { |e, (c, b)| { ext: e, files: c, bytes: b } },
stale_giants: r[:stale].map { |s, p, m| { path: p, bytes: s, mtime: m.iso8601 } }
)
end
if $PROGRAM_NAME == __FILE__
opts = parse_options(ARGV)
abort "No such directory: #{opts.path}" unless File.directory?(opts.path)
res = scan(opts)
opts.json ? print_json(res) : print_text(res, opts)
end
How it works
Find.find and one-filesystem pruning
Find.find yields every path under the root. We call Find.prune when a directory’s device number differs from the root’s, so scanning / will not wander into /proc or a network mount unless you pass --cross-fs.
Allocated size, not apparent size
st.blocks * 512 is the space actually allocated on disk, which is what du reports. Sparse files and tiny files rounded up to a block are therefore counted honestly.
Rolling sizes up the tree
For every file we loop from its directory up to the root, adding the size to each ancestor in a Hash. That makes directory totals cumulative without a second pass.
Stale giants
Files above --min-stale-mb whose mtime is older than --stale-days are listed separately: big, cold and probably safe to compress or move.
Failing soft
Errno::EACCES, ENOENT and EPERM are rescued per file and counted, so one unreadable directory or a file deleted mid-scan never aborts the run.
Example output
Troubleshooting
- Totals smaller than
df: deleted-but-open files and other filesystems are not visible to a tree walk; checklsof +L1. - Many unreadable entries: run with sudo or scan a narrower path.
- Slow on huge trees: the walk is IO-bound; run it with
ionice -c3. - Sizes differ slightly from
du -sh:dualso counts directory entries themselves; this tool counts regular files only.
Extending the script
- Alert (non-zero exit) when any top directory exceeds a percentage of the filesystem.
- Write the JSON daily and diff two days to find fast-growing directories.
- Add an
--excludeglob list for build caches. - Report inode usage per directory for filesystems that run out of inodes first.