Scripted OpsAutomate the boring stuff.

Bash: Find the largest files/directories (top N) safely

bash

This script safely searches your filesystem to identify the largest files and directories consuming disk space. It provides human-readable output showing the top N results, with built-in safety features like system path exclusions, depth limits, and low-priority execution to prevent system impact during scans.

bashdisk-managementstoragesystem-administrationhomelabfilesystemcleanup-prep

Script Code

Bashbash
#!/usr/bin/env bash
# =============================================================================
# Script Name: find-largest-files.sh
# Description: Find the largest files and directories in a filesystem safely with size limits and throttling.
# Author:      ScriptedOps (scriptedops.com)
# Version:     1.0
# Requires:    bash 4.0+, du, find, sort
# Tested on:   Ubuntu 22.04, Debian 12
#
# Usage:
#   ./find-largest-files.sh [--dry-run] [--log-file /path/to/log] [--top-n 10] [--search-path /path] [--type files|dirs|both]
#
# Notes:
#   - Uses safe traversal with depth limits and excludes system paths by default
#   - Handles permission errors gracefully without stopping execution
#   - Provides human-readable output with size formatting
# =============================================================================

set -euo pipefail

# =============================================================================
# CONFIGURATION BLOCK
# =============================================================================

# Default configuration - modify these as needed
DEFAULT_TOP_N=10
DEFAULT_SEARCH_PATH="/"
DEFAULT_TYPE="both"  # files, dirs, both
DEFAULT_LOG_FILE="/tmp/find-largest-files.log"
DEFAULT_MAX_DEPTH=20  # Prevent infinite loops in complex mount structures
DEFAULT_EXCLUDE_PATHS="/proc /sys /dev /run /tmp /var/lib/docker /snap"  # System paths to skip

# Performance and safety limits
MAX_RESULTS_INTERNAL=1000  # Internal limit to prevent memory issues
NICE_LEVEL=19  # Run with lowest priority to avoid system impact

# =============================================================================
# FUNCTIONS
# =============================================================================

# Logging function
log_message() {
    local level="$1"
    shift
    local message="$*"
    local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
    echo "[$timestamp] [$level] $message" | tee -a "$LOG_FILE"
}

# Display usage information
show_usage() {
    cat << 'EOF'
Usage: find-largest-files.sh [OPTIONS]

OPTIONS:
    --dry-run           Show what would be done without executing
    --top-n N           Number of results to return (default: 10, max: 100)
    --search-path PATH  Directory to search (default: /)
    --type TYPE         What to find: files, dirs, both (default: both)
    --log-file FILE     Log file location (default: /tmp/find-largest-files.log)
    --max-depth N       Maximum directory depth (default: 20)
    --help              Show this help message

EXAMPLES:
    # Find top 10 largest files and directories from root (dry-run)
    ./find-largest-files.sh --dry-run

    # Find top 20 largest files in /home
    ./find-largest-files.sh --top-n 20 --search-path /home --type files

    # Find largest directories in current directory
    ./find-largest-files.sh --search-path . --type dirs --top-n 5

EOF
}

# Check if required commands exist
check_dependencies() {
    local missing_deps=()

    for cmd in du find sort awk; do
        if ! command -v "$cmd" >/dev/null 2>&1; then
            missing_deps+=("$cmd")
        fi
    done

    if [[ ${#missing_deps[@]} -gt 0 ]]; then
        log_message "ERROR" "Missing required dependencies: ${missing_deps[*]}"
        exit 2
    fi

    log_message "INFO" "Dependency check passed"
}

# Format bytes to human readable format
format_size() {
    local bytes=$1
    local units=("B" "K" "M" "G" "T" "P")
    local unit_index=0
    local size=$bytes

    while (( size >= 1024 && unit_index < 5 )); do
        size=$((size / 1024))
        ((unit_index++))
    done

    printf "%6d%s" "$size" "${units[$unit_index]}"
}

# Build exclude arguments for find command
build_exclude_args() {
    local exclude_args=""
    local IFS=' '

    for path in $DEFAULT_EXCLUDE_PATHS; do
        if [[ -d "$path" ]]; then
            exclude_args="$exclude_args -path $path -prune -o"
        fi
    done

    echo "$exclude_args"
}

# Find largest files
find_largest_files() {
    local search_path="$1"
    local max_depth="$2"
    local exclude_args
    exclude_args=$(build_exclude_args)

    log_message "INFO" "Searching for largest files in: $search_path"

    # Use find with du to get file sizes, exclude system paths
    eval "nice -n $NICE_LEVEL find \"$search_path\" $exclude_args -maxdepth $max_depth -type f -exec du -b {} + 2>/dev/null" | \
        sort -rn | \
        head -n "$MAX_RESULTS_INTERNAL"
}

# Find largest directories
find_largest_dirs() {
    local search_path="$1"
    local max_depth="$2"
    local exclude_args
    exclude_args=$(build_exclude_args)

    log_message "INFO" "Searching for largest directories in: $search_path"

    # Use du to calculate directory sizes, exclude system paths
    {
        for exclude_path in $DEFAULT_EXCLUDE_PATHS; do
            if [[ -d "$exclude_path" && "$search_path" == "/" ]]; then
                exclude_args="$exclude_args --exclude=$exclude_path"
            fi
        done

        nice -n $NICE_LEVEL du -b --max-depth="$max_depth" $exclude_args "$search_path" 2>/dev/null
    } | \
        sort -rn | \
        head -n "$MAX_RESULTS_INTERNAL"
}

# Format and display results
format_results() {
    local type="$1"
    local top_n="$2"

    log_message "INFO" "Formatting top $top_n $type results"

    echo
    echo "Top $top_n Largest $type:"
    echo "========================================"
    printf "%-10s %-60s\n" "Size" "Path"
    echo "----------------------------------------"

    local count=0
    while IFS=$'\t' read -r size path && [[ $count -lt $top_n ]]; do
        if [[ -n "$size" && -n "$path" ]]; then
            formatted_size=$(format_size "$size")
            printf "%-10s %-60s\n" "$formatted_size" "$path"
            ((count++))
        fi
    done
}

# Main execution function
main() {
    local dry_run=false
    local top_n="$DEFAULT_TOP_N"
    local search_path="$DEFAULT_SEARCH_PATH"
    local type="$DEFAULT_TYPE"
    local log_file="$DEFAULT_LOG_FILE"
    local max_depth="$DEFAULT_MAX_DEPTH"

    # Parse command line arguments
    while [[ $# -gt 0 ]]; do
        case $1 in
            --dry-run)
                dry_run=true
                shift
                ;;
            --top-n)
                if [[ -n "${2:-}" ]] && [[ "$2" =~ ^[0-9]+$ ]] && [[ "$2" -le 100 ]]; then
                    top_n="$2"
                    shift 2
                else
                    log_message "ERROR" "Invalid --top-n value. Must be a number between 1 and 100."
                    exit 1
                fi
                ;;
            --search-path)
                if [[ -n "${2:-}" ]] && [[ -d "$2" ]]; then
                    search_path="$2"
                    shift 2
                else
                    log_message "ERROR" "Invalid --search-path. Directory does not exist: ${2:-}"
                    exit 1
                fi
                ;;
            --type)
                if [[ -n "${2:-}" ]] && [[ "$2" =~ ^(files|dirs|both)$ ]]; then
                    type="$2"
                    shift 2
                else
                    log_message "ERROR" "Invalid --type value. Must be: files, dirs, or both."
                    exit 1
                fi
                ;;
            --log-file)
                if [[ -n "${2:-}" ]]; then
                    log_file="$2"
                    shift 2
                else
                    log_message "ERROR" "Invalid --log-file value."
                    exit 1
                fi
                ;;
            --max-depth)
                if [[ -n "${2:-}" ]] && [[ "$2" =~ ^[0-9]+$ ]] && [[ "$2" -le 50 ]]; then
                    max_depth="$2"
                    shift 2
                else
                    log_message "ERROR" "Invalid --max-depth value. Must be a number between 1 and 50."
                    exit 1
                fi
                ;;
            --help)
                show_usage
                exit 0
                ;;
            *)
                log_message "ERROR" "Unknown option: $1"
                show_usage
                exit 1
                ;;
        esac
    done

    # Set global LOG_FILE variable
    LOG_FILE="$log_file"

    # Create log file directory if it doesn't exist
    local log_dir
    log_dir=$(dirname "$LOG_FILE")
    if [[ ! -d "$log_dir" ]]; then
        mkdir -p "$log_dir"
    fi

    log_message "INFO" "Starting find-largest-files.sh v1.0"
    log_message "INFO" "Configuration: top_n=$top_n, search_path=$search_path, type=$type, max_depth=$max_depth"

    if [[ "$dry_run" == true ]]; then
        log_message "INFO" "DRY RUN MODE - No actual searching will be performed"
        echo "DRY RUN: Would search for top $top_n largest $type in: $search_path"
        echo "DRY RUN: Max depth: $max_depth"
        echo "DRY RUN: Excluded paths: $DEFAULT_EXCLUDE_PATHS"
        echo "DRY RUN: Log file: $LOG_FILE"
        exit 0
    fi

    # Check dependencies
    check_dependencies

    # Verify search path exists and is readable
    if [[ ! -r "$search_path" ]]; then
        log_message "ERROR" "Cannot read search path: $search_path"
        exit 1
    fi

    # Execute the search based on type
    case "$type" in
        "files")
            find_largest_files "$search_path" "$max_depth" | format_results "Files" "$top_n"
            ;;
        "dirs")
            find_largest_dirs "$search_path" "$max_depth" | format_results "Directories" "$top_n"
            ;;
        "both")
            echo "=== FILES ==="
            find_largest_files "$search_path" "$max_depth" | format_results "Files" "$top_n"
            echo
            echo "=== DIRECTORIES ==="
            find_largest_dirs "$search_path" "$max_depth" | format_results "Directories" "$top_n"
            ;;
    esac

    log_message "INFO" "Search completed successfully"
}

# =============================================================================
# SCRIPT ENTRY POINT
# =============================================================================

# Ensure script is not run as root unless necessary
if [[ $EUID -eq 0 ]] && [[ "${ALLOW_ROOT:-false}" != "true" ]]; then
    echo "WARNING: Running as root. Set ALLOW_ROOT=true if this is intentional."
    echo "This script can run safely as a regular user for most searches."
    read -p "Continue as root? (y/N): " -r
    if [[ ! $REPLY =~ ^[Yy]$ ]]; then
        exit 1
    fi
fi

# Run main function with all arguments
main "$@"

Get new scripts weekly.

Subscribe to receive fresh PowerShell scripts, automation tips, and IT workflow improvements.

Subscribe to Newsletter