Skip to main content
Bash Scripting Tutorial — Write Your First Useful Script

Bash Scripting Tutorial — Write Your First Useful Script

KU
• 7 min read•Updated •23 views

Build a real disk-cleanup script step by step. Learn variables, conditionals, loops, error handling, and the safety preamble that prevents foot-guns.

By the end of this post you'll have written a disk-cleanup script that finds and reports old files, with proper error handling and a --dry-run mode. You'll know enough bash to write small operational tools that don't blow up at 2 AM. About 25 minutes.

You'll need a Linux or macOS terminal with bash (most do; check with bash --version).

Why bash is worth learning even in 2026

Bash is the lingua franca of operations. Every Linux server has it. Every CI runner has it. Every container has it. For small "glue between commands" tasks, nothing beats it for speed and ubiquity. For anything beyond ~150 lines, reach for Python — but the small stuff is bash.

The skill you want is enough fluency to write 30-line scripts that work safely, not deep mastery. Five concepts cover most operational scripts.

Step 1: The safety preamble

Every non-trivial bash script starts with this:

bash.bash
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

What each does:

  • #!/usr/bin/env bash — shebang line. Says "use bash, find it via PATH." Works on Mac and various Linux distros.
  • set -e — exit on any error. Without this, scripts blunder past failures.
  • set -u — error on undefined variables. Catches typos like $USRENAME instead of $USERNAME.
  • set -o pipefail — pipe failures count as errors. Without this, cmd1 | cmd2 succeeds whenever cmd2 succeeds, even if cmd1 failed.
  • IFS=$'\n\t' — only split words on newlines and tabs, not spaces. Makes filenames-with-spaces work correctly.

These four lines prevent a big chunk of the "but it worked on my laptop" bash bugs. Use them.

Step 2: Variables and quoting

Variables are simple: NAME=value (no spaces around =). Reference with $NAME. Always quote when you reference: "$NAME", not $NAME.

Why the quotes matter:

bash.bash
NAME="Alice Smith"
echo $NAME       # prints: Alice Smith (works by accident)
ls $NAME         # tries to ls TWO files: Alice and Smith
ls "$NAME"       # tries to ls one file: "Alice Smith"

Always quoting is the safe default. ShellCheck (a linter we'll use at the end) catches missing quotes.

Try this:

bash.bash
NAME="${USER:-stranger}"
echo "Hello, $NAME"

The ${VAR:-default} syntax: use $VAR if set, otherwise default. Useful for optional config.

Step 3: Build the cleanup script

Create cleanup.sh:

bash.bash
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

# Default values
DAYS="${DAYS:-30}"
DIR="${DIR:-/tmp}"
DRY_RUN=0

# Parse args
while [[ $# -gt 0 ]]; do
    case "$1" in
        --days) DAYS="$2"; shift 2 ;;
        --dir) DIR="$2"; shift 2 ;;
        --dry-run) DRY_RUN=1; shift ;;
        --help)
            echo "Usage: $0 [--days N] [--dir PATH] [--dry-run]"
            echo "  Find files older than N days in DIR (default: 30 days, /tmp)"
            exit 0
            ;;
        *) echo "Unknown arg: $1" >&2; exit 1 ;;
    esac
done

if [[ ! -d "$DIR" ]]; then
    echo "Error: $DIR is not a directory" >&2
    exit 1
fi

echo "Scanning $DIR for files older than $DAYS days..."

count=0
total_size=0
while IFS= read -r -d '' file; do
    size=$(stat -f %z "$file" 2>/dev/null || stat -c %s "$file")  # macOS or Linux
    total_size=$((total_size + size))
    count=$((count + 1))
    if [[ $DRY_RUN -eq 1 ]]; then
        echo "[dry-run] would delete: $file"
    else
        rm "$file"
        echo "deleted: $file"
    fi
done < <(find "$DIR" -type f -mtime +"$DAYS" -print0)

echo
echo "Files: $count"
echo "Total size: $((total_size / 1024 / 1024)) MB"
[[ $DRY_RUN -eq 1 ]] && echo "(dry run — nothing actually deleted)"

Make it executable:

bash.bash
chmod +x cleanup.sh

Run it in dry-run mode first (always — for any script that deletes things):

bash.bash
./cleanup.sh --dry-run --days 7 --dir /tmp

You should see a list of files older than 7 days in /tmp that it would delete, with a count and total size at the end. No actual deletion happens.

If that looks right, drop --dry-run:

bash.bash
./cleanup.sh --days 7 --dir /tmp

That's a real, useful operational script. Walk through what each piece does:

  • Argument parsing — the case "$1" in ... esac block handles --days, --dir, --dry-run, --help. Each option consumes 1 or 2 positional args (shift advances).
  • Validation — the if [[ ! -d "$DIR" ]] check catches typos in the directory path before we proceed.
  • The find ... -print0 | while read -d '' pattern — the standard way to safely loop over files with arbitrary names (including spaces and newlines). Read every file path one at a time.
  • Cross-platform stat — macOS and Linux have different stat syntax. The 2>/dev/null || stat -c %s fallback handles both.
  • Dry-run pattern — the script defaults to dry-run-friendly behavior. The user has to explicitly opt into the destructive path.

Step 4: Functions and trap

Bigger scripts benefit from functions and cleanup hooks. Quick example:

bash.bash
#!/usr/bin/env bash
set -euo pipefail

TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT

log() {
    echo "[$(date '+%H:%M:%S')] $*" >&2
}

main() {
    log "Working in $TMPDIR"
    # ... do stuff with $TMPDIR ...
    log "Done"
}

main "$@"

trap '<command>' EXIT runs the command whether the script succeeds, fails, or is interrupted. Perfect for cleaning up temp files.

log() is a tiny convention: write status to stderr (>&2) so it doesn't pollute stdout, which scripts in pipelines might need to consume.

main "$@" at the bottom runs the actual logic, passing through the original arguments.

Step 5: Lint with ShellCheck

ShellCheck catches a huge category of bash bugs automatically. Install:

bash.bash
# Mac
brew install shellcheck
# Ubuntu/Debian
sudo apt install shellcheck

Run on your script:

bash.bash
shellcheck cleanup.sh

You should see zero or just-a-few warnings. Fix anything it flags. Run shellcheck on every script you write — it catches mistakes faster than running them in production does.

Common mistakes

Forgetting quotes. Probably the #1 bash bug. Variables without quotes silently break on filenames with spaces, empty values, or weird characters. Quote everything.

Using [ ... ] instead of [[ ... ]]. The single-bracket form is older and less powerful. Use [[ ... ]] — supports regex, doesn't word-split, clearer syntax. Bash-only, but you're using bash.

Skipping the safety preamble. Without set -euo pipefail, scripts continue past failures. You won't notice until something corrupts data.

Parsing complex output with cut and awk. Once a script is parsing JSON or doing nontrivial logic, you've outgrown bash. Move to Python. The migration is faster than the next bug.

Editing scripts on the fly without testing. Always have a dry-run mode. Always test on a sandbox before running in production. Every "rm -rf $UNSET_VAR" disaster started with a confident edit.

You've got the basics. The next levels:

Bash scripting is a "glue" skill — the most useful 50 lines you'll ever write are usually bash. Get the safety preamble in muscle memory, quote your variables, lint with ShellCheck, and you'll avoid the worst classes of bugs.

Explore topics:Linux
React
Share this post
KU

Kiril Urbonas

AI Engineer

560 articles
View all articles by Kiril Urbonas

You might have missed

Evergreen posts worth revisiting.