20 KiB
Common Bash Patterns and Anti-Patterns
Collection of proven patterns and common mistakes in bash scripting with explanations and solutions.
Table of Contents
- Variable Handling
- Command Execution
- File Operations
- String Processing
- Arrays and Loops
- Conditionals and Tests
- Functions
- Error Handling
- Process Management
- Security Patterns
🚨 CRITICAL GUIDELINES
Windows File Path Requirements
MANDATORY: Always Use Backslashes on Windows for File Paths
When using Edit or Write tools on Windows, you MUST use backslashes (\) in file paths, NOT forward slashes (/).
Examples:
- ❌ WRONG:
D:/repos/project/file.tsx - ✅ CORRECT:
D:\repos\project\file.tsx
This applies to:
- Edit tool file_path parameter
- Write tool file_path parameter
- All file operations on Windows systems
Documentation Guidelines
NEVER create new documentation files unless explicitly requested by the user.
- Priority: Update existing README.md files rather than creating new documentation
- Repository cleanliness: Keep repository root clean - only README.md unless user requests otherwise
- Style: Documentation should be concise, direct, and professional - avoid AI-generated tone
- User preference: Only create additional .md files when user specifically asks for documentation
Variable Handling
Pattern: Safe Variable Expansion
# ✓ GOOD: Always quote variables
echo "$variable"
cp "$source" "$destination"
rm -rf "$directory"
# ✗ BAD: Unquoted variables
echo $variable # Word splitting and globbing
cp $source $destination # Breaks with spaces
rm -rf $directory # VERY DANGEROUS unquoted
Why: Unquoted variables undergo word splitting and pathname expansion, leading to unexpected behavior.
Pattern: Default Values
# ✓ GOOD: Use parameter expansion for defaults
timeout="${TIMEOUT:-30}"
config="${CONFIG_FILE:-$HOME/.config/app.conf}"
# ✗ BAD: Manual check
if [ -z "$TIMEOUT" ]; then
timeout=30
else
timeout="$TIMEOUT"
fi
Why: Parameter expansion is concise, readable, and handles edge cases correctly.
Anti-Pattern: Confusing Assignment and Comparison
# ✗ VERY BAD: Using = instead of ==
if [ "$var" = "value" ]; then # Assignment in POSIX test!
echo "Match"
fi
# ✓ GOOD: Use == or = correctly
if [[ "$var" == "value" ]]; then # Comparison in bash
echo "Match"
fi
# ✓ GOOD: POSIX-compliant
if [ "$var" = "value" ]; then # Single = is correct in [ ]
echo "Match"
fi
Why: In [[ ]], both = and == work. In [ ], only = is POSIX-compliant.
Anti-Pattern: Unset Variable Access
# ✗ BAD: Accessing undefined variables
echo "Value: $undefined_variable" # Silent error, prints "Value: "
# ✓ GOOD: Use set -u
set -u
echo "Value: $undefined_variable" # Error: undefined_variable: unbound variable
# ✓ GOOD: Provide default
echo "Value: ${undefined_variable:-default}"
Why: set -u catches typos and logic errors early.
Command Execution
Pattern: Check Command Existence
# ✓ GOOD: Use command -v
if command -v jq &> /dev/null; then
echo "jq is installed"
else
echo "jq is not installed" >&2
exit 1
fi
# ✗ BAD: Using which
if which jq; then # Deprecated, not POSIX
echo "jq is installed"
fi
# ✗ BAD: Using type
if type jq; then # Verbose output
echo "jq is installed"
fi
Why: command -v is POSIX-compliant, silent, and reliable.
Pattern: Command Substitution
# ✓ GOOD: Modern syntax with $()
result=$(command arg1 arg2)
timestamp=$(date +%s)
# ✗ BAD: Backticks (hard to nest)
result=`command arg1 arg2`
timestamp=`date +%s`
# ✓ GOOD: Nested substitution
result=$(echo "Outer: $(echo "Inner")")
# ✗ BAD: Nested backticks (requires escaping)
result=`echo "Outer: \`echo \"Inner\"\`"`
Why: $() is easier to read, nest, and maintain.
Anti-Pattern: Useless Use of Cat
# ✗ BAD: UUOC (Useless Use of Cat)
cat file.txt | grep "pattern"
# ✓ GOOD: Direct input
grep "pattern" file.txt
# ✗ BAD: Multiple cats
cat file1 | grep pattern | cat | sort | cat
# ✓ GOOD: Direct pipeline
grep pattern file1 | sort
Why: Unnecessary cat wastes resources and adds extra processes.
Anti-Pattern: Using ls in Scripts
# ✗ BAD: Parsing ls output
for file in $(ls *.txt); do
echo "$file"
done
# ✓ GOOD: Use globbing
for file in *.txt; do
[[ -f "$file" ]] || continue # Skip if no matches
echo "$file"
done
# ✗ BAD: Counting files with ls
count=$(ls -1 | wc -l)
# ✓ GOOD: Use array
files=(*)
count=${#files[@]}
Why: ls output is meant for humans, not scripts. Parsing it breaks with spaces, newlines, etc.
File Operations
Pattern: Safe File Reading
# ✓ GOOD: Preserve leading/trailing whitespace and backslashes
while IFS= read -r line; do
echo "Line: $line"
done < file.txt
# ✗ BAD: Without IFS= (strips leading/trailing whitespace)
while read -r line; do
echo "Line: $line"
done < file.txt
# ✗ BAD: Without -r (interprets backslashes)
while IFS= read line; do
echo "Line: $line"
done < file.txt
Why: IFS= prevents trimming, -r prevents backslash interpretation.
Pattern: Null-Delimited Files
# ✓ GOOD: For filenames with special characters
find . -name "*.txt" -print0 | while IFS= read -r -d '' file; do
echo "Processing: $file"
done
# Or with mapfile (bash 4+)
mapfile -d '' -t files < <(find . -name "*.txt" -print0)
for file in "${files[@]}"; do
echo "Processing: $file"
done
# ✗ BAD: Newline-delimited (breaks with newlines in filenames)
find . -name "*.txt" | while IFS= read -r file; do
echo "Processing: $file"
done
Why: Filenames can contain any character except null and slash.
Anti-Pattern: Testing File Existence Incorrectly
# ✗ BAD: Using ls to test existence
if ls file.txt &> /dev/null; then
echo "File exists"
fi
# ✓ GOOD: Use test operators
if [[ -f file.txt ]]; then
echo "File exists"
fi
# ✓ GOOD: Different tests
[[ -e path ]] # Exists (file or directory)
[[ -f file ]] # Regular file
[[ -d dir ]] # Directory
[[ -L link ]] # Symbolic link
[[ -r file ]] # Readable
[[ -w file ]] # Writable
[[ -x file ]] # Executable
Why: Test operators are the correct, efficient way to check file properties.
Pattern: Temporary Files
# ✓ GOOD: Secure temporary file
temp_file=$(mktemp)
trap 'rm -f "$temp_file"' EXIT
# Use temp file
echo "data" > "$temp_file"
# ✗ BAD: Insecure temp file
temp_file="/tmp/myapp.$$"
echo "data" > "$temp_file"
# No cleanup!
# ✓ GOOD: Temporary directory
temp_dir=$(mktemp -d)
trap 'rm -rf "$temp_dir"' EXIT
Why: mktemp creates secure, unique files and prevents race conditions.
String Processing
Pattern: String Manipulation with Parameter Expansion
# ✓ GOOD: Use bash parameter expansion
filename="document.tar.gz"
basename="${filename%%.*}" # document
extension="${filename##*.}" # gz
name="${filename%.gz}" # document.tar
# ✗ BAD: Using external commands
basename=$(echo "$filename" | sed 's/\..*$//')
extension=$(echo "$filename" | awk -F. '{print $NF}')
Why: Parameter expansion is faster and doesn't spawn processes.
Pattern: String Comparison
# ✓ GOOD: Use [[ ]] for strings
if [[ "$string1" == "$string2" ]]; then
echo "Equal"
fi
# ✓ GOOD: Pattern matching
if [[ "$filename" == *.txt ]]; then
echo "Text file"
fi
# ✓ GOOD: Regex matching
if [[ "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
echo "Valid email"
fi
# ✗ BAD: Using grep for simple string check
if echo "$string" | grep -q "substring"; then
echo "Found"
fi
# ✓ GOOD: Use substring matching
if [[ "$string" == *"substring"* ]]; then
echo "Found"
fi
Why: [[ ]] is bash-native, faster, and more readable.
Anti-Pattern: Word Splitting Issues
# ✗ BAD: Unquoted expansion with spaces
var="file1.txt file2.txt"
for file in $var; do # Splits on spaces!
echo "$file" # file1.txt, then file2.txt
done
# ✓ GOOD: Use array
files=("file1.txt" "file2.txt")
for file in "${files[@]}"; do
echo "$file"
done
# ✗ BAD: Word splitting in command arguments
file="my file.txt"
rm $file # Tries to remove "my" and "file.txt"!
# ✓ GOOD: Quote variables
rm "$file"
Why: Word splitting on spaces is a major source of bugs.
Arrays and Loops
Pattern: Array Declaration and Use
# ✓ GOOD: Array declaration
files=("file1.txt" "file2.txt" "file 3.txt")
# ✓ GOOD: Array expansion (each element quoted)
for file in "${files[@]}"; do
echo "$file"
done
# ✗ BAD: Unquoted array expansion
for file in ${files[@]}; do # Word splitting!
echo "$file"
done
# ✓ GOOD: Add to array
files+=("file4.txt")
# ✓ GOOD: Array length
echo "Count: ${#files[@]}"
# ✓ GOOD: Array indices
for i in "${!files[@]}"; do
echo "File $i: ${files[$i]}"
done
Why: Proper array handling prevents word splitting and globbing issues.
Pattern: Reading Command Output into Array
# ✓ GOOD: mapfile/readarray (bash 4+)
mapfile -t lines < file.txt
# ✓ GOOD: With command substitution
mapfile -t files < <(find . -name "*.txt")
# ✗ BAD: Word splitting
files=($(find . -name "*.txt")) # Breaks with spaces in filenames!
# ✓ GOOD: Alternative (POSIX-compatible)
while IFS= read -r file; do
files+=("$file")
done < <(find . -name "*.txt")
Why: mapfile is efficient and handles special characters correctly.
Anti-Pattern: C-Style For Loops for Arrays
# ✗ BAD: C-style loop for arrays
for ((i=0; i<${#files[@]}; i++)); do
echo "${files[$i]}"
done
# ✓ GOOD: For-in loop
for file in "${files[@]}"; do
echo "$file"
done
# ✓ ACCEPTABLE: When you need the index
for i in "${!files[@]}"; do
echo "Index $i: ${files[$i]}"
done
Why: For-in loops are simpler and less error-prone.
Pattern: Loop over Range
# ✓ GOOD: Brace expansion
for i in {1..10}; do
echo "$i"
done
# ✓ GOOD: With variables (bash 4+)
start=1
end=10
for i in $(seq $start $end); do
echo "$i"
done
# ✓ GOOD: C-style (arithmetic)
for ((i=1; i<=10; i++)); do
echo "$i"
done
# ✗ BAD: Using seq in a loop unnecessarily
for i in $(seq 1 1000000); do # Creates huge string in memory!
echo "$i"
done
# ✓ GOOD: Use C-style for large ranges
for ((i=1; i<=1000000; i++)); do
echo "$i"
done
Why: Choose the right loop construct based on the use case.
Conditionals and Tests
Pattern: File Tests
# ✓ GOOD: Use appropriate test
if [[ -f "$file" ]]; then # Regular file
if [[ -d "$dir" ]]; then # Directory
if [[ -e "$path" ]]; then # Exists (any type)
if [[ -L "$link" ]]; then # Symbolic link
if [[ -r "$file" ]]; then # Readable
if [[ -w "$file" ]]; then # Writable
if [[ -x "$file" ]]; then # Executable
if [[ -s "$file" ]]; then # Non-empty file
# ✗ BAD: Incorrect test
if [[ -e "$file" ]]; then # Exists, but could be directory!
cat "$file" # Fails if directory
fi
# ✓ GOOD: Specific test
if [[ -f "$file" ]]; then
cat "$file"
fi
Why: Use the most specific test for your use case.
Pattern: Numeric Comparison
# ✓ GOOD: Arithmetic context
if (( num > 10 )); then
echo "Greater than 10"
fi
# ✓ GOOD: Test operator
if [[ $num -gt 10 ]]; then
echo "Greater than 10"
fi
# ✗ BAD: String comparison for numbers
if [[ "$num" > "10" ]]; then # Lexicographic comparison!
echo "Greater than 10" # "9" > "10" is true!
fi
Why: Use numeric comparison operators for numbers.
Anti-Pattern: Testing Boolean Strings
# ✗ BAD: Comparing to string "true"
if [[ "$flag" == "true" ]]; then
do_something
fi
# ✓ GOOD: Use boolean variable directly
flag=false # or true
if $flag; then
do_something
fi
# ✓ BETTER: Use integers for flags
flag=0 # false
flag=1 # true
if (( flag )); then
do_something
fi
# ✓ GOOD: For command success/failure
if command; then
echo "Success"
fi
Why: Boolean strings are error-prone; use actual booleans or return codes.
Pattern: Multiple Conditions
# ✓ GOOD: Logical operators
if [[ condition1 && condition2 ]]; then
echo "Both true"
fi
if [[ condition1 || condition2 ]]; then
echo "At least one true"
fi
if [[ ! condition ]]; then
echo "False"
fi
# ✗ BAD: Separate tests
if [ condition1 -a condition2 ]; then # Deprecated
echo "Both true"
fi
# ✗ BAD: Nested ifs for AND
if [[ condition1 ]]; then
if [[ condition2 ]]; then
echo "Both true"
fi
fi
Why: && and || in [[ ]] are clearer and recommended.
Functions
Pattern: Function Return Values
# ✓ GOOD: Return status, output to stdout
get_value() {
local value="result"
if [[ -n "$value" ]]; then
echo "$value"
return 0
else
return 1
fi
}
# Usage
if result=$(get_value); then
echo "Got: $result"
else
echo "Failed"
fi
# ✗ BAD: Using return for data
get_value() {
return 42 # Can only return 0-255!
}
result=$? # Gets 42, but limited range
Why: return is for exit status (0-255), not data. Output to stdout for data.
Pattern: Local Variables in Functions
# ✓ GOOD: Declare local variables
my_function() {
local arg="$1"
local result=""
result=$(process "$arg")
echo "$result"
}
# ✗ BAD: Global variables
my_function() {
arg="$1" # Pollutes global namespace!
result="" # Global variable!
result=$(process "$arg")
echo "$result"
}
Why: Local variables prevent unexpected side effects.
Anti-Pattern: Capturing Local Command Failure
# ✗ BAD: Local declaration masks command failure
my_function() {
local result=$(command_that_fails) # $? is from 'local', not 'command'!
echo "$result"
}
# ✓ GOOD: Separate declaration and assignment
my_function() {
local result
result=$(command_that_fails) || return 1
echo "$result"
}
# ✓ GOOD: Check command separately
my_function() {
local result
if ! result=$(command_that_fails); then
return 1
fi
echo "$result"
}
Why: Combining local and command substitution hides command failure.
Error Handling
Pattern: Check Command Success
# ✓ GOOD: Direct check
if ! command; then
echo "Command failed" >&2
exit 1
fi
# ✓ GOOD: With logical operator
command || {
echo "Command failed" >&2
exit 1
}
# ✓ GOOD: Capture output and check
if ! output=$(command 2>&1); then
echo "Command failed: $output" >&2
exit 1
fi
# ✗ BAD: Not checking status
command # What if it fails?
next_command
Why: Always check if commands succeed unless failure is acceptable.
Pattern: Error Messages to stderr
# ✓ GOOD: Errors to stderr
echo "Error: Invalid argument" >&2
# ✗ BAD: Errors to stdout
echo "Error: Invalid argument"
# ✓ GOOD: Error function
error() {
echo "ERROR: $*" >&2
}
error "Something went wrong"
Why: stderr is for errors, stdout is for data output.
Pattern: Cleanup on Exit
# ✓ GOOD: Trap for cleanup
temp_file=$(mktemp)
cleanup() {
rm -f "$temp_file"
}
trap cleanup EXIT
# Do work with temp_file
# ✗ BAD: Manual cleanup (might not run)
temp_file=$(mktemp)
# Do work
rm -f "$temp_file" # Doesn't run if script exits early!
Why: Trap ensures cleanup runs on exit, even on errors.
Anti-Pattern: Silencing Errors
# ✗ BAD: Silencing errors
command 2>/dev/null # What if it fails?
next_command
# ✓ GOOD: Check status even if silencing output
if ! command 2>/dev/null; then
echo "Command failed" >&2
exit 1
fi
# ✓ ACCEPTABLE: When failure is expected and acceptable
if command 2>/dev/null; then
echo "Command succeeded"
else
echo "Command failed (expected)"
fi
Why: Silencing errors without checking status leads to silent failures.
Process Management
Pattern: Background Jobs
# ✓ GOOD: Track background jobs
long_running_task &
pid=$!
# Wait for completion
if wait "$pid"; then
echo "Task completed successfully"
else
echo "Task failed" >&2
fi
# ✓ GOOD: Multiple background jobs
job1 &
pid1=$!
job2 &
pid2=$!
wait "$pid1" "$pid2"
Why: Proper job management prevents zombie processes.
Pattern: Timeout for Commands
# ✓ GOOD: Use timeout command (if available)
if timeout 30 long_running_command; then
echo "Completed within timeout"
else
echo "Timed out or failed" >&2
fi
# ✓ GOOD: Manual timeout implementation
timeout_command() {
local timeout=$1
shift
"$@" &
local pid=$!
( sleep "$timeout"; kill "$pid" 2>/dev/null ) &
local killer=$!
if wait "$pid" 2>/dev/null; then
kill "$killer" 2>/dev/null
wait "$killer" 2>/dev/null
return 0
else
return 1
fi
}
timeout_command 30 long_running_command
Why: Prevents scripts from hanging indefinitely.
Anti-Pattern: Killing Processes Unsafely
# ✗ BAD: kill -9 immediately
kill -9 "$pid"
# ✓ GOOD: Graceful shutdown first
kill -TERM "$pid"
sleep 2
if kill -0 "$pid" 2>/dev/null; then
echo "Process still running, forcing..." >&2
kill -KILL "$pid"
fi
# ✓ GOOD: With timeout
graceful_kill() {
local pid=$1
local timeout=${2:-10}
kill -TERM "$pid" 2>/dev/null || return 0
for ((i=0; i<timeout; i++)); do
if ! kill -0 "$pid" 2>/dev/null; then
return 0
fi
sleep 1
done
echo "Forcing kill of $pid" >&2
kill -KILL "$pid" 2>/dev/null
}
Why: SIGTERM allows graceful shutdown; SIGKILL should be last resort.
Security Patterns
Pattern: Input Validation
# ✓ GOOD: Whitelist validation
validate_action() {
local action=$1
case "$action" in
start|stop|restart|status)
return 0
;;
*)
echo "Error: Invalid action: $action" >&2
return 1
;;
esac
}
# ✗ BAD: No validation
action="$1"
systemctl "$action" myservice # User can pass arbitrary commands!
# ✓ GOOD: Validate first
if validate_action "$1"; then
systemctl "$1" myservice
else
exit 1
fi
Why: Whitelist validation prevents command injection.
Pattern: Avoid eval
# ✗ BAD: eval with user input
eval "$user_command" # DANGEROUS!
# ✓ GOOD: Use arrays
command_args=("$arg1" "$arg2" "$arg3")
command "${command_args[@]}"
# ✗ BAD: Dynamic variable names
eval "var_$name=value"
# ✓ GOOD: Associative arrays (bash 4+)
declare -A vars
vars[$name]="value"
Why: eval with user input is a security vulnerability.
Pattern: Safe PATH
# ✓ GOOD: Set explicit PATH
export PATH="/usr/local/bin:/usr/bin:/bin"
# ✓ GOOD: Use absolute paths for critical commands
/usr/bin/rm -rf "$directory"
# ✗ BAD: Trusting user's PATH
rm -rf "$directory" # What if there's a malicious 'rm' in PATH?
Why: Prevents PATH injection attacks.
Summary
Most Critical Patterns:
- Always quote variable expansions:
"$var" - Use
set -euo pipefailfor safety - Prefer
[[ ]]over[ ]in bash - Use arrays for lists:
"${array[@]}" - Check command success:
if ! command; then - Use local variables in functions
- Errors to stderr:
echo "Error" >&2 - Use
mktempfor temporary files - Cleanup with traps:
trap cleanup EXIT - Validate all user input
Most Dangerous Anti-Patterns:
- Unquoted variables:
$var - Parsing
lsoutput - Using
evalwith user input - Silencing errors without checking
- Not using
set -uor defaults - Global variables in functions
- Word splitting on filenames
- Testing strings with
>for numbers kill -9without trying graceful shutdown- Trusting user PATH
Following these patterns and avoiding anti-patterns will result in robust, secure, and maintainable bash scripts.