Site Tools


wiki:shell-best-practices

Shell Best Practices

Script Structure

Start with a shebang line and safety flags. Use clear variable names, proper quoting, and functions to organize code.

#!/bin/bash
set -e -u -o pipefail
 
# Script configuration
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly DATA_FILE="$SCRIPT_DIR/data.txt"
 
# Main function
main() {
    if [[ $# -lt 1 ]]; then
        usage
        exit 1
    fi
 
    local input="$1"
    process_input "$input"
}
 
# Helper function
process_input() {
    local input="$1"
    # Use "$input" (quoted) not $input
    echo "Processing: $input"
}
 
usage() {
    cat << EOF
Usage: $0 INPUT
EOF
}
 
# Make script executable: chmod +x script.sh
main "$@"

Explain the safety flags:

  • set -e — Exit immediately if any command fails (exit status non-zero)
  • set -u — Exit if undefined variable is used (catches typos)
  • set -o pipefail — Pipe fails if any command in the pipeline fails

Always quote variables to preserve whitespace and special characters:

# BAD: loses structure with spaces
for file in $files; do
    process $file  # Breaks if $files contains filenames with spaces
done
 
# GOOD: preserves structure
for file in "$files"; do
    process "$file"
done
 
# GOOD: array iteration
for file in "${files[@]}"; do
    process "$file"
done

Debugging

Enable debug mode with set -x to trace command execution. Improve debug output with a custom PS4 prompt.

# Enable debugging for the entire script
#!/bin/bash
set -x
 
# Or enable debugging only for a section
set -x
# ... code to debug ...
set +x
 
# Improve debug output with line numbers and filenames
set -x
PS4='+ ${BASH_SOURCE}:${LINENO}: '
# Now debug output shows which file and line is executing
 
# Example output:
# + /path/to/script.sh:42: command arg1 arg2

Use shellcheck to lint scripts and catch common errors:

# Install shellcheck (available in most package managers)
# apt install shellcheck, brew install shellcheck, etc.
 
# Check a script
shellcheck script.sh
 
# It catches issues like:
# - Unquoted variables
# - Unreachable code
# - Incorrect comparison operators
# - Variable shadowing

Test POSIX compatibility by running with sh instead of bash:

# Run script with sh (more portable, stricter)
sh script.sh
 
# Avoid bash-specific features for maximum portability:
# - Use [ ] instead of [[ ]] (though [[ ]] is very common)
# - Use $(cmd) instead of `cmd`
# - Avoid arrays (not in POSIX sh)
# - Avoid declare, local (check sh compatibility)

Common Pitfalls

Forgetting to quote variables is the most common mistake. It breaks on spaces, glob patterns, and empty values.

# BAD: breaks with spaces and glob patterns
file="my file.txt"
rm $file           # Expands to: rm my file.txt (wrong!)
 
# GOOD: preserves spaces and special characters
rm "$file"         # Correctly removes "my file.txt"
 
# BAD: numeric comparison with string operator
if [ "$num" = "5" ]; then   # String comparison (wrong)
    echo "five"
fi
 
# GOOD: use -eq for numeric comparison
if [[ $num -eq 5 ]]; then   # Numeric comparison (correct)
    echo "five"
fi

Use [[ ]] instead of [ ] for safer conditionals in bash (allows =~ regex, safer quoting):

# BAD: needs careful quoting
if [ -z "$var" ]; then echo "empty"; fi
 
# GOOD: safer with [[
if [[ -z $var ]]; then echo "empty"; fi
 
# BAD: backticks are harder to nest
result=`cat \`ls file.txt\``
 
# GOOD: $() is nestable and modern
result=$(cat "$(ls file.txt)")

Always check exit codes when they matter:

# BAD: ignores failure silently
download_file
 
# GOOD: check status
if ! download_file; then
    echo "Download failed"
    exit 1
fi
 
# GOOD: short-circuit operators
download_file || exit 1
 
# BAD: secrets exposed in environment
export DB_PASSWORD="secret123"
ps aux | grep script  # Password visible!
 
# GOOD: read from secure file
DB_PASSWORD=$(cat /etc/app/db_password)
export DB_USER="appuser"  # Non-sensitive only

Performance

Avoid subshells and command substitutions in loops. Use built-in string operations instead of external commands.

# BAD: spawns subprocess for each iteration
for line in "$(cat bigfile.txt)"; do
    process "$line"
done
 
# GOOD: reads directly without subprocess
while IFS= read -r line; do
    process "$line"
done < bigfile.txt
 
# BAD: multiple sed invocations
text=$1
text=$(echo "$text" | sed 's/a/b/')
text=$(echo "$text" | sed 's/c/d/')
 
# GOOD: use bash string operations
text="${1//a/b}"  # Replace first
text="${text//c/d}"  # Replace all

Minimize command substitutions and use built-ins:

# BAD: spawns external command
count=$(echo "$str" | wc -c)
 
# GOOD: use bash string length
count=${#str}
 
# BAD: multiple external calls
upper=$(echo "$str" | tr 'a-z' 'A-Z')
 
# GOOD: bash 4+ parameter expansion
upper="${str^^}"
 
# BAD: spawns grep
if echo "$line" | grep -q "pattern"; then ...
 
# GOOD: use bash pattern matching
if [[ $line == *pattern* ]]; then ...

Prefer [[ ]] over [ ] for better performance and fewer quoting issues:

# BAD: slower and requires more quoting
if [ -n "$var" ] && [ "$var" != "x" ]; then ...
 
# GOOD: faster, safer
if [[ -n $var && $var != x ]]; then ...
wiki/shell-best-practices.md · Last modified: by 127.0.0.1