backend / linux bash / 02_bash_scripting_essentials.md

Bash Scripting Essentials

6 interview angles 6 min read source

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 via return (numeric exit code only, 0–255).
  • Use local to 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 = value works.” — no, spaces matter. Variable assignment is name=value with no spaces.
  • “You can return strings from a function with return.” — no, return only takes a numeric exit code (0–255). To return a string, echo it 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/bash is portable.” — fine on Linux, but macOS ships ancient bash 3.2 at /bin/bash; modern bash from Homebrew lives elsewhere. #!/usr/bin/env bash finds whichever is on $PATH.

Interview angle

  • “What does set -euo pipefail do?”-e exit on error, -u error on unset variable, -o pipefail pipeline fails if any command fails. Standard hardening for any script.
  • [ ] vs [[ ]] — what’s the difference?”[ ] is POSIX test; word-splits unquoted vars, no regex. [[ ]] is bash-builtin, safer with variables, supports regex =~ and logical &&/||.
  • “Why quote \"$var\"?” — unquoted $var undergoes 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?”echo the value, capture with result=$(func). return only 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.