Back to Computing & Systems Foundations Series

Part 9: Shells & Advanced Bash Scripting

May 13, 2026Wasil Zafar18 min read

Variables, quoting, control flow, functions, arrays, job control, and the patterns that make Bash scripts reliable — including the bugs that trip up everyone.

Table of Contents

  1. Shell Types
  2. Script Basics & Safety Options
  3. Variables & Quoting
  4. Control Flow
  5. Functions
  6. Arrays & Associative Arrays
  7. Traps & Error Handling
  8. Job Control
  9. Exercises
  10. Conclusion

Shell Types

The shell is the interface between the user and the kernel — a command interpreter that parses commands, manages processes, and provides scripting. Linux offers many shells; Bash (Bourne Again SHell) is the default on most distributions and the de facto standard for scripting.

ShellBinaryNotable Features
bash/bin/bashDefault on most Linux distros; rich scripting; good compatibility
sh/bin/shPOSIX shell; minimal but portable; often symlinks to dash/bash
dash/usr/bin/dashFast, minimal POSIX shell; Ubuntu's /bin/sh; great for system scripts
zsh/usr/bin/zshmacOS default; richer interactive features; Oh-My-Zsh ecosystem
fish/usr/bin/fishUser-friendly; auto-suggestions; not POSIX-compatible
# View your current shell
echo $SHELL          # Login shell (from /etc/passwd)
echo $0              # Current shell process name
ps -p $$             # Current process details

# List installed shells
cat /etc/shells

# Change your default shell
chsh -s /bin/zsh     # (requires logout/login to take effect)

Script Basics & Safety Options

Every production Bash script should start with these safety options:

#!/usr/bin/env bash
# ^^^ Use env to find bash — more portable than #!/bin/bash

set -euo pipefail
# -e: Exit immediately if any command fails (non-zero exit code)
# -u: Treat unset variables as an error (prevents silent $UNSET_VAR = "")
# -o pipefail: Fail if any command in a pipeline fails (not just the last)

# Combine them: set -euo pipefail
# This one line catches ~80% of common scripting bugs

# Additional useful options
set -x    # Print each command before executing (debug mode)
set +x    # Turn off debug mode

# Check script for syntax errors without running
bash -n script.sh

# Run with tracing
bash -x script.sh 2>&1 | head -50

Variables & Quoting

Quoting is the most common source of bugs in Bash scripts. The rule: always quote variable expansions with double quotes unless you specifically want word splitting or globbing.

#!/usr/bin/env bash
set -euo pipefail

# Variable assignment (no spaces around =)
NAME="Alice Smith"
COUNT=42
PATH_WITH_SPACES="/home/alice/my documents"

# Always quote variable expansions
echo "$NAME"              # Correct: treats as one argument
echo $NAME                # BUG: "Alice" and "Smith" become two arguments

# Unset variable protection (with -u flag or ${VAR:-default})
DB_HOST="${DB_HOST:-localhost}"        # Default value if unset
DB_PORT="${DB_PORT:-5432}"
REQUIRED="${REQUIRED:?'REQUIRED must be set'}"  # Error and exit if unset

# Command substitution
DATE=$(date +%Y-%m-%d)    # Modern syntax: $(command)
FILES=$(ls /tmp/*.log 2>/dev/null | wc -l)

# Arithmetic
RESULT=$(( 10 + 20 ))       # Integer arithmetic
RESULT=$(( COUNT * 2 ))
echo "Double: $RESULT"      # 84

# String operations
echo "${NAME,,}"             # Lowercase: alice smith
echo "${NAME^^}"             # Uppercase: ALICE SMITH
echo "${NAME:0:5}"           # Substring: Alice
echo "${NAME/Alice/Bob}"     # Substitution: Bob Smith
echo "${#NAME}"              # Length: 11

# Arrays
FRUITS=("apple" "banana" "cherry")
echo "${FRUITS[0]}"          # apple
echo "${FRUITS[@]}"          # all: apple banana cherry
echo "${#FRUITS[@]}"         # count: 3
FRUITS+=("date")             # Append
for fruit in "${FRUITS[@]}"; do echo "$fruit"; done

Control Flow

if / test

#!/usr/bin/env bash
set -euo pipefail

# File/path tests
FILE="/etc/passwd"
if [ -f "$FILE" ]; then echo "file exists"; fi
if [ -d "/tmp" ]; then echo "directory exists"; fi
if [ -r "$FILE" ]; then echo "readable"; fi
if [ -x "/usr/bin/python3" ]; then echo "executable"; fi

# String tests
NAME="Alice"
if [ -z "$NAME" ]; then echo "empty"; fi              # zero length
if [ -n "$NAME" ]; then echo "not empty"; fi          # non-zero length
if [ "$NAME" = "Alice" ]; then echo "equals"; fi      # string equals (use = not ==)
if [[ "$NAME" =~ ^A.* ]]; then echo "starts with A"; fi  # regex (Bash only)

# Numeric tests
COUNT=42
if [ "$COUNT" -gt 10 ]; then echo "greater than 10"; fi
if [ "$COUNT" -eq 42 ]; then echo "equals 42"; fi
# Operators: -eq -ne -lt -le -gt -ge

# Logical operators
if [ -f "$FILE" ] && [ -r "$FILE" ]; then echo "file is readable"; fi
if [ "$COUNT" -lt 0 ] || [ "$COUNT" -gt 100 ]; then echo "out of range"; fi

# Modern [[...]] (Bash-specific, preferred for new scripts)
if [[ -f "$FILE" && -r "$FILE" ]]; then echo "readable file"; fi

for / while / until

#!/usr/bin/env bash
set -euo pipefail

# for loop — iterate over list
for server in web-01 web-02 db-01; do
    echo "Checking $server..."
    # ssh "$server" uptime
done

# for loop — iterate over array
SERVICES=("nginx" "postgresql" "redis")
for svc in "${SERVICES[@]}"; do
    systemctl is-active "$svc" 2>/dev/null && echo "$svc: running" || echo "$svc: stopped"
done

# C-style for loop
for ((i=1; i<=5; i++)); do
    echo "Step $i"
done

# while loop — condition before body
COUNT=0
while [ "$COUNT" -lt 5 ]; do
    echo "Count: $COUNT"
    COUNT=$(( COUNT + 1 ))
done

# Read file line by line
while IFS= read -r line; do
    echo "Line: $line"
done < /etc/passwd

# Process output of a command
while IFS= read -r pid; do
    echo "Process: $pid"
done < <(pgrep python3 2>/dev/null)  # process substitution

case

#!/usr/bin/env bash
set -euo pipefail

# case statement — cleaner than if/elif for matching
OS=$(uname -s)
case "$OS" in
    Linux)   echo "Running on Linux" ;;
    Darwin)  echo "Running on macOS" ;;
    MINGW*)  echo "Running on Windows (Git Bash)" ;;
    *)       echo "Unknown OS: $OS"; exit 1 ;;
esac

# Match against multiple patterns
STATUS="$1"  # Command line argument
case "$STATUS" in
    start|restart)  echo "Starting service" ;;
    stop|halt)      echo "Stopping service" ;;
    status)         echo "Checking status" ;;
    *)              echo "Usage: $0 {start|stop|restart|status}"; exit 1 ;;
esac

Functions

#!/usr/bin/env bash
set -euo pipefail

# Function definition
log_info() {
    local timestamp
    timestamp=$(date '+%Y-%m-%d %H:%M:%S')
    echo "[$timestamp] INFO: $*"   # $* = all arguments as single string
}

log_error() {
    local timestamp
    timestamp=$(date '+%Y-%m-%d %H:%M:%S')
    echo "[$timestamp] ERROR: $*" >&2   # Write to stderr
}

# Functions can return values via stdout or exit code
get_container_ip() {
    local container="$1"
    docker inspect "$container" 2>/dev/null | \
        python3 -c "import json,sys; d=json.load(sys.stdin); print(d[0]['NetworkSettings']['IPAddress'])"
}

# Use local for all function variables (avoid polluting global scope)
calculate() {
    local a="$1"
    local b="$2"
    local op="$3"
    case "$op" in
        add)  echo $(( a + b )) ;;
        sub)  echo $(( a - b )) ;;
        mul)  echo $(( a * b )) ;;
        *)    log_error "Unknown op: $op"; return 1 ;;
    esac
}

# Test the functions
log_info "Script started"
RESULT=$(calculate 10 5 add)
log_info "10 + 5 = $RESULT"

Arrays & Associative Arrays

#!/usr/bin/env bash
set -euo pipefail

# Indexed arrays
declare -a SERVERS
SERVERS=("web-01" "web-02" "web-03")
SERVERS+=("db-01")               # Append

echo "${SERVERS[0]}"             # First: web-01
echo "${SERVERS[-1]}"            # Last: db-01 (Bash 4.2+)
echo "${SERVERS[@]}"             # All elements
echo "${#SERVERS[@]}"            # Count: 4
echo "${SERVERS[@]:1:2}"         # Slice: elements 1-2

# Iterate safely
for server in "${SERVERS[@]}"; do
    echo "Server: $server"
done

# Delete element
unset "SERVERS[2]"               # Delete web-03
SERVERS=("${SERVERS[@]}")        # Re-index

# Associative arrays (dictionaries) — Bash 4.0+
declare -A CONFIG
CONFIG["db_host"]="localhost"
CONFIG["db_port"]="5432"
CONFIG["db_name"]="myapp"

echo "${CONFIG[db_host]}"        # localhost
echo "${!CONFIG[@]}"             # All keys
echo "${CONFIG[@]}"              # All values
for key in "${!CONFIG[@]}"; do
    echo "$key = ${CONFIG[$key]}"
done

Traps & Error Handling

#!/usr/bin/env bash
set -euo pipefail

# Trap signals and errors for cleanup
LOCKFILE="/tmp/my-script.lock"
TMPDIR_CREATED=""

cleanup() {
    local exit_code=$?
    echo "Cleaning up (exit code: $exit_code)..."
    [ -f "$LOCKFILE" ] && rm -f "$LOCKFILE"
    [ -n "$TMPDIR_CREATED" ] && rm -rf "$TMPDIR_CREATED"
    exit $exit_code
}

# Register cleanup to run on:
# EXIT (any exit), INT (Ctrl+C), TERM (kill), ERR (any error with set -e)
trap cleanup EXIT INT TERM

# Create a lock file to prevent concurrent runs
if [ -f "$LOCKFILE" ]; then
    echo "ERROR: Another instance is running (lockfile: $LOCKFILE)" >&2
    exit 1
fi
touch "$LOCKFILE"

# Create temp directory
TMPDIR_CREATED=$(mktemp -d)

# Error handling pattern
run_with_retry() {
    local max_attempts=3
    local attempt=1
    local cmd=("$@")

    while [ $attempt -le $max_attempts ]; do
        if "${cmd[@]}"; then
            return 0
        fi
        echo "Attempt $attempt failed. Retrying..."
        attempt=$(( attempt + 1 ))
        sleep $(( attempt * 2 ))  # Exponential backoff
    done
    echo "All $max_attempts attempts failed" >&2
    return 1
}

echo "Script complete"
Common Bugs

The Top 5 Bash Scripting Bugs (and How to Avoid Them)

  1. Unquoted variables: rm $FILE → Use rm "$FILE". Without quotes, filenames with spaces split into multiple arguments.
  2. Missing set -e: A failing command silently continues. Always start with set -euo pipefail.
  3. Comparing strings with == in [...]: Use = in single brackets, or use [[...]] where == works.
  4. Forgetting to handle pipefail: command | grep foo — if command fails, without -o pipefail the exit code is grep's (0 if found), hiding the failure.
  5. Using global variables in functions: Always declare local variables inside functions to prevent namespace pollution.
Bash Bugsset -eQuoting

Job Control

#!/usr/bin/env bash
# Job control in interactive shells

# Start jobs in background
sleep 30 &          # Background job
echo "Background PID: $!"  # $! = PID of last background job

# List jobs
jobs                # Show all background jobs with job IDs

# Bring to foreground
fg %1               # Bring job 1 to foreground
fg %sleep           # Bring by name

# Send to background
Ctrl+Z              # Suspend foreground job
bg %1               # Resume job 1 in background

# Wait for background jobs
sleep 5 & sleep 3 & sleep 1 &
wait                # Wait for ALL background jobs
echo "All done"

# Wait for specific job
sleep 5 &
PID=$!
wait $PID           # Wait for specific PID
echo "Job $PID complete: exit code $?"

# Parallel execution pattern
for host in web-01 web-02 web-03; do
    ssh "$host" "hostname -f" &
done
wait                # Wait for all ssh commands

Exercises

#!/usr/bin/env bash
set -euo pipefail

# Exercise 1: Write a script that checks if required tools are installed
check_dependencies() {
    local missing=()
    for tool in git docker kubectl curl jq; do
        command -v "$tool" >/dev/null 2>&1 || missing+=("$tool")
    done
    if [ ${#missing[@]} -gt 0 ]; then
        echo "Missing tools: ${missing[*]}"
        return 1
    fi
    echo "All dependencies present"
}
check_dependencies

# Exercise 2: Parse arguments
usage() { echo "Usage: $0 [-e ENV] [-v] command" >&2; exit 1; }
VERBOSE=false
ENV="development"
while getopts "e:v" opt; do
    case "$opt" in
        e) ENV="$OPTARG" ;;
        v) VERBOSE=true ;;
        *) usage ;;
    esac
done
echo "ENV=$ENV, VERBOSE=$VERBOSE"

# Exercise 3: Retry logic
attempt=0
until ping -c1 8.8.8.8 >/dev/null 2>&1 || [ $attempt -ge 3 ]; do
    attempt=$((attempt + 1))
    echo "Attempt $attempt: waiting for network..."
    sleep 2
done
echo "Network check complete"

Conclusion & Next Steps

Reliable Bash scripting comes down to a few rules: always use set -euo pipefail, always quote variable expansions, use local in functions, trap for cleanup, and test with bash -n. The scripting patterns in this article — retry loops, argument parsing, cleanup traps — form the backbone of most CI/CD scripts, deployment automation, and operational tools.