Bash Scripting Essentials
The minimum vocabulary for writing maintainable bash scripts: shebang, variables, conditionals, loops, functions, arguments. Bash isn’t Python — its quirks bite hard. Use it for glue, not business logic.
The shebang and strict mode
Every script starts with:
#!/usr/bin/env bash
set -euo pipefail
| Bit | Why |
|---|---|
#!/usr/bin/env bash |
finds bash via $PATH (more portable than hardcoded /bin/bash) |
set -e |
exit immediately on any command failure |
set -u |
error on use of unset variables |
set -o pipefail |
a pipeline fails if any command in it fails (not just the last) |
Without these, scripts silently soldier on past failures. See 06_exit_codes_strict_mode.md.
Variables
name="alice" # NO spaces around the =
greeting="hello, $name" # interpolated
echo "$greeting" # always quote variable expansions
| Mistake | What it does |
|---|---|
name = "alice" |
runs the program name with args = and "alice" |
name="hello $name" |
works; $name interpolates |
name='hello $name' |
literal $name — single quotes don’t interpolate |
echo $files |
word-splits on whitespace; breaks for filenames with spaces |
echo "$files" |
safe; preserves the value as one word |
Always quote "$var" unless you specifically want word splitting.
Arrays
fruits=(apple banana "kiwi fruit")
echo "${fruits[0]}" # apple
echo "${fruits[@]}" # apple banana "kiwi fruit" (each as one word)
echo "${fruits[*]}" # apple banana kiwi fruit (one word, joined)
echo "${#fruits[@]}" # 3 (length)
for f in "${fruits[@]}"; do
echo "$f"
done
"${arr[@]}" (with quotes) is the safe iteration. Bare ${arr[@]} word-splits.
Associative arrays (bash 4+):
declare -A user
user[name]="alice"
user[email]="a@b.com"
echo "${user[name]}"
Conditionals
if [[ "$x" -eq 5 ]]; then
echo "five"
elif [[ "$x" -gt 5 ]]; then
echo "big"
else
echo "small"
fi
Use [[ ... ]] (bash builtin), not [ ... ] (older test):
[ ... ] |
[[ ... ]] |
|---|---|
POSIX, works in sh |
bash-only |
| word-splits unquoted vars (bug-prone) | safer with vars |
no && ` |
|
no =~ regex |
supports =~ regex |
[[ -f /etc/passwd ]] # file exists
[[ -d /tmp ]] # directory exists
[[ -z "$var" ]] # var is empty/unset
[[ -n "$var" ]] # var is non-empty
[[ "$x" == "abc" ]] # string equality
[[ "$x" =~ ^[0-9]+$ ]] # regex match
[[ "$x" -eq 5 ]] # numeric equality
[[ "$a" == "b" && "$c" == "d" ]] # AND
| Test | String | Numeric |
|---|---|---|
| equal | == or = |
-eq |
| not equal | != |
-ne |
| less | < (alphabetical!) |
-lt |
| greater | > (alphabetical!) |
-gt |
[[ "10" < "9" ]] is true (alphabetical) — use -lt for numbers.
Loops
# while
while read -r line; do
echo "got: $line"
done < input.txt
# for over a list
for f in *.txt; do
echo "$f"
done
# for over command output
for pid in $(pgrep gunicorn); do
echo "$pid"
done
# C-style
for ((i=0; i<10; i++)); do
echo "$i"
done
# until (loop until command succeeds)
until curl -sf http://localhost:8000/healthz; do
sleep 1
done
The “read a file line-by-line” idiom is while IFS= read -r line; do ...; done < file. Without -r, backslashes are interpreted; without IFS=, leading/trailing whitespace is stripped.
Functions
greet() {
local name="$1" # `local` scopes to the function
local greeting="${2:-hello}"
echo "$greeting, $name"
}
greet "alice" # "hello, alice"
greet "bob" "hi" # "hi, bob"
result=$(greet "carol") # capture output
echo "$result"
Functions:
- Take positional args via
$1,$2,$@. - Don’t declare params in
(). - Return values via stdout (
echo) or viareturn(numeric exit code only, 0–255). - Use
localto avoid leaking variables to the global scope.
Arguments and $@ vs $*
#!/usr/bin/env bash
echo "script name: $0"
echo "first arg: $1"
echo "all args: $@"
echo "count: $#"
"$@" |
"$*" |
|
|---|---|---|
| As one word | no — each arg separate | yes — joined by first IFS char |
| Use for | passing args along (func "$@") |
rarely |
forward_to_python() {
python script.py "$@" # preserves arg boundaries
}
forward_to_python "hello world" foo correctly forwards two args, not three.
Default values and required args
MODE="${MODE:-production}" # default if unset or empty
PORT="${PORT:-8000}"
if [[ -z "${API_KEY:-}" ]]; then
echo "ERROR: API_KEY required" >&2
exit 1
fi
${var:-default} substitutes default if var is unset/empty. The full table is in 04_parameter_expansion.md.
Command substitution
today=$(date +%Y-%m-%d)
files=$(ls -1 *.txt | wc -l)
hash=$(git rev-parse --short HEAD)
# AVOID backticks — old syntax, doesn't nest cleanly
hash=`git rev-parse --short HEAD`
$(...) is the modern form. Backticks work but nest awkwardly.
A skeleton script
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<EOF
Usage: $0 [-v] [-o output] input
-v verbose
-o file output path (default: stdout)
input input file
EOF
exit 1
}
verbose=0
output=""
while getopts "vo:h" opt; do
case "$opt" in
v) verbose=1 ;;
o) output="$OPTARG" ;;
h|*) usage ;;
esac
done
shift $((OPTIND-1))
[[ $# -lt 1 ]] && usage
input="$1"
[[ "$verbose" == 1 ]] && echo "Processing $input → ${output:-stdout}" >&2
# ... actual work ...
getopts parses single-char flags (-v, -o file). For long flags (--verbose), use getopt (different tool, GNU-specific) or hand-roll a case loop.
When NOT to use bash
Hard limits:
- Math beyond integers — use Python.
- Anything with structured data (JSON, XML, CSV with quoted fields) — use Python or
jq. - Scripts past ~100 lines — port to Python.
- Anything that needs error handling beyond “exit on failure” — bash error handling is famously fragile.
Bash is for: glue, deploys, one-shot ops scripts, CI helpers, container entrypoints.
Common interview confusions
- “
name = valueworks.” — no, spaces matter. Variable assignment isname=valuewith no spaces. - “You can return strings from a function with
return.” — no,returnonly takes a numeric exit code (0–255). To return a string,echoit and capture with$(...). - “
[ ... ]and[[ ... ]]are the same.” —[[ ... ]]is bash-only with safer semantics (no word-splitting, supports regex=~,&&inside). Always prefer it in bash scripts. - “
#!/bin/bashis portable.” — fine on Linux, but macOS ships ancient bash 3.2 at/bin/bash; modern bash from Homebrew lives elsewhere.#!/usr/bin/env bashfinds whichever is on$PATH.
Interview angle
- “What does
set -euo pipefaildo?” —-eexit on error,-uerror on unset variable,-o pipefailpipeline fails if any command fails. Standard hardening for any script. - “
[ ]vs[[ ]]— what’s the difference?” —[ ]is POSIXtest; word-splits unquoted vars, no regex.[[ ]]is bash-builtin, safer with variables, supports regex=~and logical&&/||. - “Why quote
\"$var\"?” — unquoted$varundergoes word-splitting on whitespace and glob expansion. Filenames with spaces explode into multiple args. Always quote unless you specifically want splitting. - “How do you return a value from a bash function?” —
echothe value, capture withresult=$(func).returnonly sets the exit code (0–255). - “What’s the difference between
\"$@\"and\"$*\"?” —"$@"expands to each arg as a separate word (the safe form for re-forwarding args)."$*"joins all args into one word using the first IFS character (rarely what you want). - “When should you NOT use bash?” — anything with structured data (use jq or Python), anything past ~100 lines (port to Python), anything needing real error handling, anything doing math beyond integers.