Table of Contents
- Start with a Proper Shebang
- Use Descriptive Naming Conventions
- Enable Strict Error Handling
- Validate Inputs and Dependencies
- Leverage Functions for Reusability
- Format Code for Readability
- Write Clear Comments and Documentation
- Avoid Global Variables
- Handle Errors Gracefully
- Use Modern Shell Features (When Appropriate)
- Test Thoroughly
- Version Control and Peer Review
- Conclusion
- References
1. Start with a Proper Shebang
The shebang (#!) at the top of a script tells the system which interpreter to use. Using an explicit, correct shebang ensures your script runs with the intended shell (e.g., bash, not sh), avoiding unexpected behavior due to differing shell features.
Best Practice:
- Use
#!/bin/bashfor Bash-specific scripts (most common). - Use
#!/bin/shonly for POSIX-compliant scripts (limited features, but portable). - Avoid
#!/usr/bin/env bashunless you need to resolve the interpreter via$PATH(e.g., for virtual environments).
Example:
#!/bin/bash # Good: Explicitly uses Bash
#!/bin/sh # Caution: Uses POSIX shell (no arrays, process substitution, etc.)
#!/bin/env bash # Avoid: Less predictable interpreter resolution
2. Use Descriptive Naming Conventions
Ambiguous names (e.g., $x, $tmp, process()) make scripts hard to follow. Use descriptive names for variables, functions, and files to clarify intent.
Guidelines:
- Variables: Use lowercase with underscores (e.g.,
user_count,log_file_path). - Constants: Use uppercase with underscores (e.g.,
MAX_RETRIES=3,API_BASE_URL="https://api.example.com"). - Functions: Use verbs or verb-noun pairs (e.g.,
backup_logs,validate_input). - Avoid abbreviations unless universally understood (e.g.,
configis okay,cfgis not).
Example:
# Bad: Ambiguous names
x=5
tmp="/tmp/file.txt"
do_stuff() { ... }
# Good: Clear, descriptive names
user_count=5
temporary_log_path="/tmp/app.log"
compress_and_backup_logs() { ... }
3. Enable Strict Error Handling
By default, Bash ignores errors (e.g., a failed cd won’t stop the script) and tolerates unset variables. This can hide bugs. Use set -euo pipefail to make scripts robust.
What Each Flag Does:
-e: Exit immediately if any command fails (non-zero exit code).-u: Treat unset variables as an error and exit.-o pipefail: Exit if any command in a pipeline fails (not just the last one).
Example:
#!/bin/bash
set -euo pipefail # Enable strict mode
# Without -u: Unset variable $undefined_var would expand to empty string
echo "Hello, $undefined_var" # Fails with "-u": "undefined_var: unbound variable"
# Without -e: Script continues even if "cd" fails
cd /invalid/directory # Fails with "-e": Exits immediately
# Without pipefail: Pipeline returns exit code of last command (grep), hiding "ls" failure
ls /invalid/path | grep "file.txt" # Fails with pipefail: "ls" returns non-zero, script exits
4. Validate Inputs and Dependencies
Scripts often fail because inputs are missing, files don’t exist, or required tools (e.g., curl, jq) aren’t installed. Validate these upfront to avoid cryptic errors later.
Common Checks:
- Input arguments: Ensure required arguments are provided (check
$#). - File existence: Verify files/directories exist with
[ -f "$file" ](file) or[ -d "$dir" ](directory). - Dependencies: Check if tools are installed with
command -v <tool>.
Example:
#!/bin/bash
set -euo pipefail
# Validate input arguments
if [ $# -ne 1 ]; then
echo "ERROR: Usage: $0 <log_file_path>" >&2 # >&2 redirects to stderr
exit 1
fi
log_file="$1"
# Validate log file exists
if [ ! -f "$log_file" ]; then
echo "ERROR: Log file '$log_file' not found." >&2
exit 1
fi
# Validate dependency (e.g., jq for JSON parsing)
if ! command -v jq &> /dev/null; then
echo "ERROR: 'jq' is required but not installed. Install with: sudo apt install jq" >&2
exit 1
fi
5. Leverage Functions for Reusability
Repeating code leads to duplication and bugs. Use functions to encapsulate logic, improve readability, and enable reuse.
Best Practices for Functions:
- Single responsibility: Each function should do one thing (e.g.,
parse_configvs.parse_config_and_send_email). - Use parameters: Pass inputs as arguments instead of relying on global variables.
- Return values: Use
returnfor exit codes (0 = success) orechoto return strings (capture withresult=$(my_function "input")). - Local variables: Use
localto limit scope and avoid polluting the global namespace.
Example:
#!/bin/bash
set -euo pipefail
# Function to calculate average (returns exit code 0 on success)
calculate_average() {
local sum=0
local count=0
# Validate input: Ensure arguments are numbers
for num in "$@"; do
if ! [[ "$num" =~ ^[0-9]+$ ]]; then
echo "ERROR: '$num' is not a number" >&2
return 1 # Non-zero exit code = failure
fi
sum=$((sum + num))
count=$((count + 1))
done
# Avoid division by zero
if [ "$count" -eq 0 ]; then
echo "ERROR: No numbers provided" >&2
return 1
fi
local average=$((sum / count))
echo "$average" # Return result via stdout
return 0 # Success
}
# Usage
average=$(calculate_average 10 20 30)
echo "Average: $average" # Output: "Average: 20"
6. Format Code for Readability
Poorly formatted code (inconsistent indentation, long lines) is hard to parse. Adopt consistent formatting rules to improve readability.
Guidelines:
- Indentation: Use 4 spaces (not tabs) for blocks (loops, conditionals, functions).
- Line length: Keep lines under 80 characters (split long commands with
\). - Spacing: Add spaces around operators (
=,==,+) and after commas. - Blank lines: Separate logical sections (e.g., constants, functions, main logic).
Example:
#!/bin/bash
set -euo pipefail
# Constants
MAX_RETRIES=3
API_URL="https://api.example.com/data"
# Function to fetch data with retries
fetch_data() {
local retries=0
local response_file="/tmp/api_response.json"
while [ "$retries" -lt "$MAX_RETRIES" ]; do
# Use curl with timeout and silent mode
curl -s -w "%{http_code}" -o "$response_file" \
--connect-timeout 10 \
"$API_URL"
# Check if HTTP status is 200 (OK)
if grep -q "200" "$response_file"; then
echo "Successfully fetched data"
return 0
fi
retries=$((retries + 1))
echo "Retry $retries/$MAX_RETRIES..."
sleep 2
done
echo "Failed to fetch data after $MAX_RETRIES retries" >&2
return 1
}
# Main logic
fetch_data
process_response "$response_file" # Assume this function exists
7. Write Clear Comments and Documentation
Comments should explain why (intent, assumptions, workarounds) rather than what (the code already shows that). Use them to clarify complex logic, edge cases, or non-obvious decisions.
Guidelines:
- Header comment: At the top of the script, describe purpose, usage, inputs/outputs, and author.
- Function comments: Explain what a function does, parameters, return values, and side effects.
- Avoid redundant comments (e.g.,
x=5 # Set x to 5is unnecessary).
Example:
#!/bin/bash
set -euo pipefail
# Purpose: Backs up log files to S3, rotating logs older than 7 days.
# Usage: ./backup_logs.sh <log_directory> <s3_bucket>
# Dependencies: aws-cli, gzip
# Author: Jane Doe ([email protected])
# ...
# Compress logs older than 7 days (mtime +7). We use gzip instead of bzip2 here
# because gzip is faster for large log files, and S3 transfer time outweighs
# storage savings of bzip2.
compress_old_logs() {
local log_dir="$1"
find "$log_dir" -name "*.log" -mtime +7 -exec gzip {} \;
}
8. Avoid Global Variables
Global variables (variables defined outside functions) can lead to unintended side effects (e.g., a function modifying a variable used elsewhere). Minimize them by using local variables and passing parameters.
Example:
#!/bin/bash
set -euo pipefail
# Bad: Global variable modified by function
count=0
increment_count() {
count=$((count + 1)) # Modifies global variable
}
increment_count
echo "$count" # Output: 1 (works, but risky in large scripts)
# Good: No global variables; pass parameters and return values
increment() {
local num="$1"
echo $((num + 1))
}
current=0
current=$(increment "$current")
echo "$current" # Output: 1 (safer, no side effects)
9. Handle Errors Gracefully
Scripts should fail loudly but informatively. Use trap for cleanup (e.g., temporary files) and provide actionable error messages.
Key Practices:
- Trap signals: Use
trapto clean up temporary files on exit (success or failure). - Error messages: Include context (e.g., “Failed to read file: /data/logs.txt”) and exit with a non-zero code.
- Cleanup functions: Define a
cleanup()function to delete temp files, close connections, etc.
Example:
#!/bin/bash
set -euo pipefail
# Temporary file to clean up
temp_file="/tmp/temp_data.txt"
# Cleanup function: Runs on exit (success, error, or signal)
cleanup() {
if [ -f "$temp_file" ]; then
rm "$temp_file"
echo "Cleaned up temporary file: $temp_file" >&2
fi
}
# Trap EXIT signal to trigger cleanup
trap cleanup EXIT
# Main logic
echo "Creating temporary data..."
echo "some data" > "$temp_file"
# Simulate an error
false # Fails due to "set -e"
echo "This line will never run" # Script exits before here, but cleanup still runs
10. Use Modern Shell Features (When Appropriate)
If targeting Bash (not POSIX sh), leverage modern features to write cleaner, more efficient code. Examples include arrays, parameter expansion, and process substitution.
Useful Modern Features:
- Arrays: Store lists of values (e.g.,
files=("file1.txt" "file2.txt")). - Parameter expansion:
${var,,}(lowercase),${var:0:3}(substring),${var:-default}(default value). - Process substitution:
<(command)to pass output as a “file” (e.g.,diff <(sort a.txt) <(sort b.txt)).
Example:
#!/bin/bash
set -euo pipefail
# Arrays: Store list of log files
log_files=(
"/var/log/auth.log"
"/var/log/syslog"
"/var/log/application.log"
)
# Loop through array
for file in "${log_files[@]}"; do
echo "Processing: $file"
done
# Parameter expansion: Default value if variable is unset
username="${1:-guest}" # Use "guest" if $1 is not provided
echo "Hello, $username"
# Lowercase conversion
uppercase_string="HELLO WORLD"
lowercase_string="${uppercase_string,,}"
echo "$lowercase_string" # Output: "hello world"
11. Test Thoroughly
Even small scripts benefit from testing. Use linters to catch syntax errors and unit tests to validate behavior.
Tools and Practices:
- Linting: Use
shellcheck(static analysis tool) to detect bugs and bad practices.
Example:shellcheck my_script.sh - Unit tests: Use
bats(Bash Automated Testing System) to write test cases.
Example test forcalculate_average(from Section 5):#!/usr/bin/env bats @test "average of 10, 20, 30 is 20" { result=$(./my_script.sh calculate_average 10 20 30) [ "$result" -eq 20 ] } @test "fails on non-numeric input" { run ./my_script.sh calculate_average "abc" [ "$status" -ne 0 ] # Expect non-zero exit code } - Manual testing: Validate edge cases (empty inputs, missing files, network failures).
12. Version Control and Peer Review
Treat shell scripts like any other code: track changes in version control and review them with peers to catch issues early.
Tips:
- Commit frequently: Include descriptive commit messages (e.g., “fix: handle empty log files in backup_logs”).
- Peer review: Use PRs to get feedback on readability, error handling, and edge cases.
- Version control metadata: Add a
CHANGELOG.mdto track script updates.
Conclusion
Writing maintainable shell scripts isn’t about perfection—it’s about consistency and empathy for future readers (including yourself). By following these tips—using strict error handling, descriptive names, functions, and testing—you’ll create scripts that are easier to debug, extend, and collaborate on.
Remember: A script that “works” today but is unmaintainable will cost far more time tomorrow than the extra effort to write it cleanly upfront.