Table of Contents
- Start with a Clear Shebang and Purpose Comment
- Use Descriptive Script Names
- Embed Usage Documentation
- Design Readable Functions
- Name Variables Intentionally
- Write “Why” Comments, Not “What” Comments
- Enforce Consistent Formatting
- Use Meaningful Exit Codes
- Example: A Self-Documenting Script in Action
- References
1. Start with a Clear Shebang and Purpose Comment
The first lines of your script set the tone for readability. They should immediately answer: What interpreter runs this script? and What does this script do?
The Shebang Line
Always start with a shebang (#!) to specify the interpreter. For bash scripts, use:
#!/usr/bin/env bash
This is more portable than #!/bin/bash because env locates the bash executable in the user’s PATH, avoiding issues with systems where bash isn’t in /bin (e.g., macOS, some Linux distributions).
Purpose and Metadata Comment
Immediately after the shebang, add a comment block describing the script’s purpose, author, version, and key details. This acts as a “header” for anyone opening the script.
Example:
#!/usr/bin/env bash
# Purpose: Automates daily backups of user home directories to an external drive
# Author: Jane Doe <[email protected]>
# Version: 1.0.0
# Date: 2024-03-15
# Dependencies: rsync, gzip, sudo (for root access to protected files)
# Usage: ./backup-home-dir.sh [--dry-run] [--verbose]
This header answers critical questions at a glance: What does it do? Who wrote it? How do I run it?
2. Use Descriptive Script Names
A script’s filename should hint at its purpose. Avoid generic names like script.sh, do_stuff.sh, or fix_thing.sh. Instead, use action-oriented, specific names that describe the task.
Good Examples:
backup-home-dir.sh(backs up home directories)deploy-api-to-staging.sh(deploys an API to staging)clean-up-old-logs.sh(purges outdated log files)
Bad Examples:
script.sh(no clue what it does)backup.sh(backup what? Where?)run.sh(runs what? How?)
Consistency helps too. Use kebab-case (lowercase words separated by hyphens) for readability, as filenames with spaces or underscores are harder to type and less intuitive.
3. Embed Usage Documentation
Every script should include a --help option that prints a human-readable guide to its usage. This eliminates the need to hunt for external docs when you forget how to run the script.
How to Implement It
Add a usage() function that prints:
- A brief description of the script
- Required/optional arguments
- Flags/options (e.g.,
--dry-run,--verbose) - Examples of common use cases
Then, check if the user passed --help or -h and call usage if so.
Example usage() Function:
usage() {
cat << EOF
Usage: $(basename "$0") [OPTIONS]
Automates daily backups of user home directories to an external drive.
Options:
--dry-run Simulate backup without making changes (default: off)
--verbose Enable detailed output (default: off)
-h, --help Show this help message and exit
Examples:
# Run a normal backup
$(basename "$0")
# Simulate backup and show details
$(basename "$0") --dry-run --verbose
EOF
}
Trigger usage for --help
At the start of your script, add:
# Check for --help or -h
if [[ "$1" == "--help" || "$1" == "-h" ]]; then
usage
exit 0
fi
Now, running ./backup-home-dir.sh --help prints a clear guide, making the script self-sufficient.
4. Design Readable Functions
Bash scripts often devolve into long, monolithic blocks of code. To fix this, split logic into small, focused functions with descriptive names. Each function should do one thing and do it well.
Function Naming
Function names should be verb-noun phrases that describe their action. Avoid vague names like process(), handle_data(), or do_step1().
Good Function Names:
validate_backup_drive()(checks if the backup drive is mounted)compress_logs_before_backup()(zips logs to save space)print_backup_summary()(outputs a report after backup)
Bad Function Names:
check()(check what?)run_backup()(too vague; “run” is redundant)step2()(no context for what “step 2” is)
Function Comment Blocks
Add a brief comment block above each function to explain:
- Purpose: What the function does.
- Parameters: What arguments it expects (if any).
- Side Effects: Does it modify files? Print output? Exit the script?
Example Function with Comment Block:
# Purpose: Validates that the backup drive is mounted and writable
# Parameters:
# $1 - Path to the backup drive (e.g., /mnt/backup)
# Returns:
# 0 if drive is valid; 1 if not mounted; 2 if not writable
validate_backup_drive() {
local backup_drive="$1"
if ! mountpoint -q "$backup_drive"; then
echo "Error: Backup drive $backup_drive is not mounted." >&2
return 1
fi
if ! touch "$backup_drive/test_write.txt" 2>/dev/null; then
echo "Error: Backup drive $backup_drive is not writable." >&2
return 2
fi
rm -f "$backup_drive/test_write.txt"
return 0
}
This comment block clarifies what the function does, what inputs it needs, and how to interpret its return value—critical for anyone modifying the script later.
5. Name Variables Intentionally
Variables are the “nouns” of your script. Poorly named variables (e.g., a, tmp, x) turn scripts into puzzles. Instead, use descriptive, context-rich names that explain what the variable holds.
Rules for Variable Naming
- Be specific:
backup_drive_pathinstead ofdrive;log_file_size_limitinstead ofsize. - Use lowercase for local variables: Follow bash convention (uppercase for environment variables, lowercase for script-local variables).
- Avoid single letters: Except in short loops (e.g.,
iinfor i in {1..5}), single-letter variables are ambiguous. - Mark constants as
readonly: For values that never change (e.g., paths, timeouts), usereadonlyto prevent accidental modification.
Good Variable Examples:
local backup_drive="/mnt/external/backups" # Path to backup destination
local max_log_age_days=30 # Delete logs older than 30 days
readonly ERROR_LOG="/var/log/backup-errors.log" # Constant path to error log
Bad Variable Examples:
local d="/mnt/external/backups" # What is "d"? Drive? Directory?
local m=30 # "m" could mean minutes, months, or max?
log="/var/log/backup-errors.log" # Not marked as readonly; could be overwritten
6. Write “Why” Comments, Not “What” Comments
Comments should explain why the code does something, not what it does. The code itself should make the “what” obvious. Redundant comments (e.g., i++ # increment i) clutter the script and waste space.
When to Comment
- Complex logic: If a section uses non-obvious bash features (e.g., parameter expansion, subshells), explain why it’s needed.
- Workarounds: If you’re fixing a bug or edge case (e.g., “Handle USB drives that report incorrect sizes”), note the context.
- Decisions with tradeoffs: If you chose
rsyncovercpfor speed, explain why:# Use rsync --link-dest to save space with incremental backups.
Example of Good Comments:
# Use --link-dest to create hardlinks to previous backup, saving space
rsync -a --link-dest="$latest_backup" "$source_dir" "$new_backup"
# Sleep 5s to avoid overwhelming the USB drive (it’s slow to mount)
sleep 5
Example of Bad (Redundant) Comments:
i=0 # set i to 0
i=$((i + 1)) # increment i by 1
echo "$i" # print i
7. Enforce Consistent Formatting
Consistent formatting makes scripts easier to scan and understand. Even small inconsistencies (e.g., mixed indentation, random line breaks) can break readability.
Key Formatting Rules
- Indentation: Use 2 or 4 spaces (never tabs). Tools like
shfmtcan auto-format this. - Line length: Keep lines under 80 characters (or 120, if you prefer). Wrap long commands with backslashes or use subshells.
- Whitespace: Add blank lines between logical sections (e.g., after the header, between functions).
- Avoid backslashes for line continuation when possible. Use subshells or arrays instead:
# Bad: Hard to read with backslashes rsync -a \ --exclude=".cache" \ --exclude=".local/share" \ "$source" "$dest" # Better: Use an array for arguments local rsync_args=( -a --exclude=".cache" --exclude=".local/share" ) rsync "${rsync_args[@]}" "$source" "$dest"
Tools to Automate Formatting
Use linters and formatters to enforce style consistently:
- shfmt: Auto-formats bash scripts (supports indentation, line length, and syntax).
- shellcheck: A linter that flags errors, bad practices, and inconsistent formatting.
Example Workflow:
# Install shfmt (Linux)
sudo apt install shfmt
# Format your script with 2-space indentation and 80-char line limits
shfmt -i 2 -w 80 backup-home-dir.sh
8. Use Meaningful Exit Codes
Bash scripts exit with a status code (0 for success, non-zero for failure). Using standardized, documented exit codes helps users and other scripts diagnose issues.
Common Exit Codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | General error (e.g., “Backup failed”) |
| 2 | Invalid arguments (e.g., missing --drive flag) |
| 126 | Permission denied (cannot execute a required tool) |
| 127 | Command not found (e.g., rsync is missing) |
Document these codes in your usage() function so users know what went wrong. For example:
usage() {
cat << EOF
...
Exit Codes:
0 Success
1 Backup failed (e.g., drive full, I/O error)
2 Invalid arguments (e.g., missing --drive)
127 Required tool not found (e.g., rsync is missing)
EOF
}
How to Use Them
Explicitly exit with codes instead of letting the script fail silently:
# Check if rsync is installed
if ! command -v rsync &> /dev/null; then
echo "Error: rsync is required but not installed." >&2
exit 127
fi
# Validate arguments
if [[ -z "$backup_drive" ]]; then
echo "Error: --drive is required." >&2
exit 2
fi
9. Example: A Self-Documenting Script in Action
Let’s tie it all together with a sample script that implements the practices above. This script backs up home directories with --dry-run and --verbose options, and includes all the self-documenting features we’ve discussed.
backup-home-dir.sh
#!/usr/bin/env bash
# Purpose: Automates daily backups of user home directories to an external drive
# Author: Jane Doe <[email protected]>
# Version: 1.0.0
# Date: 2024-03-15
# Dependencies: rsync, mountpoint
# Usage: ./backup-home-dir.sh [--dry-run] [--verbose] [--help]
set -eo pipefail # Exit on error; catch pipes
# --------------------------
# Variables
# --------------------------
local dry_run=false
local verbose=false
local backup_drive="/mnt/external/backups"
local source_dir="/home"
local latest_backup_link="$backup_drive/latest"
readonly ERROR_LOG="/var/log/backup-errors.log"
# --------------------------
# Usage Documentation
# --------------------------
usage() {
cat << EOF
Usage: $(basename "$0") [OPTIONS]
Automates daily backups of user home directories to an external drive.
Uses rsync for incremental backups (saves space with hardlinks).
Options:
--dry-run Simulate backup without making changes (default: off)
--verbose Enable detailed output (default: off)
-h, --help Show this help message and exit
Exit Codes:
0 Success
1 Backup failed (e.g., drive full, I/O error)
2 Invalid arguments
127 Required tool not found (e.g., rsync)
Examples:
# Run a normal backup
$(basename "$0")
# Simulate backup and show details
$(basename "$0") --dry-run --verbose
EOF
}
# --------------------------
# Validate Dependencies
# --------------------------
validate_dependencies() {
# Check if required tools are installed
local dependencies=("rsync" "mountpoint")
for dep in "${dependencies[@]}"; do
if ! command -v "$dep" &> /dev/null; then
echo "Error: Required tool '$dep' not found." >&2
exit 127
fi
done
}
# --------------------------
# Validate Backup Drive
# --------------------------
validate_backup_drive() {
local drive="$1"
# Check if drive is mounted
if ! mountpoint -q "$drive"; then
echo "Error: Backup drive '$drive' is not mounted." >&2
return 1
fi
# Check if drive is writable
if ! touch "$drive/test-write.txt" &> /dev/null; then
echo "Error: Backup drive '$drive' is not writable." >&2
rm -f "$drive/test-write.txt" # Cleanup in case of partial success
return 1
fi
rm -f "$drive/test-write.txt"
return 0
}
# --------------------------
# Main Backup Logic
# --------------------------
run_backup() {
local rsync_opts=("-a" "--delete") # Archive mode; delete extraneous files
# Add dry-run if enabled
if [[ "$dry_run" == true ]]; then
rsync_opts+=("--dry-run")
fi
# Add verbose if enabled
if [[ "$verbose" == true ]]; then
rsync_opts+=("-v")
fi
# Use hardlinks to previous backup for space efficiency
if [[ -d "$latest_backup_link" ]]; then
rsync_opts+=("--link-dest=$latest_backup_link")
fi
# Run rsync
echo "Starting backup from '$source_dir' to '$backup_drive'..."
if rsync "${rsync_opts[@]}" "$source_dir" "$backup_drive/current"; then
echo "Backup completed successfully."
# Update symlink to latest backup
ln -nsf "$backup_drive/current" "$latest_backup_link"
return 0
else
echo "Backup failed. Check $ERROR_LOG for details." >&2
return 1
fi
}
# --------------------------
# Parse Arguments
# --------------------------
parse_args() {
while [[ "$#" -gt 0 ]]; do
case $1 in
--dry-run) dry_run=true ;;
--verbose) verbose=true ;;
-h|--help) usage; exit 0 ;;
*) echo "Error: Unknown option '$1'"; usage; exit 2 ;;
esac
shift
done
}
# --------------------------
# Main Execution
# --------------------------
main() {
parse_args "$@"
validate_dependencies
if ! validate_backup_drive "$backup_drive"; then
exit 1
fi
run_backup || exit 1
}
# Start main execution
main "$@"
10. References
- Bash Style Guide (Google) – Best practices for shell scripting.
- ShellCheck – Linter for bash scripts (flags errors and bad practices).
- shfmt – Auto-formatter for shell scripts.
- Linux Exit Codes – Standard exit codes for bash.
Conclusion
Writing self-documenting bash scripts isn’t about perfection—it’s about empathy. It’s about making your script understandable for the next person (or future you) who needs to modify it. By following these practices—clear headers, descriptive names, embedded usage docs, and consistent formatting—you’ll create scripts that are not just tools, but collaborative assets.
The next time you’re tempted to write a “quick script” with vague names and no comments, remember: the 5 minutes you save now will cost you hours later when you can’t figure out how it works. Invest in self-documentation, and your future self (and teammates) will thank you.