Bash scripts start simple and grow complex. A quick automation becomes critical infrastructure. Here’s how to write scripts that don’t become maintenance nightmares.

Start Every Script Right

1
2
3
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

What these do:

  • #!/usr/bin/env bash — Portable shebang, finds bash in PATH
  • set -e — Exit on any error
  • set -u — Error on undefined variables
  • set -o pipefail — Pipeline fails if any command fails
  • IFS=$'\n\t' — Safer word splitting (no spaces)

The Debugging Version

1
2
#!/usr/bin/env bash
set -euxo pipefail

The -x prints each command before execution. Remove for production.

Variables

Always Quote Variables

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Bad - breaks with spaces or special chars
rm -rf $path

# Good
rm -rf "$path"

# Even in conditionals
if [[ -d "$dir" ]]; then
    echo "Directory exists"
fi

Use Braces for Clarity

1
2
3
4
5
# Ambiguous
echo "$foobar"

# Clear
echo "${foo}bar"

Default Values

1
2
3
4
5
6
7
8
# Default if unset
name="${1:-default_name}"

# Default if unset or empty
name="${1:=default_name}"

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

Readonly Variables

1
2
readonly CONFIG_DIR="/etc/myapp"
readonly LOG_FILE="/var/log/myapp.log"

Functions

Define Before Use

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# Good structure
main() {
    setup
    process_files
    cleanup
}

setup() {
    echo "Setting up..."
}

process_files() {
    echo "Processing..."
}

cleanup() {
    echo "Cleaning up..."
}

# Run main at the end
main "$@"

Local Variables

1
2
3
4
5
6
process_file() {
    local filename="$1"
    local content
    content=$(cat "$filename")
    echo "$content"
}

Return Values

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
is_valid() {
    local value="$1"
    if [[ "$value" =~ ^[0-9]+$ ]]; then
        return 0  # Success/true
    else
        return 1  # Failure/false
    fi
}

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

Conditionals

Use [[ ]] Instead of [ ]

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Better - more features, fewer surprises
if [[ -f "$file" && "$name" == "test" ]]; then
    echo "Match"
fi

# String comparison
[[ "$a" == "$b" ]]   # Equal
[[ "$a" != "$b" ]]   # Not equal
[[ "$a" < "$b" ]]    # Less than (alphabetically)
[[ -z "$a" ]]        # Empty
[[ -n "$a" ]]        # Not empty

# Regex matching
if [[ "$email" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]]; then
    echo "Valid email"
fi

File Tests

1
2
3
4
5
6
7
[[ -e "$path" ]]   # Exists
[[ -f "$path" ]]   # Is file
[[ -d "$path" ]]   # Is directory
[[ -r "$path" ]]   # Is readable
[[ -w "$path" ]]   # Is writable
[[ -x "$path" ]]   # Is executable
[[ -s "$path" ]]   # Is non-empty

Arithmetic

1
2
3
4
5
6
7
# Use (( )) for math
if (( count > 10 )); then
    echo "Too many"
fi

(( count++ ))
(( total = a + b * c ))

Loops

Iterate Over Files Safely

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Bad - breaks on spaces in filenames
for file in $(ls *.txt); do
    echo "$file"
done

# Good - handles spaces correctly
for file in *.txt; do
    [[ -e "$file" ]] || continue  # Skip if no matches
    echo "$file"
done

# Better - handles all edge cases
while IFS= read -r -d '' file; do
    echo "$file"
done < <(find . -name "*.txt" -print0)

Read Lines From File

1
2
3
while IFS= read -r line; do
    echo "Line: $line"
done < "$input_file"

Process Command Output

1
2
3
while IFS= read -r line; do
    echo "Found: $line"
done < <(grep "pattern" file.txt)

Error Handling

Trap for Cleanup

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
cleanup() {
    rm -rf "$temp_dir"
    echo "Cleaned up"
}

trap cleanup EXIT

temp_dir=$(mktemp -d)
# Script work here...
# cleanup runs automatically on exit

Handle Specific Signals

1
2
trap 'echo "Interrupted"; exit 1' INT TERM
trap 'echo "Error on line $LINENO"; exit 1' ERR

Custom Error Function

1
2
3
4
5
6
die() {
    echo "ERROR: $*" >&2
    exit 1
}

[[ -f "$config" ]] || die "Config file not found: $config"

Input Validation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Check argument count
if (( $# < 2 )); then
    echo "Usage: $0 <input> <output>" >&2
    exit 1
fi

# Validate file exists
input="$1"
[[ -f "$input" ]] || die "Input file not found: $input"

# Validate is a number
if ! [[ "$count" =~ ^[0-9]+$ ]]; then
    die "Count must be a number: $count"
fi

Output and Logging

Stderr for Errors

1
2
echo "Starting process..."           # Normal output (stdout)
echo "Warning: file missing" >&2     # Errors/warnings (stderr)

Logging Function

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
readonly LOG_FILE="/var/log/myapp.log"

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

log INFO "Script started"
log ERROR "Something went wrong"

Quiet Mode

1
2
3
4
5
6
7
VERBOSE="${VERBOSE:-false}"

debug() {
    if [[ "$VERBOSE" == "true" ]]; then
        echo "DEBUG: $*" >&2
    fi
}

Working with Commands

Check Command Exists

1
2
3
4
5
6
7
command_exists() {
    command -v "$1" &> /dev/null
}

if ! command_exists jq; then
    die "jq is required but not installed"
fi

Capture Output and Exit Code

1
2
3
4
5
if output=$(some_command 2>&1); then
    echo "Success: $output"
else
    echo "Failed: $output" >&2
fi

Timeout Commands

1
2
3
if ! timeout 30 long_running_command; then
    echo "Command timed out"
fi

Temporary Files

1
2
3
4
5
6
7
# Create temp file
temp_file=$(mktemp)
trap 'rm -f "$temp_file"' EXIT

# Create temp directory
temp_dir=$(mktemp -d)
trap 'rm -rf "$temp_dir"' EXIT

Script Template

 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
59
60
61
62
63
64
65
66
67
68
#!/usr/bin/env bash
set -euo pipefail

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

# Logging
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
die() { echo "ERROR: $*" >&2; exit 1; }

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

Options:
    -h, --help      Show this help
    -v, --verbose   Verbose output
    -o, --output    Output file

Example:
    $SCRIPT_NAME -v input.txt
EOF
}

# Parse arguments
VERBOSE=false
OUTPUT=""

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

# Validate arguments
(( $# >= 1 )) || { usage; exit 1; }
INPUT="$1"
[[ -f "$INPUT" ]] || die "File not found: $INPUT"

# Main logic
main() {
    log "Processing $INPUT..."
    
    # Do work here
    
    log "Done"
}

main "$@"

Common Gotchas

  1. Unquoted variables: Always quote "$var"
  2. cd without checking: cd "$dir" || exit 1
  3. Testing empty variables: Use [[ -n "$var" ]]
  4. Word splitting on filenames: Use find -print0 with read -d ''
  5. Forgetting exit codes: Check $? or use set -e

Bash isn’t pretty, but it’s everywhere. Learn these patterns once, and your scripts will be safer, more readable, and easier to maintain. Your future self (debugging at 2 AM) will thank you.