How to Implement Dry-Run Mode in Bash Scripts: Best Practices and Examples


4 views

When writing bash scripts that perform potentially destructive operations, implementing a dry-run mode becomes crucial for testing and safety. The core challenge lies in executing commands conditionally while maintaining script readability.

Most developers consider these approaches:

# Option 1: Wrapping each command in if statements
if [[ -z "$DRY_RUN" ]]; then
    rm -rf /tmp/old_files
else
    echo "Would execute: rm -rf /tmp/old_files"
fi

While functional, this approach leads to code duplication and reduced readability in complex scripts.

Here's an improved version of the function approach:

#!/bin/bash

dry_run() {
    if [[ "${DRY_RUN:-false}" == "true" ]]; then
        printf "[DRY RUN] %s\n" "$*"
        return 0
    fi
    printf "[EXECUTING] %s\n" "$*"
    "$@"
}

# Example usage:
dry_run cp source.txt destination.txt
DRY_RUN=true dry_run mv /old/path /new/path

For production scripts, we should add proper error handling:

dry_run() {
    local cmd=("$@")
    if [[ "${DRY_RUN:-false}" == "true" ]]; then
        printf "[DRY RUN] %s\n" "${cmd[*]}"
        return 0
    fi
    
    if ! "${cmd[@]}"; then
        printf "Error executing: %s\n" "${cmd[*]}" >&2
        return 1
    fi
    return 0
}

The basic approach fails with pipes. Here's a solution:

dry_run_eval() {
    local cmd="$*"
    if [[ "${DRY_RUN:-false}" == "true" ]]; then
        printf "[DRY RUN] %s\n" "$cmd"
        return 0
    fi
    eval "$cmd"
}

# Example with pipe:
dry_run_eval "find . -name '*.tmp' | xargs rm -f"

Bash has a built-in no-execution mode:

#!/bin/bash

if [[ "${DRY_RUN:-false}" == "true" ]]; then
    set -n
    echo "Running in dry-run mode (no commands will execute)"
fi

# Actual commands follow
rm -rf /tmp/cache
mkdir -p /backups

Consider these factors when implementing dry-run:

  • Script complexity
  • Need for accurate command preview
  • Error handling requirements
  • Maintainability

The function wrapper approach generally offers the best balance between flexibility and readability for most use cases.


When building production-grade bash scripts, implementing dry-run capability is crucial for:

  • Validating command sequences without execution risk
  • Debugging complex scripting logic
  • Providing transparency in automation workflows

Here are three professional-grade methods with varying complexity levels:

1. The Function Wrapper Method

Most maintainable solution for medium-to-large scripts:

#!/bin/bash

dry_run=false

function safe_exec() {
  if [[ $dry_run == true ]]; then
    echo "[DRY-RUN] $@"
  else
    echo "[EXECUTING] $@"
    "$@"
  fi
  return $?
}

# Usage examples:
safe_exec cp -v source.txt destination.txt
safe_exec rm -rf /tmp/expired_cache

# Enable dry-run mode
dry_run=true
safe_exec touch /var/log/app.log

2. Command Prefix Approach

For simpler scripts where you need minimal changes:

#!/bin/bash

DRY_RUN=${1:-false}
CMD_PREFIX=""

[[ "$DRY_RUN" == "true" ]] && CMD_PREFIX="echo"

# Actual commands become:
$CMD_PREFIX mkdir -p /opt/new_directory
$CMD_PREFIX chown appuser:appgroup /opt/new_directory

3. Advanced Trap-Based Solution

For framework-level implementation:

#!/bin/bash

trap 'dry_run_controller "$BASH_COMMAND"' DEBUG

dry_run_controller() {
  local cmd="$1"
  [[ $DRY_RUN ]] && { echo "[DRY] $cmd"; return 1; } || return 0
}

# Normal script commands follow:
mv old_file new_location
rsync -avz source/ destination/

Professional implementations often include:

# Color coding for better visibility
function color_echo() {
  local color=$1
  shift
  echo -e "\e[${color}m$@\e[0m"
}

function dry_exec() {
  if $DRY_RUN; then
    color_echo 33 "DRY: $@"
    return 0
  else
    color_echo 32 "EXEC: $@" 
    eval "$@"
    return $?
  fi
}

Consider these edge scenarios:

# 1. Piped commands
dry_run=false
[[ $dry_run ]] && echo "cat log.txt | grep ERROR" || cat log.txt | grep ERROR

# 2. Variable assignments
dry_run=true
[[ ! $dry_run ]] && actual_value=$(date) || echo "SIMULATED: actual_value=\$(date)"

# 3. Conditional logic
if [[ $dry_run ]]; then
  echo "Would execute: [ $condition ] && action1 || action2"
else
  [ $condition ] && action1 || action2
fi

For brownfield implementations:

#!/bin/bash

# Transform existing script by adding this preamble:
original_script() {
  # Original script content goes here
  mv source dest
  rm -rf /tmp/cache
}

if [[ $1 == "--dry-run" ]]; then
  echo "Dry run of original operations:"
  typeset -f original_script | tail -n +3 | head -n -1
else
  original_script
fi