Bash Scripting Cheat Sheet¶
Quick reference for writing bash scripts: a safe template, variables, expansions, tests, control flow, functions, arrays, option parsing, and redirection. Chapters: all of Level 2, especially Error handling and Arguments and getopts.
Script template¶
#!/usr/bin/env bash
#
# backup.sh - archive a directory, with logging, cleanup, and a dry-run mode.
set -euo pipefail
SCRIPT_NAME="${0##*/}"
readonly SCRIPT_NAME
log() { printf '%s [%s] %s\n' "$(date '+%F %T')" "$SCRIPT_NAME" "$*" >&2; }
die() { log "ERROR: $*"; exit 1; }
usage() {
cat <<EOF
Usage: $SCRIPT_NAME [-n] [-v] [-o DIR] SOURCE
-n dry run: show what would happen
-v verbose output
-o DIR output directory (default: /tmp)
-h show this help
EOF
}
tmpdir=""
cleanup() {
local rc=$?
if [[ -n "$tmpdir" ]]; then rm -rf -- "$tmpdir"; fi
exit "$rc"
}
trap cleanup EXIT
trap 'log "interrupted"; exit 130' INT TERM
dry_run=false
verbose=false
outdir=/tmp
while getopts ":nvo:h" opt; do
case "$opt" in
n) dry_run=true ;;
v) verbose=true ;;
o) outdir="$OPTARG" ;;
h) usage; exit 0 ;;
:) die "option -$OPTARG needs an argument" ;;
\?) usage >&2; die "unknown option: -$OPTARG" ;;
esac
done
shift $((OPTIND - 1))
(( $# == 1 )) || { usage >&2; exit 2; }
src="$1"
[[ -d "$src" ]] || die "not a directory: $src"
tmpdir="$(mktemp -d)"
if $verbose; then log "src=$src outdir=$outdir dry_run=$dry_run"; fi
if $dry_run; then
log "would archive $src into $outdir"
else
tar -czf "$outdir/backup-$(date +%F).tar.gz" -C "$src" .
log "done"
fi
| Line | Why it's there |
|---|---|
#!/usr/bin/env bash |
Shebang: run with the first bash found in PATH |
set -e |
Exit when a command fails (with exceptions: inside if, &&, ||) |
set -u |
Treat unset variables as errors (catches typos) |
set -o pipefail |
A pipeline fails if any command in it fails, not just the last |
trap cleanup EXIT |
Always run cleanup when the script ends, for any reason |
log ... >&2 |
Messages go to stderr, so stdout stays clean for data |
Check every script with shellcheck script.sh. Debug with bash -x
script.sh, or set -x / set +x around a section.
Variables and quoting¶
name="alex" # no spaces around =
echo "Hello, $name" # double quotes: variables expand
echo 'Cost: $5' # single quotes: nothing expands
echo "${name}_backup" # braces separate the name from text
today="$(date +%F)" # command substitution
count=$((count + 1)) # arithmetic
readonly MAX=10 # constant
export PGHOST=db.internal # pass to child processes
local tmp="x" # inside a function: local scope
Common mistake: unquoted variables
Always write "$var", not $var. Unquoted, the value is split on spaces
and expanded as a glob, so a file named my report.txt becomes two
arguments. Use "$@" (quoted) to pass all arguments through unchanged.
Parameter expansion¶
With f=/home/alex/data/report.final.csv:
| Expansion | Result | What it does |
|---|---|---|
${f} |
/home/alex/data/report.final.csv |
Value |
${#f} |
32 |
Length |
${f##*/} |
report.final.csv |
Remove longest prefix matching */ (basename) |
${f%/*} |
/home/alex/data |
Remove shortest suffix matching /* (dirname) |
${f%.*} |
/home/alex/data/report.final |
Remove shortest suffix: strip extension |
${f%%.*} |
/home/alex/data/report |
Remove longest suffix |
${f##*.} |
csv |
Extension |
${f/report/summary} |
/home/alex/data/summary.final.csv |
Replace first match |
${f//a/A} |
/home/Alex/dAtA/report.finAl.csv |
Replace all matches |
${f:6:4} |
alex |
Substring: offset 6, length 4 |
${f: -3} |
csv |
Last 3 characters (note the space) |
${var:-default} |
default if unset or empty |
Use a default |
${var:=default} |
Same, and assigns it | Set a default |
${var:?message} |
Exits with message if unset or empty |
Require a value |
${var:+alt} |
alt if set and non-empty, else empty |
Use alternate |
${name^^} / ${name,,} |
ALEX / alex |
Upper / lower case |
${name^} |
Alex |
Capitalize first letter |
Test operators¶
Use [[ ... ]] in bash scripts. It handles empty variables safely and
supports &&, ||, == patterns, and =~ regexes.
| Strings | True if | Numbers | True if |
|---|---|---|---|
-z "$s" |
s is empty |
$a -eq $b |
Equal |
-n "$s" |
s is not empty |
$a -ne $b |
Not equal |
"$a" == "$b" |
Equal | $a -lt $b |
Less than |
"$a" != "$b" |
Not equal | $a -le $b |
Less or equal |
"$a" == b* |
Matches glob (unquoted right side) | $a -gt $b |
Greater than |
"$a" =~ ^[0-9]+$ |
Matches regex (unquoted) | $a -ge $b |
Greater or equal |
"$a" < "$b" |
Sorts before | (( a > b )) |
Arithmetic test, C-style |
| Files | True if | Files | True if |
|---|---|---|---|
-e f |
Exists | -r f |
Readable by you |
-f f |
Regular file | -w f |
Writable by you |
-d f |
Directory | -x f |
Executable by you |
-L f |
Symbolic link | -s f |
Exists and not empty |
f1 -nt f2 |
f1 newer than f2 | f1 -ot f2 |
f1 older than f2 |
Combine with &&, ||, and ! inside [[ ]]. Regex capture groups land in
BASH_REMATCH:
if [[ "$date" =~ ^([0-9]{4})-([0-9]{2})$ ]]; then
echo "year=${BASH_REMATCH[1]} month=${BASH_REMATCH[2]}"
fi
Conditionals¶
if [[ -f "$config" ]]; then
source "$config"
elif [[ -n "${CONFIG_URL:-}" ]]; then
curl -fsS "$CONFIG_URL" -o "$config"
else
die "no config"
fi
if grep -q 'ERROR' app.log; then echo "errors found"; fi # test a command's exit status
[[ -d "$dir" ]] || mkdir -p "$dir" # short form
case "$1" in
start|up) start_app ;;
stop) stop_app ;;
*.tar.gz) extract "$1" ;;
*) usage; exit 2 ;;
esac
Loops¶
for f in *.csv; do # over files (quote "$f" inside!)
echo "processing $f"
done
for i in {1..5}; do echo "$i"; done # brace range
for ((i = 0; i < 10; i++)); do echo "$i"; done # C-style
for host in "${hosts[@]}"; do ping -c1 "$host"; done # over an array
while IFS= read -r line; do # read a file line by line, safely
echo "> $line"
done < input.txt
while IFS=, read -r name city age; do # split CSV fields
echo "$name is $age"
done < <(tail -n +2 people.csv)
until curl -fs http://localhost:8080/health; do sleep 2; done # retry until success
break # leave the loop
continue # skip to the next iteration
Functions¶
greet() {
local name="${1:?name required}" # local variable, required argument
local greeting="${2:-Hello}" # optional, with default
printf '%s, %s!\n' "$greeting" "$name"
}
greet alex # Hello, alex!
msg="$(greet bob Hi)" # capture output
is_root() { [[ $EUID -eq 0 ]]; } # the last command's status is the return value
if is_root; then echo "running as root"; fi
fail() { return 3; } # return sets the exit status (0–255), not a value
Arrays¶
files=(a.txt "b c.txt" d.txt) # indexed array
files+=(e.txt) # append
echo "${files[0]}" # first element
echo "${files[-1]}" # last element
echo "${#files[@]}" # number of elements
echo "${!files[@]}" # indexes: 0 1 2 3
for f in "${files[@]}"; do echo "$f"; done # iterate, always quoted
echo "${files[@]:1:2}" # slice: 2 elements from index 1
unset 'files[1]' # remove one element
mapfile -t lines < input.txt # read a file into an array, one line each
declare -A port=([http]=80 [https]=443) # associative array (dictionary)
port[ssh]=22
echo "${port[https]}" # 443
for k in "${!port[@]}"; do echo "$k=${port[$k]}"; done # keys (unordered)
[[ -v port[ssh] ]] && echo "has ssh"
getopts template¶
verbose=false
output=""
while getopts ":vo:h" opt; do # leading ":" = handle errors yourself
case "$opt" in # "o:" = -o takes an argument
v) verbose=true ;;
o) output="$OPTARG" ;;
h) usage; exit 0 ;;
:) echo "-$OPTARG needs an argument" >&2; exit 2 ;;
\?) echo "unknown option -$OPTARG" >&2; exit 2 ;;
esac
done
shift $((OPTIND - 1)) # now "$@" holds the remaining arguments
getopts handles short options only (-v -o out or -vo out). For long
options like --verbose, parse with a while/case loop over "$1" and
shift.
Redirection¶
| Syntax | What it does |
|---|---|
cmd > file |
stdout to file (overwrite) |
cmd >> file |
stdout to file (append) |
cmd 2> file |
stderr to file |
cmd > file 2>&1 |
stdout and stderr to file (order matters) |
cmd &> file |
Same, bash shorthand |
cmd 2>/dev/null |
Discard errors |
cmd < file |
stdin from file |
cmd1 | cmd2 |
stdout of cmd1 into stdin of cmd2 |
cmd1 |& cmd2 |
stdout and stderr into cmd2 |
cmd | tee file |
Show output and save it |
cmd <<EOF ... EOF |
Here-document: multi-line stdin (<<'EOF' disables expansion) |
cmd <<< "$var" |
Here-string: a string as stdin |
diff <(cmd1) <(cmd2) |
Process substitution: command output as a file |
exec > >(tee -a log.txt) 2>&1 |
Send the rest of the script's output to a log too |
>&2 echo "msg" |
Print to stderr |
Special variables¶
| Variable | Meaning |
|---|---|
$0 |
Script name as invoked |
$1 … $9, ${10} |
Positional arguments |
$# |
Number of arguments |
"$@" |
All arguments, each as a separate word (use this) |
"$*" |
All arguments joined into one string |
$? |
Exit status of the last command (0 = success) |
$$ |
PID of the current shell |
$! |
PID of the last background job |
$_ |
Last argument of the previous command |
$- |
Current shell option flags |
$EUID / $UID |
Effective / real user ID (0 = root) |
$RANDOM |
Random integer 0–32767 |
$LINENO |
Current line number (useful in error messages) |
$SECONDS |
Seconds since the shell started |
$PIPESTATUS |
Array of exit statuses from the last pipeline |
$IFS |
Characters used for word splitting (default: space, tab, newline) |
$BASH_SOURCE |
Path of the current script file (works when sourced) |
Exit codes¶
| Code | Meaning |
|---|---|
0 |
Success |
1 |
General error |
2 |
Misuse: bad arguments (convention) |
126 |
Command found but not executable |
127 |
Command not found |
128+N |
Killed by signal N (130 = Ctrl+C, 137 = SIGKILL, 143 = SIGTERM) |