Bash scripts glue systems together. Here’s how to write them without the usual fragility.

Script Header

Always start with:

1
2
3
4
5
6
#!/usr/bin/env bash
set -euo pipefail

# -e: Exit on error
# -u: Error on undefined variables
# -o pipefail: Fail if any pipe command fails

Argument Parsing

Simple Positional

1
2
3
4
5
6
7
8
9
#!/usr/bin/env bash
set -euo pipefail

if [[ $# -lt 1 ]]; then
    echo "Usage: $0 <filename>" >&2
    exit 1
fi

FILENAME="$1"

With Options (getopts)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#!/usr/bin/env bash
set -euo pipefail

usage() {
    echo "Usage: $0 [-v] [-o output] [-n count] input"
    echo "  -v          Verbose mode"
    echo "  -o output   Output file"
    echo "  -n count    Number of iterations"
    exit 1
}

VERBOSE=false
OUTPUT=""
COUNT=1

while getopts "vo:n:h" opt; do
    case $opt in
        v) VERBOSE=true ;;
        o) OUTPUT="$OPTARG" ;;
        n) COUNT="$OPTARG" ;;
        h) usage ;;
        *) usage ;;
    esac
done
shift $((OPTIND - 1))

if [[ $# -lt 1 ]]; then
    usage
fi

INPUT="$1"

Long Options

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#!/usr/bin/env bash
set -euo pipefail

VERBOSE=false
OUTPUT=""

while [[ $# -gt 0 ]]; do
    case $1 in
        -v|--verbose)
            VERBOSE=true
            shift
            ;;
        -o|--output)
            OUTPUT="$2"
            shift 2
            ;;
        -h|--help)
            usage
            ;;
        -*)
            echo "Unknown option: $1" >&2
            exit 1
            ;;
        *)
            break
            ;;
    esac
done

Error Handling

Trap for Cleanup

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#!/usr/bin/env bash
set -euo pipefail

TMPDIR=""

cleanup() {
    if [[ -n "$TMPDIR" && -d "$TMPDIR" ]]; then
        rm -rf "$TMPDIR"
    fi
}
trap cleanup EXIT

TMPDIR=$(mktemp -d)
# Work with $TMPDIR - it's cleaned up on exit, error, or interrupt

Custom Error Handler

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#!/usr/bin/env bash
set -euo pipefail

error() {
    echo "Error: $1" >&2
    exit "${2:-1}"
}

warn() {
    echo "Warning: $1" >&2
}

# Usage
[[ -f "$CONFIG" ]] || error "Config file not found: $CONFIG"

Detailed Error Reporting

1
2
3
4
5
6
7
8
#!/usr/bin/env bash
set -euo pipefail

on_error() {
    echo "Error on line $1" >&2
    exit 1
}
trap 'on_error $LINENO' ERR

Variables and Defaults

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Default value
NAME="${1:-default}"

# Error if unset
NAME="${1:?Error: name required}"

# Default only if unset (not empty)
NAME="${NAME-default}"

# Assign default if unset
: "${NAME:=default}"

String Operations

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
FILE="/path/to/file.txt"

# Extract parts
echo "${FILE##*/}"      # file.txt (basename)
echo "${FILE%/*}"       # /path/to (dirname)
echo "${FILE%.txt}"     # /path/to/file (remove extension)
echo "${FILE##*.}"      # txt (extension only)

# Replace
echo "${FILE/path/new}" # /new/to/file.txt
echo "${FILE//t/T}"     # /paTh/To/file.TxT (all occurrences)

# Case conversion
echo "${FILE^^}"        # Uppercase
echo "${FILE,,}"        # Lowercase

# Length
echo "${#FILE}"         # String length

Conditionals

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# File tests
[[ -f "$FILE" ]]    # File exists
[[ -d "$DIR" ]]     # Directory exists
[[ -r "$FILE" ]]    # Readable
[[ -w "$FILE" ]]    # Writable
[[ -x "$FILE" ]]    # Executable
[[ -s "$FILE" ]]    # Non-empty

# String tests
[[ -z "$VAR" ]]     # Empty
[[ -n "$VAR" ]]     # Non-empty
[[ "$A" == "$B" ]]  # Equal
[[ "$A" != "$B" ]]  # Not equal
[[ "$A" =~ regex ]] # Regex match

# Numeric tests
[[ $A -eq $B ]]     # Equal
[[ $A -ne $B ]]     # Not equal
[[ $A -lt $B ]]     # Less than
[[ $A -le $B ]]     # Less or equal
[[ $A -gt $B ]]     # Greater than
[[ $A -ge $B ]]     # Greater or equal

# Logical
[[ $A && $B ]]      # And
[[ $A || $B ]]      # Or
[[ ! $A ]]          # Not

Loops

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# Over arguments
for arg in "$@"; do
    echo "$arg"
done

# Over array
arr=("one" "two" "three")
for item in "${arr[@]}"; do
    echo "$item"
done

# C-style
for ((i=0; i<10; i++)); do
    echo "$i"
done

# Over files
for file in *.txt; do
    [[ -f "$file" ]] || continue
    echo "$file"
done

# Read lines from file
while IFS= read -r line; do
    echo "$line"
done < "$FILE"

# Read lines from command
while IFS= read -r line; do
    echo "$line"
done < <(some_command)

Functions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# Basic function
greet() {
    local name="$1"
    echo "Hello, $name"
}

# With return value
is_valid() {
    local input="$1"
    [[ "$input" =~ ^[0-9]+$ ]]
}

if is_valid "$value"; then
    echo "Valid"
fi

# Return data via stdout
get_config() {
    cat /etc/myapp/config
}
CONFIG=$(get_config)

# Local variables
process() {
    local tmp
    tmp=$(mktemp)
    # tmp is local to this function
}

Arrays

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Create
arr=("one" "two" "three")
arr[3]="four"

# Access
echo "${arr[0]}"        # First element
echo "${arr[@]}"        # All elements
echo "${#arr[@]}"       # Length
echo "${arr[@]:1:2}"    # Slice (start:length)

# Add
arr+=("five")

# Iterate
for item in "${arr[@]}"; do
    echo "$item"
done

# With index
for i in "${!arr[@]}"; do
    echo "$i: ${arr[$i]}"
done

Associative Arrays

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
declare -A config

config[host]="localhost"
config[port]="8080"

echo "${config[host]}"
echo "${!config[@]}"    # All keys
echo "${config[@]}"     # All values

for key in "${!config[@]}"; do
    echo "$key: ${config[$key]}"
done

Process Substitution

1
2
3
4
5
6
7
# Compare two commands
diff <(sort file1) <(sort file2)

# Feed command output as file
while read -r line; do
    echo "$line"
done < <(curl -s "$URL")

Subshells

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Run in subshell (changes don't affect parent)
(
    cd /tmp
    rm -f *.tmp
)
# Still in original directory

# Capture output
result=$(command)

# Capture with error
result=$(command 2>&1)

Here Documents

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Multi-line string
cat << EOF
This is a multi-line
string with $VARIABLE expansion
EOF

# No variable expansion
cat << 'EOF'
This preserves $VARIABLE literally
EOF

# Here string
grep "pattern" <<< "$string"

Practical Patterns

Check Dependencies

1
2
3
4
5
6
7
8
9
check_deps() {
    local deps=("curl" "jq" "git")
    for dep in "${deps[@]}"; do
        if ! command -v "$dep" &> /dev/null; then
            echo "Missing dependency: $dep" >&2
            exit 1
        fi
    done
}

Logging

1
2
3
4
5
6
7
LOG_FILE="/var/log/myapp.log"

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}

log "Starting process"

Confirmation Prompt

1
2
3
4
5
6
7
8
confirm() {
    read -rp "$1 [y/N] " response
    [[ "$response" =~ ^[Yy]$ ]]
}

if confirm "Delete all files?"; then
    rm -rf /tmp/data/*
fi

Retry Logic

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
retry() {
    local max_attempts="$1"
    local delay="$2"
    shift 2
    local attempt=1
    
    until "$@"; do
        if ((attempt >= max_attempts)); then
            echo "Failed after $attempt attempts" >&2
            return 1
        fi
        echo "Attempt $attempt failed, retrying in ${delay}s..."
        sleep "$delay"
        ((attempt++))
    done
}

retry 3 5 curl -sf "$URL"

Lock File

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
LOCKFILE="/var/lock/myapp.lock"

acquire_lock() {
    exec 200>"$LOCKFILE"
    flock -n 200 || {
        echo "Another instance is running" >&2
        exit 1
    }
}

acquire_lock
# Script continues only if lock acquired

Complete Example

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#!/usr/bin/env bash
set -euo pipefail

readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_NAME="$(basename "$0")"

usage() {
    cat << EOF
Usage: $SCRIPT_NAME [options] <input>

Options:
    -o, --output FILE   Output file (default: stdout)
    -v, --verbose       Verbose output
    -h, --help          Show this help

Examples:
    $SCRIPT_NAME data.txt
    $SCRIPT_NAME -o result.txt -v input.txt
EOF
    exit "${1:-0}"
}

log() {
    if [[ "$VERBOSE" == true ]]; then
        echo "[$(date '+%H:%M:%S')] $*" >&2
    fi
}

error() {
    echo "Error: $*" >&2
    exit 1
}

# Defaults
VERBOSE=false
OUTPUT="/dev/stdout"

# Parse arguments
while [[ $# -gt 0 ]]; do
    case $1 in
        -o|--output) OUTPUT="$2"; shift 2 ;;
        -v|--verbose) VERBOSE=true; shift ;;
        -h|--help) usage ;;
        -*) error "Unknown option: $1" ;;
        *) break ;;
    esac
done

[[ $# -ge 1 ]] || usage 1
INPUT="$1"

# Validate
[[ -f "$INPUT" ]] || error "File not found: $INPUT"

# Main logic
log "Processing $INPUT"
process_data < "$INPUT" > "$OUTPUT"
log "Done"

Bash scripts don’t have to be fragile. Apply these patterns and they’ll work reliably for years.