Bash scripting reference
Syntax, structure, common commands, and ready-to-use scripts in one place. Use the filter box to narrow sections. Hover over any code block to copy it.
Script structure
Every script should start with a shebang and, for most scripts, “strict mode”. Keep logic inside functions and call main at the bottom.
#!/usr/bin/env bash
# my-script.sh — one-line description of what it does
set -euo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
usage() { echo "Usage: $(basename "$0") <arg>"; }
main() {
[[ $# -ge 1 ]] || { usage; exit 1; }
echo "Hello, $1"
}
main "$@"
| Line | Meaning |
#!/usr/bin/env bash | Find bash on PATH. More portable than #!/bin/bash, which on macOS is the old bash 3.2. |
set -e | Exit right away when a command fails. |
set -u | Treat unset variables as an error. |
set -o pipefail | A pipeline fails if any command in it fails, not just the last. |
main "$@" | Pass every argument through to main, unchanged. |
Running a script
chmod +x my-script.sh # make executable (once)
./my-script.sh arg1 # run it
bash my-script.sh arg1 # run without exec permission
source lib.sh # run in the CURRENT shell (also: . lib.sh)
Variables & quoting
name="world" # no spaces around =
echo "Hello, $name" # Hello, world
echo "Hello, ${name}!" # braces mark where the name ends
echo 'Hello, $name' # single quotes: no expansion → Hello, $name
readonly PI=3.14 # constant
export PATH="$HOME/bin:$PATH" # make visible to child processes
unset name # delete variable
today=$(date +%F) # command substitution (prefer over backticks)
files=$(ls | wc -l)
Always quote variables, as in "$var". Without quotes, Bash splits the value on spaces and expands globs, so rm $file with file="my notes.txt" tries to delete my and notes.txt.
Special variables
| Variable | Meaning |
$0 | Script name |
$1 … $9, ${10} | Positional arguments |
$# | Number of arguments |
"$@" | All arguments, each kept as a separate word (almost always what you want) |
"$*" | All arguments joined into one string |
$? | Exit code of the last command |
$$ | PID of the current shell |
$! | PID of the last background job |
$_ | Last argument of the previous command |
$RANDOM | Random integer 0–32767 |
$LINENO, $SECONDS | Current line number; seconds since the shell started |
Parameter expansion
Built-in string manipulation, with no need for sed or cut.
file="/home/user/photos/beach.tar.gz"
${file##*/} # beach.tar.gz strip longest match from start (basename)
${file%/*} # /home/user/photos strip shortest match from end (dirname)
${file#*.} # tar.gz strip shortest match from start
${file##*.} # gz extension
${file%.*} # /home/user/photos/beach.tar
${file%%.*} # /home/user/photos/beach
${#file} # 31 length
${file:6:4} # user substring (offset:length)
${file: -6} # tar.gz last 6 chars (note the space)
${file/photos/pics} # replace first match
${file//o/0} # replace all matches
${file^^} # UPPERCASE (bash 4+)
${file,,} # lowercase (bash 4+)
Defaults & required values
| Syntax | Result |
${var:-default} | Use default if var is unset or empty |
${var:=default} | Same, and also assign it to var |
${var:+other} | Use other only if var is set |
${var:?message} | Exit with message if var is unset or empty |
port="${PORT:-8080}"
input="${1:?Usage: $0 <input-file>}"
Brace expansion
echo {a,b,c}.txt # a.txt b.txt c.txt
echo {1..5} # 1 2 3 4 5
echo {01..10..3} # 01 04 07 10
mkdir -p project/{src,tests,docs}
cp config.yml{,.bak} # cp config.yml config.yml.bak
Arrays
Indexed arrays
fruits=("apple" "banana" "cherry")
fruits+=("date") # append
echo "${fruits[0]}" # apple
echo "${fruits[@]}" # all elements
echo "${#fruits[@]}" # length → 4
echo "${!fruits[@]}" # indices → 0 1 2 3
echo "${fruits[@]:1:2}" # slice → banana cherry
unset 'fruits[1]' # remove element
for f in "${fruits[@]}"; do echo "$f"; done
files=(*.txt) # array from a glob
mapfile -t lines < file.txt # array of lines (bash 4+)
Associative arrays (bash 4+)
declare -A ports=([http]=80 [https]=443)
ports[ssh]=22
echo "${ports[https]}" # 443
for key in "${!ports[@]}"; do
echo "$key → ${ports[$key]}"
done
[[ -v ports[ftp] ]] || echo "no ftp key"
macOS ships bash 3.2, which has no associative arrays or mapfile. Run brew install bash to get bash 5.
Arithmetic
a=10; b=3
echo $(( a + b )) # 13
echo $(( a / b )) # 3 (integer division)
echo $(( a % b )) # 1
echo $(( a ** 2 )) # 100
(( a++ )) # increment
(( count += 5 ))
(( a > b )) && echo "a is bigger"
# Floating point — Bash only does integers; use bc or awk
echo "scale=2; 10 / 3" | bc # 3.33
awk 'BEGIN { printf "%.2f\n", 10/3 }' # 3.33
Conditionals & tests
if [[ -f "$file" ]]; then
echo "file exists"
elif [[ -d "$file" ]]; then
echo "it's a directory"
else
echo "not found"
fi
# One-liners
[[ -z "$name" ]] && echo "name is empty"
[[ -d "$dir" ]] || mkdir -p "$dir"
# Test a command's success directly
if grep -q "error" log.txt; then echo "found errors"; fi
if ! command -v jq >/dev/null; then echo "jq not installed"; fi
Use [[ … ]] instead of [ … ] in Bash. It handles empty variables safely and supports &&, ||, =~ regex, and glob patterns.
File tests
| Test | True if… |
-e path | exists |
-f path | is a regular file |
-d path | is a directory |
-L path | is a symlink |
-r / -w / -x path | is readable / writable / executable |
-s path | exists and isn't empty |
a -nt b / a -ot b | a is newer / older than b |
String tests
| Test | True if… |
-z "$s" | string is empty |
-n "$s" | string is not empty |
"$a" == "$b" | strings are equal (!= for not equal) |
"$s" == *.txt | glob match (no quotes on the pattern) |
"$s" =~ ^[0-9]+$ | regex match; groups go into ${BASH_REMATCH[@]} |
Numeric tests
| Test | Meaning |
-eq -ne | equal, not equal |
-lt -le | less than, less or equal |
-gt -ge | greater than, greater or equal |
(( a > b )) | arithmetic form, often easier to read |
Combining
[[ -f "$f" && -r "$f" ]]
[[ "$env" == "prod" || "$env" == "staging" ]]
[[ ! -e "$f" ]]
case statements
case "$1" in
start)
echo "Starting…" ;;
stop|halt)
echo "Stopping…" ;;
*.tar.gz|*.tgz)
tar -xzf "$1" ;;
[0-9]*)
echo "starts with a digit" ;;
-h|--help)
usage ;;
*)
echo "Unknown: $1" >&2
exit 1 ;;
esac
Loops
# Over a list
for name in alice bob carol; do echo "Hi $name"; done
# Over files (glob — never parse `ls`)
for f in *.jpg; do
[[ -e "$f" ]] || continue # skip if no matches
echo "Processing $f"
done
# Ranges
for i in {1..10}; do echo "$i"; done
for (( i = 0; i < 10; i += 2 )); do echo "$i"; done
# While / until
count=0
while (( count < 5 )); do (( count++ )); done
until ping -c1 example.com &>/dev/null; do sleep 1; done
# Infinite loop with break
while true; do
read -rp "Continue? [y/n] " ans
[[ "$ans" == n ]] && break
done
break exits the loop and continue skips to the next iteration. Both take a number to act on outer loops, as in break 2.
Functions
greet() {
local name="${1:-stranger}" # `local` keeps variables scoped to the function
echo "Hello, $name"
}
greet "Alice"
# "Return" a value by printing it, then capture with $( )
get_ext() { echo "${1##*.}"; }
ext=$(get_ext "report.pdf") # pdf
# `return` sets the exit status (0–255), used for true/false
is_number() { [[ "$1" =~ ^-?[0-9]+$ ]]; }
if is_number "42"; then echo "yes"; fi
# All args, arg count
show_args() { echo "$# args: $*"; for a in "$@"; do echo " - $a"; done; }
Arguments & getopts
Short options with getopts
verbose=0; output=""
while getopts ":vo:h" opt; do # leading ":" = handle errors yourself
case "$opt" in # "o:" = -o takes a value
v) verbose=1 ;;
o) output="$OPTARG" ;;
h) usage; exit 0 ;;
:) echo "-$OPTARG needs a value" >&2; exit 1 ;;
\?) echo "Unknown option -$OPTARG" >&2; exit 1 ;;
esac
done
shift $((OPTIND - 1)) # "$@" now holds the remaining args
Long options (manual loop)
while [[ $# -gt 0 ]]; do
case "$1" in
-v|--verbose) verbose=1; shift ;;
-o|--output) output="$2"; shift 2 ;;
--output=*) output="${1#*=}"; shift ;;
--) shift; break ;;
-*) echo "Unknown option $1" >&2; exit 1 ;;
*) args+=("$1"); shift ;;
esac
done
Redirection & pipes
| Syntax | Effect |
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 as above (bash shorthand) |
cmd > /dev/null 2>&1 | discard all output |
cmd < file | file as stdin |
echo "msg" >&2 | write to stderr (for errors and logs) |
cmd1 | cmd2 | stdout of cmd1 → stdin of cmd2 |
cmd1 |& cmd2 | stdout and stderr piped |
cmd | tee file | show output and save it (tee -a appends) |
Command chaining
| Syntax | Runs cmd2… |
cmd1 ; cmd2 | always |
cmd1 && cmd2 | only if cmd1 succeeded |
cmd1 || cmd2 | only if cmd1 failed |
cmd & | runs cmd in the background; wait waits for it |
( cmd1; cmd2 ) | in a subshell (a cd there doesn't affect the parent) |
{ cmd1; cmd2; } | grouped in the current shell |
Here-docs & substitution
# Here-doc: multi-line input (variables expand)
cat <<EOF > config.ini
user=$USER
home=$HOME
EOF
# Quoted delimiter: no expansion (good for writing scripts)
cat <<'EOF'
echo "$HOME is not expanded here"
EOF
# <<- strips leading TABs, so you can indent the body
if true; then
cat <<-EOF
indented with tabs
EOF
fi
# Here-string
grep -o '[0-9]*' <<< "order 1234"
# Command substitution
now="$(date '+%Y-%m-%d %H:%M')"
# Process substitution: treat command output as a file
diff <(sort a.txt) <(sort b.txt)
while read -r line; do echo "$line"; done < <(find . -name '*.md')
Reading input & files
# Prompt the user
read -rp "Your name: " name
read -rsp "Password: " pass; echo # -s = silent
read -rt 5 -p "Quick! " ans || echo "timed out"
# Read a file line by line (the correct way)
while IFS= read -r line; do
echo "→ $line"
done < file.txt
# Split fields (CSV-ish)
while IFS=, read -r name email role; do
echo "$name <$email> ($role)"
done < users.csv
# Handle filenames with spaces/newlines from find
find . -type f -print0 | while IFS= read -r -d '' f; do
echo "$f"
done
# Whole file into a variable
content="$(< file.txt)"
IFS= keeps leading and trailing whitespace. -r stops backslashes from being treated as escape characters. Use both every time you read lines.
Exit codes & error handling
| Code | Meaning |
0 | success |
1 | general error |
2 | misuse of shell builtin / bad usage |
126 | command found but not executable |
127 | command not found |
128+N | killed by signal N (130 = Ctrl-C, 137 = kill -9) |
die() { echo "ERROR: $*" >&2; exit 1; }
[[ -r "$config" ]] || die "Cannot read $config"
cd "$dir" || die "cd failed"
# Check status explicitly
if ! output=$(some_command 2>&1); then
die "some_command failed: $output"
fi
# Allow a command to fail under set -e
rm -f maybe.txt || true
# Require dependencies
for cmd in curl jq git; do
command -v "$cmd" >/dev/null || die "Missing dependency: $cmd"
done
# Require root
(( EUID == 0 )) || die "Run as root (sudo)"
Traps & cleanup
tmp="$(mktemp -d)"
cleanup() { rm -rf "$tmp"; }
trap cleanup EXIT # runs however the script exits
trap 'echo "Interrupted"; exit 130' INT TERM
# Report the line where an error occurred
trap 'echo "Error on line $LINENO" >&2' ERR
# Simple lock so only one copy of the script runs at once
lock="/tmp/$(basename "$0").lock"
if ! mkdir "$lock" 2>/dev/null; then
echo "Already running" >&2; exit 1
fi
trap 'rm -rf "$lock"' EXIT
Debugging
bash -n script.sh # syntax check only, no execution
bash -x script.sh # print each command as it runs
set -x … set +x # trace only one section
# More informative trace prefix
export PS4='+ ${BASH_SOURCE}:${LINENO}: ${FUNCNAME[0]:+${FUNCNAME[0]}(): }'
# Static analysis (strongly recommended)
shellcheck script.sh # brew install shellcheck / apt install shellcheck
Best practices
- Start with
#!/usr/bin/env bash and set -euo pipefail.
- Quote every expansion:
"$var", "$@", "$(cmd)".
- Use
[[ ]] for tests and (( )) for arithmetic.
- Declare function variables with
local.
- Send errors and logs to stderr (
>&2) so stdout stays clean for piping.
- Never parse
ls. Use globs or find -print0.
- Use
$(…), not backticks.
- Use
mktemp for temp files and clean up with trap … EXIT.
- Write
rm -- "$file" so a filename that starts with - isn't read as an option.
- Guard dangerous paths:
rm -rf "${dir:?}/"* refuses to run if dir is empty.
- Give risky scripts a dry-run mode, and print a usage message for
-h.
- Run
shellcheck on everything.
- Once a script grows past about 200 lines or needs complex data structures, switch to Python.
Files & directories
| Command | Purpose |
ls -lah | list all files, long format, human-readable sizes |
cd - | go back to the previous directory |
pwd | print working directory |
mkdir -p a/b/c | create nested directories |
cp -r src dest | copy recursively (-a preserves attributes) |
mv old new | move or rename |
rm -rf dir | delete recursively, no prompt (careful!) |
ln -s target link | create a symlink |
touch file | create file / update timestamp |
chmod 755 f / chmod +x f | change permissions |
chown user:group f | change owner |
stat file | detailed file info |
realpath file | absolute path |
rsync -avh src/ dest/ | sync directories (add --delete to mirror) |
du -sh * | size of each item here |
df -h | free disk space |
find
find . -name "*.log" # by name (-iname = case-insensitive)
find . -type f -size +100M # files over 100 MB
find . -type f -mtime -1 # modified in the last 24 h
find . -type d -empty -delete # delete empty dirs
find . -name "*.tmp" -exec rm {} + # run a command on matches
find . -type f -name "*.sh" -print0 | xargs -0 chmod +x
find . -path ./node_modules -prune -o -name "*.js" -print # exclude a dir
Text processing
| Command | Purpose |
cat / less | print / page through a file |
head -n 20 / tail -n 20 | first / last lines |
tail -f app.log | follow a file as it grows |
wc -l | count lines (-w words, -c bytes) |
sort | sort lines (-n numeric, -r reverse, -u unique, -k2 by column 2) |
uniq -c | count adjacent duplicates (sort first) |
cut -d, -f1,3 | extract fields 1 and 3 from CSV |
tr 'a-z' 'A-Z' | translate characters (-d deletes) |
paste -sd, | join lines with commas |
column -t | align output into a table |
diff -u a b | compare files |
xargs | build commands from stdin (-n1, -P4 parallel) |
jq | query and transform JSON |
grep
grep "error" app.log # lines containing "error"
grep -i "error" app.log # case-insensitive
grep -v "debug" app.log # lines NOT matching
grep -rn "TODO" src/ # recursive, with line numbers
grep -E "warn|error" app.log # extended regex (alternation)
grep -o "[0-9]\+\.[0-9]\+" f # print only the match
grep -c "error" app.log # count matching lines
grep -l "foo" *.txt # list files that match
grep -A3 -B1 "panic" app.log # 3 lines after, 1 before
sed
sed 's/old/new/' file # replace first match per line
sed 's/old/new/g' file # replace all
sed -i 's/old/new/g' file # edit in place (GNU)
sed -i '' 's/old/new/g' file # edit in place (macOS/BSD)
sed -n '10,20p' file # print lines 10–20
sed '/^#/d' file # delete comment lines
sed '/^$/d' file # delete blank lines
sed -E 's/([a-z]+)@/\1 at /' f # capture groups
awk
awk '{print $1}' file # first column (whitespace-split)
awk -F, '{print $2}' data.csv # custom separator
awk '$3 > 100' file # filter rows
awk '{sum += $2} END {print sum}' file # sum a column
awk 'NR > 1' file # skip header line
awk '{print NR": "$0}' file # number lines
awk -F: '{print $1, $NF}' /etc/passwd # first and last field
awk '{count[$1]++} END {for (k in count) print count[k], k}' access.log
Classic pipelines
# Top 10 most frequent words
tr -cs '[:alpha:]' '\n' < book.txt | tr 'A-Z' 'a-z' | sort | uniq -c | sort -rn | head
# Most common lines in a log
sort app.log | uniq -c | sort -rn | head
# Pretty-print and filter JSON
curl -s https://api.github.com/repos/torvalds/linux | jq '{name, stars: .stargazers_count}'
System & processes
| Command | Purpose |
ps aux | all running processes |
ps aux | grep [n]ginx | find a process (the [n] trick hides grep itself) |
pgrep -fl name | find PIDs by name |
top / htop | live process monitor |
kill PID | ask a process to stop (SIGTERM) |
kill -9 PID | force kill (SIGKILL) |
pkill -f pattern | kill by name / pattern |
jobs / fg / bg | manage shell jobs (Ctrl-Z suspends) |
nohup cmd & | keep running after logout |
lsof -i :8080 | what's using port 8080 |
uname -a | kernel / OS info |
uptime | uptime and load average |
free -h | memory usage (Linux) |
whoami / id | current user / groups |
env / printenv | environment variables |
which cmd / type cmd | where a command comes from |
time cmd | measure how long a command takes |
watch -n 2 cmd | re-run every 2 seconds |
crontab -e | edit scheduled jobs |
systemctl status svc | service status (systemd) |
journalctl -u svc -f | follow a service's logs |
Cron syntax
# ┌ minute (0-59)
# │ ┌ hour (0-23)
# │ │ ┌ day of month (1-31)
# │ │ │ ┌ month (1-12)
# │ │ │ │ ┌ day of week (0-7, Sun=0 or 7)
# * * * * * command
0 2 * * * /path/backup.sh # daily at 02:00
*/15 * * * * /path/healthcheck.sh # every 15 minutes
0 9 * * 1-5 /path/report.sh # weekdays at 09:00
@reboot /path/startup.sh # at boot
Networking
curl -fsSL https://example.com # fetch (fail on HTTP errors, silent, follow redirects)
curl -o file.zip https://example.com/f.zip
curl -X POST -H "Content-Type: application/json" -d '{"a":1}' https://api.example.com
curl -I https://example.com # headers only
curl -w '%{http_code}\n' -o /dev/null -s https://example.com # status code only
wget -q https://example.com/file
ssh user@host # remote shell
ssh user@host 'df -h' # run one remote command
ssh -L 8080:localhost:80 user@host # local port forward
scp file.txt user@host:/tmp/ # copy to remote
rsync -avz ./site/ user@host:/var/www/ # efficient sync over ssh
ssh-keygen -t ed25519 && ssh-copy-id user@host
ping -c 4 host
dig example.com +short # DNS lookup
nc -zv host 22 # is a port open?
ss -tulpn # Linux / lsof -iTCP -sTCP:LISTEN # macOS — listening ports
Archives
| Command | Purpose |
tar -czf out.tar.gz dir/ | create gzip tarball |
tar -xzf file.tar.gz | extract (add -C dir for destination) |
tar -tzf file.tar.gz | list contents |
tar -cJf out.tar.xz dir/ | xz compression (smaller, slower) |
zip -r out.zip dir/ | create zip |
unzip file.zip -d dest/ | extract zip |
gzip f / gunzip f.gz | compress / decompress a single file |
zcat f.gz / zgrep pat f.gz | read / search without extracting |
To remember the tar flags: create, extract, list, z = gzip, f = file (always last, because the filename follows it).
Shell shortcuts
| Keys / syntax | Action |
Ctrl-A / Ctrl-E | jump to start / end of line |
Ctrl-W / Ctrl-U / Ctrl-K | delete word / to start / to end |
Ctrl-R | search command history |
Ctrl-L | clear screen |
Ctrl-C / Ctrl-Z / Ctrl-D | interrupt / suspend / exit (EOF) |
!! | repeat last command (sudo !!) |
!$ | last argument of previous command |
!grep | re-run last command starting with grep |
^old^new | re-run last command with a substitution |
Alt-. | insert last argument (press repeatedly to cycle) |
history | grep x | search history |
alias ll='ls -lah' | define a shortcut (put in ~/.bashrc) |
Example scripts
Complete, tested scripts from the scripts/ folder. Pick one to read the source, then copy it or download the raw file.
Quick start
git clone https://github.com/choicespecs/bash-reference.git
cd bash-reference/scripts
./backup.sh ~/Documents ~/backups 7
# Or grab a single script
curl -fsSLO https://choicespecs.github.io/bash-reference/scripts/retry.sh && chmod +x retry.sh
No sections match your filter.