the shed // linux // disk usage

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:

disk_usage_report.rb

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
Get the code

Full script + README on GitHub: ruby-devops-toolkit/disk-usage-hotspots

Flow diagram of the disk usage report script

One tree walk feeds four reports.
Background

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.

Setup

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
Full script

The complete code

disk_usage_report.rbruby
#!/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
Step by step

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.

Run it

Example output

$ 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
When it breaks

Troubleshooting

  • Totals smaller than df: deleted-but-open files and other filesystems are not visible to a tree walk; check lsof +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: du also counts directory entries themselves; this tool counts regular files only.
Next steps

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 --exclude glob list for build caches.
  • Report inode usage per directory for filesystems that run out of inodes first.

Tha-Shed

Type a brief, get a finished film. Characters, voices and music included.

Start creating