$ bash-reference GitHub

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 "$@"
LineMeaning
#!/usr/bin/env bashFind bash on PATH. More portable than #!/bin/bash, which on macOS is the old bash 3.2.
set -eExit right away when a command fails.
set -uTreat unset variables as an error.
set -o pipefailA 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

VariableMeaning
$0Script 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
$RANDOMRandom integer 0–32767
$LINENO, $SECONDSCurrent 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

SyntaxResult
${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

TestTrue if…
-e pathexists
-f pathis a regular file
-d pathis a directory
-L pathis a symlink
-r / -w / -x pathis readable / writable / executable
-s pathexists and isn't empty
a -nt b / a -ot ba is newer / older than b

String tests

TestTrue if…
-z "$s"string is empty
-n "$s"string is not empty
"$a" == "$b"strings are equal (!= for not equal)
"$s" == *.txtglob match (no quotes on the pattern)
"$s" =~ ^[0-9]+$regex match; groups go into ${BASH_REMATCH[@]}

Numeric tests

TestMeaning
-eq -neequal, not equal
-lt -leless than, less or equal
-gt -gegreater 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

SyntaxEffect
cmd > filestdout to file (overwrite)
cmd >> filestdout to file (append)
cmd 2> filestderr to file
cmd > file 2>&1stdout and stderr to file (order matters)
cmd &> filesame as above (bash shorthand)
cmd > /dev/null 2>&1discard all output
cmd < filefile as stdin
echo "msg" >&2write to stderr (for errors and logs)
cmd1 | cmd2stdout of cmd1 → stdin of cmd2
cmd1 |& cmd2stdout and stderr piped
cmd | tee fileshow output and save it (tee -a appends)

Command chaining

SyntaxRuns cmd2…
cmd1 ; cmd2always
cmd1 && cmd2only if cmd1 succeeded
cmd1 || cmd2only 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

CodeMeaning
0success
1general error
2misuse of shell builtin / bad usage
126command found but not executable
127command not found
128+Nkilled 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

CommandPurpose
ls -lahlist all files, long format, human-readable sizes
cd -go back to the previous directory
pwdprint working directory
mkdir -p a/b/ccreate nested directories
cp -r src destcopy recursively (-a preserves attributes)
mv old newmove or rename
rm -rf dirdelete recursively, no prompt (careful!)
ln -s target linkcreate a symlink
touch filecreate file / update timestamp
chmod 755 f / chmod +x fchange permissions
chown user:group fchange owner
stat filedetailed file info
realpath fileabsolute path
rsync -avh src/ dest/sync directories (add --delete to mirror)
du -sh *size of each item here
df -hfree 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

CommandPurpose
cat / lessprint / page through a file
head -n 20 / tail -n 20first / last lines
tail -f app.logfollow a file as it grows
wc -lcount lines (-w words, -c bytes)
sortsort lines (-n numeric, -r reverse, -u unique, -k2 by column 2)
uniq -ccount adjacent duplicates (sort first)
cut -d, -f1,3extract fields 1 and 3 from CSV
tr 'a-z' 'A-Z'translate characters (-d deletes)
paste -sd,join lines with commas
column -talign output into a table
diff -u a bcompare files
xargsbuild commands from stdin (-n1, -P4 parallel)
jqquery 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

CommandPurpose
ps auxall running processes
ps aux | grep [n]ginxfind a process (the [n] trick hides grep itself)
pgrep -fl namefind PIDs by name
top / htoplive process monitor
kill PIDask a process to stop (SIGTERM)
kill -9 PIDforce kill (SIGKILL)
pkill -f patternkill by name / pattern
jobs / fg / bgmanage shell jobs (Ctrl-Z suspends)
nohup cmd &keep running after logout
lsof -i :8080what's using port 8080
uname -akernel / OS info
uptimeuptime and load average
free -hmemory usage (Linux)
whoami / idcurrent user / groups
env / printenvenvironment variables
which cmd / type cmdwhere a command comes from
time cmdmeasure how long a command takes
watch -n 2 cmdre-run every 2 seconds
crontab -eedit scheduled jobs
systemctl status svcservice status (systemd)
journalctl -u svc -ffollow 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

CommandPurpose
tar -czf out.tar.gz dir/create gzip tarball
tar -xzf file.tar.gzextract (add -C dir for destination)
tar -tzf file.tar.gzlist 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.gzcompress / decompress a single file
zcat f.gz / zgrep pat f.gzread / 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 / syntaxAction
Ctrl-A / Ctrl-Ejump to start / end of line
Ctrl-W / Ctrl-U / Ctrl-Kdelete word / to start / to end
Ctrl-Rsearch command history
Ctrl-Lclear screen
Ctrl-C / Ctrl-Z / Ctrl-Dinterrupt / suspend / exit (EOF)
!!repeat last command (sudo !!)
!$last argument of previous command
!grepre-run last command starting with grep
^old^newre-run last command with a substitution
Alt-.insert last argument (press repeatedly to cycle)
history | grep xsearch 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