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.
| Shell | Binary | Notable Features |
|---|---|---|
| bash | /bin/bash | Default on most Linux distros; rich scripting; good compatibility |
| sh | /bin/sh | POSIX shell; minimal but portable; often symlinks to dash/bash |
| dash | /usr/bin/dash | Fast, minimal POSIX shell; Ubuntu's /bin/sh; great for system scripts |
| zsh | /usr/bin/zsh | macOS default; richer interactive features; Oh-My-Zsh ecosystem |
| fish | /usr/bin/fish | User-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"
The Top 5 Bash Scripting Bugs (and How to Avoid Them)
- Unquoted variables:
rm $FILE→ Userm "$FILE". Without quotes, filenames with spaces split into multiple arguments. - Missing set -e: A failing command silently continues. Always start with
set -euo pipefail. - Comparing strings with == in
[...]: Use=in single brackets, or use[[...]]where==works. - Forgetting to handle pipefail:
command | grep foo— if command fails, without-o pipefailthe exit code is grep's (0 if found), hiding the failure. - Using global variables in functions: Always
declare localvariables inside functions to prevent namespace pollution.
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.