Bash Scripting Tutorial — Write Your First Useful Script
Build a real disk-cleanup script step by step. Learn variables, conditionals, loops, error handling, and the safety preamble that prevents foot-guns.
Key takeaways
- Build a real disk-cleanup script step by step.
- Learn variables, conditionals, loops, error handling, and the safety preamble that prevents foot-guns.
On this page
Bash Scripting Tutorial: Write Your First Useful Script#
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:
#!/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$USRENAMEinstead of$USERNAME.set -o pipefail— pipe failures count as errors. Without this,cmd1 | cmd2succeeds whenevercmd2succeeds, even ifcmd1failed.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:
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:
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:
#!/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:
chmod +x cleanup.sh
Run it in dry-run mode first (always — for any script that deletes things):
./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:
./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 ... esacblock handles--days,--dir,--dry-run,--help. Each option consumes 1 or 2 positional args (shiftadvances). - 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 differentstatsyntax. The2>/dev/null || stat -c %sfallback 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:
#!/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:
# Mac
brew install shellcheck
# Ubuntu/Debian
sudo apt install shellcheck
Run on your script:
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.
What to read next#
You've got the basics. The next levels:
- Advanced bash scripting techniques — arrays, traps, process substitution, parameter expansion
- Shell scripting: what survives production — patterns from operational scripts that ran for years
- Linux process management and monitoring — once your scripts are running, how to watch what they do
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.
Get the DevOps Troubleshooting Cheat Sheet
Subscribe and get our free one-page reference for the errors that eat an afternoon — CrashLoopBackOff, OOMKilled, Terraform state locks, and more — plus new guides as we publish them.
AWS VPC Explained — Subnets, Route Tables, and the Internet Gateway
A working mental model for AWS VPCs — what each piece does, how they connect, and why "VPC" is the wrong mental model if you came from physical networks.
Build Your First RAG App in 100 Lines of Python
A working retrieval-augmented generation app you can run today. Markdown ingestion, embeddings, semantic search, and an LLM answer — start to finish in one afternoon.
More from Linux
Explore more articles in this category
ext4 vs XFS vs Btrfs: Choosing a Filesystem for a Server
The default filesystem your distro picks is not always the right one for your workload. Here is what actually differs and when each one wins.
journald Log Management: Retention, Filtering, and Forwarding
journald is the default log sink on every systemd distro, and most of it runs on defaults nobody chose. Here is how to actually control it.
DNS Troubleshooting on Linux: A Systematic Approach
\"It's always DNS\" is a joke because the failure modes are so scattered: resolver config, caching, search domains, split DNS. Here is where to actually look.
You might have missed
Evergreen posts worth revisiting.