Table of Contents
- 1. Start with a Solid Foundation: Script Structure
- 2. Enforce Strict Error Handling
- 3. Validate Inputs Rigorously
- 4. Master Variable Handling
- 5. Execute Commands Safely
- 6. Prioritize Security
- 7. Optimize Performance
- 8. Test and Debug Thoroughly
- 9. Ensure Portability
- 10. Document Your Script
- Example: A Robust Backup Script
- References
1. Start with a Solid Foundation: Script Structure
A well-structured script is easier to read, debug, and maintain. Start with these basics:
1.1 Shebang
Always start with a shebang (#!) to specify the interpreter. Use #!/bin/bash for bash-specific scripts, not #!/bin/sh (which may point to a minimal shell like dash).
#!/bin/bash
Why? Ensures the script runs with bash even if executed in a shell that’s not bash (e.g., sh).
1.2 Comments and Documentation
Add a header comment describing the script’s purpose, author, version, and usage. Use inline comments for non-obvious logic.
#!/bin/bash
# Purpose: Backs up a directory to a compressed archive
# Author: Jane Doe
# Version: 1.0
# Usage: ./backup.sh <source_dir> <dest_dir>
1.3 Organize Code Logically
Structure your script in sections:
- Configuration: Constants, variables, and settings.
- Functions: Reusable logic (e.g.,
usage(),validate_input()). - Main Execution: Argument parsing, validation, and workflow.
#!/bin/bash
# Configuration
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_EXT=".tar.gz"
# Functions
usage() {
echo "Usage: $0 <source_dir> <dest_dir>"
exit 1
}
# Main execution
parse_arguments "$@"
validate_inputs
create_backup
2. Enforce Strict Error Handling
Bash scripts are notoriously lenient by default (e.g., ignoring undefined variables or failed commands). Tighten this with strict options.
2.1 Use set -euo pipefail
Add this line near the top of your script to enable strict error checking:
-e: Exit immediately if any command fails (non-zero exit code).-u: Treat unset variables as errors (avoids “empty string” bugs).-o pipefail: Make a pipeline fail if any command in the pipeline fails (not just the last one).
#!/bin/bash
set -euo pipefail # Strict error checking
# ... rest of script ...
Exception: To temporarily disable -e (e.g., for commands expected to fail), use set +e and re-enable with set -e:
set +e # Disable strict exit
grep "pattern" file.txt # This might fail, but we don't want to exit
exit_code=$?
set -e # Re-enable strict exit
2.2 Handle Errors Explicitly
Even with set -e, explicitly check critical commands. Use if statements or &&/|| for control flow:
# Check if a command succeeded
if ! mkdir -p "$dest_dir"; then
echo "Error: Failed to create $dest_dir"
exit 1
fi
# Alternative: Use && for success, || for failure
mkdir -p "$dest_dir" || { echo "Error creating $dest_dir"; exit 1; }
2.3 Use Traps for Cleanup
Use trap to run cleanup code (e.g., deleting temp files) on exit, error, or signals like Ctrl+C.
#!/bin/bash
set -euo pipefail
TEMP_FILE=$(mktemp)
# Clean up temp file on exit (success or failure)
trap 'rm -f "$TEMP_FILE"' EXIT
# Clean up and exit on Ctrl+C (SIGINT)
trap 'echo "Aborted!"; exit 1' SIGINT
EXIT: Triggered when the script exits (normal or error).ERR: Triggered when a command fails (requiresset -o errtracein some shells).
3. Validate Inputs Rigorously
Scripts often fail because of invalid inputs (e.g., missing arguments, non-existent files). Validate early and often.
3.1 Check Argument Count
Ensure the user provides the required number of arguments:
if [ $# -ne 2 ]; then # $# = number of arguments
echo "Error: Incorrect number of arguments"
usage # Call the usage function defined earlier
fi
3.2 Validate Argument Types
Check if paths exist, are directories, or have correct permissions:
source_dir="$1"
dest_dir="$2"
# Check if source_dir exists and is a directory
if [ ! -d "$source_dir" ]; then
echo "Error: $source_dir is not a valid directory"
exit 1
fi
# Check if dest_dir is writable
if [ ! -w "$dest_dir" ]; then
echo "Error: $dest_dir is not writable"
exit 1
fi
Common checks:
-d "$dir": Is$dira directory?-f "$file": Is$filea regular file?-x "$cmd": Is$cmdexecutable?
3.3 Use getopts for Flags/Options
For scripts with flags (e.g., --verbose, -f), use getopts to parse arguments cleanly:
verbose=0
force=0
# Parse options: -v (verbose), -f (force)
while getopts "vf" opt; do
case "$opt" in
v) verbose=1 ;;
f) force=1 ;;
\?) echo "Invalid option: -$OPTARG" >&2; exit 1 ;;
esac
done
# Shift past options to get positional arguments
shift $((OPTIND - 1))
source_dir="$1"
dest_dir="$2"
4. Master Variable Handling
Poor variable handling causes most bash bugs (e.g., word splitting, globbing).
4.1 Always Quote Variables
Unquoted variables are split into words and expanded (e.g., file name.txt becomes file and name.txt). Quote variables to avoid this:
file="my file.txt"
# Bad: Splits into "my" and "file.txt" (word splitting)
ls $file # Error: No such file or directory
# Good: Treats as a single argument
ls "$file" # Works!
When to quote: Always, unless you explicitly want word splitting/expansion (rare).
4.2 Avoid Global Variables: Use local in Functions
Variables in bash are global by default. Use local in functions to limit scope:
# Bad: $temp is global
process_data() {
temp="processed_$1" # Leaks to global scope
echo "$temp"
}
# Good: $temp is local to the function
process_data() {
local temp="processed_$1" # Local variable
echo "$temp"
}
4.3 Use Read-Only Variables for Constants
Mark constants as read-only with readonly to prevent accidental modification:
readonly MAX_RETRIES=3
readonly BACKUP_SERVER="backup.example.com"
MAX_RETRIES=5 # Error: cannot assign to read-only variable
5. Execute Commands Safely
Run commands intentionally and handle failures gracefully.
5.1 Check Exit Codes Explicitly
Even with set -e, verify critical commands. Use $? to get the exit code of the last command:
tar -czf "$backup_file" "$source_dir"
if [ $? -ne 0 ]; then # $? = 0 if success, non-zero if failure
echo "Error: Backup failed"
exit 1
fi
Shorter alternative with if:
if ! tar -czf "$backup_file" "$source_dir"; then
echo "Error: Backup failed"
exit 1
fi
5.2 Avoid eval and Unsafe Command Construction
eval executes strings as code, which is a security risk. Never use it with untrusted input:
# Evil: Allows command injection!
user_input="; rm -rf /" # Malicious input
eval "echo $user_input" # Executes "echo ; rm -rf /"
# Safe: Use variables directly instead
echo "$user_input" # Only prints "; rm -rf /"
6. Prioritize Security
Bash scripts can expose systems to attacks if not secured.
6.1 Sanitize Inputs to Prevent Injection
If your script accepts user input used in commands, sanitize it. For example, avoid passing raw input to grep or find:
# Bad: Allows injection (e.g., input="file.txt; rm -rf /")
input="$1"
grep "pattern" $input # Executes "grep pattern file.txt; rm -rf /"
# Good: Quote the input
grep "pattern" "$input" # Treats input as a single filename
6.2 Avoid Running as Root Unnecessarily
Scripts often don’t need root privileges. Check $EUID (effective user ID) and exit if running as root accidentally:
if [ "$EUID" -eq 0 ]; then
echo "Error: Do not run this script as root" >&2
exit 1
fi
7. Optimize Performance
Bash is not the fastest language, but you can optimize critical paths.
7.1 Use Built-in Commands Over External Tools
Bash has built-ins like [ ] (test), echo, and printf that are faster than external tools like test or awk for simple tasks:
# Bad: Uses external `test` command
if test -d "$dir"; then ...
# Good: Uses built-in `[ ]` (same as `test`)
if [ -d "$dir" ]; then ...
7.2 Minimize Subshells
Subshells ($(...), backticks) create new processes and slow down loops. Avoid them in loops:
# Bad: Subshell in loop (slow for large N)
for i in {1..1000}; do
count=$(wc -l < file.txt) # Subshell for each iteration
done
# Good: Read once outside the loop
count=$(wc -l < file.txt)
for i in {1..1000}; do
echo "$count"
done
8. Test and Debug Thoroughly
Even robust scripts need testing. Use tools to catch issues early.
8.1 Use shellcheck for Static Analysis
shellcheck is a linter that flags bugs, bad practices, and portability issues:
# Install: sudo apt install shellcheck (Linux) or brew install shellcheck (macOS)
shellcheck my_script.sh
Example output:
In my_script.sh line 5:
echo "Hello, $NAME"
^-----^ SC2154: NAME is referenced but not assigned.
8.2 Write Tests with bats
The bats framework lets you write unit tests for bash scripts:
# test_backup.bats
@test "backup fails with invalid source" {
run ./backup.sh /invalid/dir /tmp
[ "$status" -eq 1 ]
[ "$output" = "Error: /invalid/dir is not a valid directory" ]
}
Run tests with bats test_backup.bats.
8.3 Debug with set -x
Enable debug mode to print commands as they run:
#!/bin/bash
set -x # Print commands and arguments (verbose mode)
source_dir="$1"
mkdir -p "$dest_dir" # Will print: + mkdir -p /tmp/backups
Use set -x temporarily (e.g., in a function) instead of globally for noisy scripts.
9. Ensure Portability
If your script needs to run on multiple systems (e.g., Linux, macOS, BSD), avoid platform-specific features.
9.1 Avoid Bash-Specific Features When Targeting sh
If using #!/bin/sh, avoid bash-only features like arrays, [[ ]], or source (use . instead):
# Bash-specific (avoid in sh scripts)
[[ "$var" == "value" ]] # Use [ "$var" = "value" ] instead
# Bash array (not portable to sh)
files=("a.txt" "b.txt") # Use positional parameters or loops instead
9.2 Check for Dependencies
Ensure required commands (e.g., tar, curl) exist before using them:
# Check if tar is available
if ! command -v tar &> /dev/null; then
echo "Error: tar is not installed"
exit 1
fi
Use command -v instead of which (more portable).
10. Document Your Script
Help users (and future you) understand how to use and modify the script.
10.1 Add a --help Option
Include a --help flag to display usage instructions:
if [ "$1" = "--help" ]; then
usage
exit 0
fi
10.2 Maintain Inline Comments for Complex Logic
Explain “why” not just “what” for non-trivial code:
# Use rsync instead of cp for incremental backups (faster for large dirs)
rsync -av --delete "$source_dir"/ "$dest_dir"/
Example: A Robust Backup Script
Here’s a script combining all these practices:
#!/bin/bash
set -euo pipefail
# Purpose: Backs up a directory to a compressed archive with error handling
# Usage: ./backup.sh [--force] <source_dir> <dest_dir>
# Configuration
readonly TIMESTAMP=$(date +%Y%m%d_%H%M%S)
readonly BACKUP_EXT=".tar.gz"
force=0
# Cleanup temp files on exit
temp_dir=$(mktemp -d)
trap 'rm -rf "$temp_dir"' EXIT
# Functions
usage() {
echo "Usage: $0 [--force] <source_dir> <dest_dir>"
echo " --force: Overwrite existing backup"
exit 1
}
validate_inputs() {
local source="$1"
local dest="$2"
if [ ! -d "$source" ]; then
echo "Error: Source '$source' is not a directory" >&2
exit 1
fi
if [ ! -w "$dest" ]; then
echo "Error: Destination '$dest' is not writable" >&2
exit 1
fi
}
create_backup() {
local source="$1"
local dest="$2"
local backup_name="backup_${TIMESTAMP}${BACKUP_EXT}"
local backup_path="${dest}/${backup_name}"
if [ -f "$backup_path" ] && [ "$force" -eq 0 ]; then
echo "Error: Backup '$backup_path' exists. Use --force to overwrite." >&2
exit 1
fi
echo "Creating backup: $backup_path"
tar -czf "$backup_path" -C "$source" . # -C changes to source before archiving
echo "Backup successful: $backup_path"
}
# Main execution
# Parse --force flag
if [ "$1" = "--force" ]; then
force=1
shift
fi
# Check arguments
if [ $# -ne 2 ]; then
usage
fi
source_dir="$1"
dest_dir="$2"
validate_inputs "$source_dir" "$dest_dir"
create_backup "$source_dir" "$dest_dir"
References
- Bash Manual
- ShellCheck (Static analysis for bash scripts)
- Bats (Bash testing framework)
- Greg’s Wiki: BashFAQ (Common bash questions)
- Google Shell Style Guide (Best practices)