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 PATHset -e — Exit on any errorset -u — Error on undefined variablesset -o pipefail — Pipeline fails if any command failsIFS=$'\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"
|
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#
- Unquoted variables: Always quote
"$var" - cd without checking:
cd "$dir" || exit 1 - Testing empty variables: Use
[[ -n "$var" ]] - Word splitting on filenames: Use
find -print0 with read -d '' - 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.
📬 Get the Newsletter
Weekly insights on DevOps, automation, and CLI mastery. No spam, unsubscribe anytime.