Advanced Scripting
Summary: in this tutorial, you will learn master strict mode, error handling, debugging techniques, and argument parsing for robust bash scripts.
Advanced Scripting
This tutorial transforms your scripting from "works on my machine" to production-ready code. These techniques separate hobbyist scripts from professional automation tools that run reliably in critical environments.
Why advanced techniques matter:
- Reliability: Catch bugs before they cause damage
- Maintainability: Code others (and future you) can understand
- Debuggability: Find and fix problems quickly
- Professionalism: Scripts that inspire confidence
- Safety: Prevent data loss and system damage
Master these patterns and your scripts will be robust, predictable, and trustworthy.
Strict Mode — The Foundation of Safe Scripts
The single most important thing you can add to any script:
#!/usr/bin/env bash
set -euo pipefail
These three flags transform Bash from permissive (continues despite errors) to strict (stops at the first sign of trouble).
Why this matters: Without strict mode, Bash silently ignores errors and continues executing—potentially causing cascading failures, data corruption, or security issues. Strict mode makes scripts fail fast and fail loudly.
set -e — Exit on Error
Problem without -e:
#!/bin/bash
# Dangerous script without set -e
rm /important/file.txt # Fails silently
echo "File removed!" # Still prints!
rm -rf /important/directory/ # Still runs! Data loss!
echo "Cleanup complete" # Success message despite failure
If /important/file.txt doesn't exist, rm fails but the script continues. The success messages print even though nothing was actually removed, and worse—the cleanup command runs despite the earlier failure.
Solution with -e:
#!/bin/bash
set -e # Exit immediately if any command fails
rm /important/file.txt # Fails here
echo "File removed!" # NEVER EXECUTES
rm -rf /important/directory/ # NEVER EXECUTES
echo "Cleanup complete" # NEVER EXECUTES
The script stops at the first error. No misleading success messages, no cascading damage.
When -e doesn't apply:
set -e
# These commands DON'T trigger exit:
if command_that_fails; then # Checked in if condition
echo "won't run"
fi
command_that_fails || true # Explicitly handled with ||
command_that_fails && echo "won't run" # Part of && chain
# Commands in a pipeline (without pipefail):
failing_command | succeeding_command # Doesn't exit (see pipefail below)
set -u — Error on Undefined Variables
The horror story without -u:
#!/bin/bash
# EXTREMELY DANGEROUS without set -u
DIRECTORY="/important/data"
# ... 100 lines later ...
rm -rf $DIRETORY/* # Typo! DIRETORY is empty
# This expands to: rm -rf /*
# YOU JUST DELETED YOUR ENTIRE FILESYSTEM
This is not hypothetical—this exact bug has destroyed production systems. The typo $DIRETORY (missing C) expands to an empty string, turning rm -rf $DIRETORY/* into rm -rf /*.
Safety with -u:
#!/bin/bash
set -u # Error on undefined variables
DIRECTORY="/important/data"
rm -rf $DIRETORY/* # ERROR: DIRETORY: unbound variable
# Script stops, filesystem saved!
The script immediately exits with an error message. No silent failure, no data loss.
Handling optional variables with -u:
set -u
# Method 1: Default values
output_dir="${OUTPUT_DIR:-/tmp}" # Use /tmp if OUTPUT_DIR unset
# Method 2: Check if set
if [[ -n "${DEBUG:-}" ]]; then
echo "Debug mode enabled"
fi
# Method 3: Require variable
: "${REQUIRED_VAR:?REQUIRED_VAR must be set}"
🚫 set -u has prevented countless disasters
Real-world incident: A deployment script had rm -rf $BUILD_DIR/ where $BUILD_DIR was accidentally empty due to a failed environment variable export. Without set -u, it deleted the entire server's filesystem. With set -u, it would have failed immediately with an error.
Always use set -u. It's a 7-character insurance policy against catastrophic typos.
set -o pipefail — Detect Pipeline Failures
Problem without pipefail:
#!/bin/bash
set -e # Exit on error... or so we think
# Check if server is healthy
curl -f https://api.example.com/health | jq .status | grep -q "ok"
echo "Server is healthy!"
# If curl fails (server down), the pipeline still "succeeds"
# because grep returns 0 (found nothing, which is exit 0 for grep -q)
# Result: false positive health check!
Pipeline exit status is the exit status of the last command only:
false | true # Exit code: 0 (success!) — true succeeded
true | false # Exit code: 1 (failure) — false failed
Solution with pipefail:
#!/bin/bash
set -eo pipefail # Exit on error + pipeline failures
curl -f https://api.example.com/health | jq .status | grep -q "ok"
echo "Server is healthy!"
# Now if curl fails, the entire pipeline fails
# Script exits immediately, no false positive
With pipefail, a pipeline fails if any command in it fails:
set -o pipefail
false | true # Exit code: 1 (failure!) — false failed
true | false # Exit code: 1 (failure) — false failed
true | true # Exit code: 0 (success) — all succeeded
Practical example:
set -eo pipefail
# Without pipefail, if database dump fails but gzip succeeds,
# the script continues and you think you have a backup (you don't!)
pg_dump mydb | gzip > backup.sql.gz
# With pipefail, if pg_dump fails, the whole pipeline fails
# and backup.sql.gz is not created (or incomplete)
set -o pipefail — IFS (Internal Field Separator)
By default, IFS includes space, tab, and newline, causing unexpected word splitting:
# Default IFS
file="my document.txt"
rm $file # Tries to remove "my" and "document.txt" separately!
# Safe IFS (only newline and tab)
IFS=$'\n\t'
file="my document.txt"
rm $file # Still works (but you should quote: rm "$file")
Setting IFS=$'\n\t' prevents space-based word splitting while keeping newlines for read loops.
The Complete Strict Mode Header
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
Copy this to every script. These 3 lines catch the vast majority of common Bash bugs automatically.
Error Handling Beyond set -e
Meaningful Exit Codes
Exit codes communicate what went wrong:
#!/usr/bin/env bash
set -euo pipefail
# Define exit codes as constants (at the top of your script)
readonly E_SUCCESS=0
readonly E_GENERAL=1
readonly E_MISSING_ARGS=2
readonly E_FILE_NOT_FOUND=3
readonly E_PERMISSION_DENIED=4
readonly E_NETWORK_ERROR=5
readonly E_CONFIG_ERROR=6
# Use them throughout your script
check_file() {
local file="$1"
if [[ ! -e "$file" ]]; then
echo "Error: File not found: $file" >&2
exit $E_FILE_NOT_FOUND
fi
if [[ ! -r "$file" ]]; then
echo "Error: Permission denied: $file" >&2
exit $E_PERMISSION_DENIED
fi
}
# Calling script can check the exit code:
# ./script.sh
# echo $?
# 3 (E_FILE_NOT_FOUND)
Why this matters: Automated systems can respond differently based on exit code:
E_MISSING_ARGS(2) → Retry with correct argumentsE_NETWORK_ERROR(5) → Retry after delayE_CONFIG_ERROR(6) → Alert administrator, don't retry
Logging Functions
Structured logging makes debugging and auditing easier:
#!/usr/bin/env bash
set -euo pipefail
# Logging functions with timestamp and severity
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [INFO] $*"
}
warn() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [WARN] $*" >&2
}
error() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" >&2
}
die() {
error "$*"
exit 1
}
# Usage:
log "Starting backup process"
# [2026-02-11 14:30:45] [INFO] Starting backup process
warn "Disk space is running low"
# [2026-02-11 14:30:46] [WARN] Disk space is running low
error "Failed to connect to database"
# [2026-02-11 14:30:47] [ERROR] Failed to connect to database
die "Cannot continue without database"
# [2026-02-11 14:30:48] [ERROR] Cannot continue without database
# (script exits)
Why write to stderr (>&2)?
- Errors and warnings go to stderr
- Normal output goes to stdout
- This lets users redirect them separately:
./script.sh > output.txt 2> errors.txt
# output.txt gets log() messages
# errors.txt gets warn(), error(), die() messages
trap ERR — Catch Errors with Context
trap ERR runs a function whenever a command fails:
#!/usr/bin/env bash
set -euo pipefail
on_error() {
local exit_code=$?
local line_no=$1
local bash_lineno=$2
local command="$3"
echo "" >&2
echo "════════════════════════════════════" >&2
echo "ERROR: Command failed" >&2
echo "════════════════════════════════════" >&2
echo "Exit code : $exit_code" >&2
echo "Command : $command" >&2
echo "Line : $line_no" >&2
echo "════════════════════════════════════" >&2
}
trap 'on_error $LINENO ${BASH_LINENO[0]} "$BASH_COMMAND"' ERR
# Now any error shows context:
cp /nonexistent/file /tmp/
# Output:
# ════════════════════════════════════
# ERROR: Command failed
# ════════════════════════════════════
# Exit code : 1
# Command : cp /nonexistent/file /tmp/
# Line : 23
# ════════════════════════════════════
Enhanced error handler with stack trace:
#!/usr/bin/env bash
set -euo pipefail
on_error() {
local exit_code=$?
echo "" >&2
echo "════════════════════════════════════" >&2
echo "ERROR: Script failed!" >&2
echo "════════════════════════════════════" >&2
echo "Exit code : $exit_code" >&2
echo "Command : $BASH_COMMAND" >&2
echo "Line : ${BASH_LINENO[0]}" >&2
echo "" >&2
echo "Stack trace:" >&2
local i=0
while caller $i 2>/dev/null; do
((i++))
done
echo "════════════════════════════════════" >&2
}
trap on_error ERR
Retry Logic for Flaky Operations
Network calls, remote APIs, and disk I/O can fail temporarily. Retry logic makes scripts resilient:
retry() {
local max_attempts="${1:-3}"
local delay="${2:-5}"
local attempt=1
shift 2
local command="$*"
while [[ $attempt -le $max_attempts ]]; do
echo "Attempt $attempt/$max_attempts: $command" >&2
if eval "$command"; then
return 0
fi
if [[ $attempt -lt $max_attempts ]]; then
echo "Failed. Retrying in ${delay}s..." >&2
sleep "$delay"
fi
((attempt++))
done
echo "ERROR: All $max_attempts attempts failed for: $command" >&2
return 1
}
# Usage:
retry 5 10 curl -f https://api.example.com/data
# Tries 5 times, waits 10 seconds between attempts
retry 3 2 ssh user@server "uptime"
# Tries 3 times, waits 2 seconds between attempts
# With custom handling:
if ! retry 5 3 wget https://example.com/file.tar.gz; then
die "Failed to download file after 5 attempts"
fi
Exponential backoff (more sophisticated):
retry_exponential() {
local max_attempts="${1:-5}"
local base_delay="${2:-1}"
local attempt=1
shift 2
local command="$*"
while [[ $attempt -le $max_attempts ]]; do
echo "Attempt $attempt/$max_attempts: $command" >&2
if eval "$command"; then
return 0
fi
if [[ $attempt -lt $max_attempts ]]; then
local delay=$((base_delay * (2 ** (attempt - 1))))
echo "Failed. Retrying in ${delay}s..." >&2
sleep "$delay"
fi
((attempt++))
done
echo "ERROR: All $max_attempts attempts failed" >&2
return 1
}
# Attempts with delays: 1s, 2s, 4s, 8s, 16s
retry_exponential 5 1 curl -f https://flaky-api.com/endpoint
Debugging Techniques
set -x — Trace Execution
set -x prints each command before executing it:
#!/bin/bash
set -x
name="Alice"
echo "Hello, $name"
count=5
echo "Count: $count"
# Output:
# + name=Alice
# + echo 'Hello, Alice'
# Hello, Alice
# + count=5
# + echo 'Count: 5'
# Count: 5
Every line starting with + shows the command after variable expansion.
Selective debugging:
#!/bin/bash
echo "Normal execution"
set -x
problematic_function # Only this is traced
another_function
set +x
echo "Back to normal"
Run entire script in debug mode:
# Trace all commands:
bash -x script.sh
# Trace + show lines before expansion:
bash -xv script.sh
Custom Debug Prompt (PS4)
Customize what set -x shows:
# Default PS4 is just '+ '
# Enhanced PS4 shows file:line:function:
export PS4='+${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]:+${FUNCNAME[0]}(): }'
set -x
# Now traces look like:
# +myscript.sh:42:main(): command arg1 arg2
This makes traces much more useful in complex scripts with multiple files and functions.
Conditional Debug Output
#!/usr/bin/env bash
set -euo pipefail
# Enable debug with: DEBUG=true ./script.sh
DEBUG=${DEBUG:-false}
debug() {
if [[ "$DEBUG" == "true" ]]; then
echo "[DEBUG] $*" >&2
fi
}
# Usage throughout your script:
debug "Entering function process_file"
debug "Variable state: file=$file, count=$count"
debug "About to execute: curl $url"
# Only prints when DEBUG=true
Multiple debug levels:
LOG_LEVEL="${LOG_LEVEL:-INFO}" # DEBUG, INFO, WARN, ERROR
declare -A LOG_LEVELS=([DEBUG]=0 [INFO]=1 [WARN]=2 [ERROR]=3)
log_debug() { [[ ${LOG_LEVELS[$LOG_LEVEL]} -le 0 ]] && echo "[DEBUG] $*" >&2; }
log_info() { [[ ${LOG_LEVELS[$LOG_LEVEL]} -le 1 ]] && echo "[INFO] $*"; }
log_warn() { [[ ${LOG_LEVELS[$LOG_LEVEL]} -le 2 ]] && echo "[WARN] $*" >&2; }
log_error() { [[ ${LOG_LEVELS[$LOG_LEVEL]} -le 3 ]] && echo "[ERROR] $*" >&2; }
# Usage:
log_debug "Detailed debugging info"
log_info "Normal operation"
log_warn "Something unusual"
log_error "Something failed"
# Run with: LOG_LEVEL=DEBUG ./script.sh
Debugging Tips and Tricks
# Check if command exists before using it
if ! command -v jq >/dev/null 2>&1; then
die "jq is required but not installed. Install with: apt install jq"
fi
# Validate required variables are set
: "${DATABASE_URL:?ERROR: DATABASE_URL must be set}"
: "${API_KEY:?ERROR: API_KEY must be set}"
# Script exits with error message if variables are unset
# Print variable state at key decision points
echo "DEBUG: file=$file, exists=$(test -f "$file" && echo yes || echo no)" >&2
# Use set -x for a specific section
{ set -x; complex_operation; } 2>/tmp/debug.log
# Trace goes to file, normal output to terminal
# Syntax check without execution
bash -n script.sh
# Finds syntax errors without running the script
Argument Parsing
getopts — Standard Short Options
getopts parses short options (-v, -o file, etc.):
#!/usr/bin/env bash
set -euo pipefail
# Defaults
verbose=false
output_file=""
count=1
usage() {
cat << EOF
Usage: $(basename "$0") [OPTIONS] FILE...
Process files with various options.
OPTIONS:
-v Enable verbose output
-o FILE Write output to FILE (default: stdout)
-c COUNT Number of iterations (default: 1)
-h Show this help message
EXAMPLES:
$(basename "$0") -v input.txt
$(basename "$0") -o output.txt -c 5 data.csv
$(basename "$0") -v -c 10 *.txt
EOF
}
# Parse options
while getopts ":vo:c:h" opt; do
case $opt in
v)
verbose=true
;;
o)
output_file="$OPTARG"
;;
c)
count="$OPTARG"
# Validate it's a number
if ! [[ "$count" =~ ^[0-9]+$ ]]; then
echo "Error: -c requires a positive integer" >&2
exit 1
fi
;;
h)
usage
exit 0
;;
:)
echo "Error: -$OPTARG requires an argument" >&2
usage
exit 1
;;
\?)
echo "Error: Unknown option -$OPTARG" >&2
usage
exit 1
;;
esac
done
# Shift past the options
shift $((OPTIND - 1))
# Remaining arguments are positional parameters
if [[ $# -eq 0 ]]; then
echo "Error: No input files specified" >&2
usage
exit 1
fi
# Use the parsed options
$verbose && echo "Verbose mode enabled"
[[ -n "$output_file" ]] && echo "Output file: $output_file" || echo "Output: stdout"
echo "Count: $count"
echo "Input files: $*"
getopts format string:
"vo:c:h"— Available optionsv— Takes no argumento:— Requires an argument (the:means argument required)c:— Requires an argumenth— Takes no argument
:at the start (":vo:c:h") — Silent error reporting (we handle errors)
Long Options — Manual Parsing
getopts doesn't support long options (--verbose, --output=file). Parse them manually:
#!/usr/bin/env bash
set -euo pipefail
verbose=false
output=""
config=""
dry_run=false
usage() {
cat << EOF
Usage: $(basename "$0") [OPTIONS] ARGUMENTS
OPTIONS:
-v, --verbose Enable verbose output
-o, --output FILE Output file
-c, --config FILE Configuration file
-n, --dry-run Show what would be done without doing it
-h, --help Show this help
EXAMPLES:
$(basename "$0") --verbose --output=result.txt input.txt
$(basename "$0") -v -o result.txt input.txt
$(basename "$0") --dry-run --config=app.conf
EOF
}
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
-v|--verbose)
verbose=true
shift
;;
-o|--output)
output="$2"
shift 2
;;
--output=*)
output="${1#*=}"
shift
;;
-c|--config)
config="$2"
shift 2
;;
--config=*)
config="${1#*=}"
shift
;;
-n|--dry-run)
dry_run=true
shift
;;
-h|--help)
usage
exit 0
;;
--)
shift
break
;;
-*)
echo "Error: Unknown option: $1" >&2
usage
exit 1
;;
*)
break
;;
esac
done
# Use parsed options
$verbose && echo "Verbose: enabled"
[[ -n "$output" ]] && echo "Output: $output"
[[ -n "$config" ]] && echo "Config: $config"
$dry_run && echo "Dry-run: enabled"
echo "Remaining arguments: $*"
Written by the ShellRAG Team
The ShellRAG editorial team writes practical, beginner-friendly Bash Shell tutorials with tested code examples and real-world use cases. Every article is technically reviewed for accuracy and updated regularly.
Learn more about us →