← Bash Scripting: Zero to ProTake the quiz
Cheat sheetBash 4+Production template

Bash Scripting Cheat Sheet: Zero to Pro

The syntax you will reach for most often, on one page. Every pattern here is used in a tested example in Bash Scripting: Zero to Pro, where you can see it run with real output.

Download all 162 scripts (zip)Full tutorial

Version note: associative arrays, ${v^^} / ${v,,} and mapfile need Bash 4 or newer; printf '%(...)T' and reading a negative index such as ${a[-1]} need Bash 4.2 or newer. macOS ships Bash 3.2; install a newer Bash before using those lines. Check with bash --version.

Running scripts#

#!/bin/bash                 # shebang: first line, first character
bash script.sh              # run with bash (no permission needed)
chmod +x script.sh          # make executable (once)
./script.sh                 # run from the current folder
bash -n script.sh           # syntax check only
bash -x script.sh           # trace every command
shellcheck script.sh        # lint for bugs

Variables and quoting#

name="web01"                # no spaces around =
echo "$name ${name}_old"    # read; braces when text follows
export APP_ENV=prod         # visible to child processes
readonly MAX=5              # constant
"double $expands"   'single $literal'   "escape \$ \""

Output and input#

echo "text"                 # print + newline
printf "%-10s %5d\n" a 42   # formatted (%s text, %d integer)
read -r -p "Name: " name    # prompt + read a line
read -r a b <<< "x y"       # split into variables
read -r -s -p "Pass: " pw   # hidden input

Exit status#

cmd; echo $?                # 0 = success, 1-255 = failure
status=$?                   # save immediately
cmd && echo ok || echo fail # quick reactions (not a true if/else)
exit 0 / exit 1 / exit 2    # success / error / usage error
${PIPESTATUS[@]}            # status of every command in the last pipe

Tests#

Files Strings Integers [ ] Integers (( ))
-e exists == / = equal -eq ==
-f regular file != different -ne !=
-d directory -z empty -gt >
-r readable -n not empty -ge >=
-w writable == pat* glob (in [[ ]]) -lt <
-x executable =~ regex (in [[ ]]) -le <=
-s not empty
[ -f "$f" ]                 # POSIX test: quote everything
[[ -f $f && -r $f ]]        # bash test: && inside, no splitting
(( n > 5 ))                 # arithmetic test

Conditionals#

if CMD; then ...; elif CMD; then ...; else ...; fi

case "$x" in
  start|begin) ... ;;
  *.log)       ... ;;
  *)           ... ;;       # default
esac

Loops#

for s in a b c; do ...; done
for n in {1..5}; do ...; done          # range (no variables inside)
for f in ./*.log; do [[ -e $f ]] || continue; ...; done
for (( i = 0; i < n; i++ )); do ...; done
while (( i < 5 )); do ((i++)); done
until [[ -f ready ]]; do sleep 1; done
while IFS= read -r line || [[ -n $line ]]; do ...; done < file
break        continue        break 2

Substitution and arithmetic#

today=$(date +%F)            # command substitution
echo "$(( 17 / 5 )) $(( 17 % 5 )) $(( 2 ** 10 ))"   # 3 2 1024
(( count += 1 ))             # safe increment under set -e
pct=$(( used * 100 / total ))  # multiply before dividing

Functions#

greet() { local who=${1:-world}; echo "Hello, $who"; }
greet "Asha"                 # call: no parentheses
msg=$(greet)                 # capture echo output = data
is_ok() { [[ $1 == ok ]]; }  # status = yes/no answer
if is_ok "$s"; then ...; fi
return 0..255                # status only; echo for data

Script arguments#

$0 script name   $1..$9, ${10}   $# count   "$@" all (separate)   "$*" all (joined)
shift            # drop $1
: "${1:?usage: $0 NAME}"   # required argument

Arrays#

a=(x y z); a+=(w)            # create / append
"${a[0]}" "${a[-1]}"          # first / last
"${a[@]}"  ${#a[@]}  "${!a[@]}"  # all / count / indexes
"${a[@]:1:2}"                 # slice
mapfile -t lines < file       # file -> array
declare -A m=([k]=v); m[x]=1  # associative (bash 4+)
"${!m[@]}"                    # keys (unordered)
[[ -n ${m[k]+set} ]]          # key exists?

String manipulation#

${#v}            length            ${v:2:3}   substring
${v#pat}  ${v##pat}   trim shortest/longest from START
${v%pat}  ${v%%pat}   trim shortest/longest from END
${v/old/new}  ${v//old/new}   replace first / all
${v^^}  ${v,,}   upper / lower
${v:-def}  ${v:=def}  ${v:?err}   default / assign default / require
${p##*/} file name   ${p%/*} folder   ${f%.*} drop extension   ${f##*.} extension

Redirection and pipes#

cmd > f      # stdout to file (overwrite)      cmd >> f    # append
cmd 2> f     # stderr to file                  cmd > f 2>&1   # both (order matters)
cmd &> f     # both (bash)                     cmd > /dev/null 2>&1   # silence
cmd < f      # stdin from file                 a | b | c   # pipeline
cmd | tee -a log   # screen + file             echo "err" >&2   # write to stderr

Here-docs#

cat <<EOF > file      # expands $vars
port=$port
EOF
cat <<'EOF'           # literal, no expansion
$HOME stays as text
EOF
cmd <<< "string"      # here-string

Pro toolkit#

set -euo pipefail                     # strict mode
trap cleanup EXIT                     # always clean up
trap 'exit 130' INT TERM              # Ctrl+C / kill
trap 'echo "line $LINENO failed" >&2' ERR
tmp=$(mktemp); dir=$(mktemp -d)       # safe temp files
mv "$tmp" final.conf                  # atomic replace
mkdir -p d; grep -qxF "$l" f || echo "$l" >> f   # idempotent
mkdir "$lock" 2>/dev/null || exit 0   # simple lock (or flock -n)
log() { printf '%s [%s] %s\n' "$(date '+%F %T')" "$1" "${*:2}" >&2; }
die() { log ERROR "$*"; exit 2; }
PS4='+ ${LINENO}: '; set -x ... set +x   # trace a section

getopts template#

while getopts ":n:vh" opt; do
  case $opt in
    n) name=$OPTARG ;;
    v) verbose=1 ;;
    h) usage; exit 0 ;;
    :) echo "-$OPTARG needs a value" >&2; exit 2 ;;
    \?) echo "unknown -$OPTARG" >&2; exit 2 ;;
  esac
done
shift $((OPTIND - 1))

cron#

# minute hour day-of-month month day-of-week  command
*/5 * * * *    /opt/scripts/health.sh >> /var/log/health.log 2>&1
0 2 * * *      /opt/scripts/backup.sh
30 9 * * 1-5   /opt/scripts/report.sh
crontab -e  (edit)   crontab -l  (list)   escape % as \%   set PATH in the script

Exit codes worth knowing#

Code Meaning
0 success
1 general error
2 misuse / bad arguments (convention)
126 found but not executable
127 command not found
128+N killed by signal N (130 = Ctrl+C, 143 = TERM)

Production script template#

A verified skeleton combining strict mode, logging, traps, getopts and a main function. Copy it as the starting point for new scripts. File: template-pro-skeleton.sh

#!/bin/bash
# template.sh - a starting point for production scripts (copy and rename me)
set -euo pipefail

readonly SCRIPT=${0##*/}
verbose=0
name="world"
work_dir=""

usage() { echo "Usage: $SCRIPT [-n NAME] [-v] [-h]"; }
log()   { printf '%s [%s] %s\n' "$(date '+%F %T')" "$1" "${*:2}" >&2; }
die()   { log ERROR "$*"; exit 2; }

# cleanup is called by the EXIT trap
# shellcheck disable=SC2329
cleanup() { if [[ -n $work_dir ]]; then rm -rf "$work_dir"; fi; }
trap cleanup EXIT
trap 'die "interrupted"' INT TERM

parse_args() {
  local opt
  while getopts ":n:vh" opt; do
    case $opt in
      n) name=$OPTARG ;;
      v) verbose=1 ;;
      h) usage; exit 0 ;;
      :) die "-$OPTARG needs a value" ;;
      \?) usage >&2; die "unknown option -$OPTARG" ;;
    esac
  done
  shift $((OPTIND - 1))
  [[ -n $name ]] || die "name must not be empty"
}

main() {
  parse_args "$@"
  work_dir=$(mktemp -d)
  if (( verbose )); then log INFO "working in a private temp folder"; fi
  echo "Hello, $name!" > "$work_dir/greeting.txt"
  cat "$work_dir/greeting.txt"
}

main "$@"

Run it:

bash template-pro-skeleton.sh -n Pushpjeet -v

Sample output (yours will differ: the timestamp changes every run):

2026-10-08 18:55:34 [INFO] working in a private temp folder
Hello, Pushpjeet!

Keep going#

Newsletter

Liked this? Get the next tutorial by email

New tutorials, labs and cheat sheets, plus AWS batch dates and Oracle tips if you want them. No spam, unsubscribe anytime.

I’m interested in

Double opt-in: you’ll get a confirmation email first. Privacy policy