Version note: associative arrays,
${v^^}/${v,,}andmapfileneed 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 withbash --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#
- Full tutorial with 135 examples, 25 exercises and the capstone: Bash Scripting: Zero to Pro
- Test yourself: Take the Bash quiz (25 questions)
- All scripts, including
template-pro-skeleton.sh: Download all 162 scripts (zip) - New to the terminal? Start with Linux from Scratch and its shell scripting module (M14).