Bash scripts glue systems together. Here’s how to write them without the usual fragility.
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.
📬 Get the Newsletter
Weekly insights on DevOps, automation, and CLI mastery. No spam, unsubscribe anytime.