funwithlinux guide

How to Write Self-Documenting Bash Scripts

Bash scripts are the workhorses of system administration, automation, and DevOps. They simplify repetitive tasks, orchestrate workflows, and bridge gaps between tools. However, as scripts grow in complexity—or are revisited months after writing—they often become "black boxes": hard to read, harder to modify, and nearly impossible for others (or future you) to understand without extensive comments or external documentation. **Self-documenting bash scripts** solve this problem. These are scripts designed to be inherently readable, with clear structure, descriptive names, and built-in explanations that eliminate the need for separate documentation. A well-written self-documenting script *explains itself* through its code, making maintenance, collaboration, and debugging drastically easier. In this guide, we’ll break down the principles and practices for writing self-documenting bash scripts, from high-level structure to granular details like variable naming and exit codes. By the end, you’ll be able to write scripts that are not just functional, but also intuitive and maintainable.

Table of Contents

  1. Start with a Clear Shebang and Purpose Comment
  2. Use Descriptive Script Names
  3. Embed Usage Documentation
  4. Design Readable Functions
  5. Name Variables Intentionally
  6. Write “Why” Comments, Not “What” Comments
  7. Enforce Consistent Formatting
  8. Use Meaningful Exit Codes
  9. Example: A Self-Documenting Script in Action
  10. 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_path instead of drive; log_file_size_limit instead of size.
  • 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., i in for i in {1..5}), single-letter variables are ambiguous.
  • Mark constants as readonly: For values that never change (e.g., paths, timeouts), use readonly to 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 rsync over cp for 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 shfmt can 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

CodeMeaning
0Success
1General error (e.g., “Backup failed”)
2Invalid arguments (e.g., missing --drive flag)
126Permission denied (cannot execute a required tool)
127Command 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

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.