funwithlinux guide

Writing Robust Bash Scripts: Best Practices and Tips

Bash scripts are the backbone of automation in Unix-like systems, powering everything from simple file backups to complex deployment pipelines. However, writing scripts that are reliable, maintainable, and secure can be challenging. A poorly written script might fail silently, corrupt data, or even introduce security vulnerabilities. This blog aims to demystify the process of creating robust bash scripts by breaking down essential best practices and actionable tips. Whether you’re a beginner looking to level up or an experienced developer refining your skills, these guidelines will help you write scripts that are resilient, easy to debug, and safe to use.

Table of Contents

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:

  1. Configuration: Constants, variables, and settings.
  2. Functions: Reusable logic (e.g., usage(), validate_input()).
  3. 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 (requires set -o errtrace in 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 $dir a directory?
  • -f "$file": Is $file a regular file?
  • -x "$cmd": Is $cmd executable?

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