Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 33

Robust Error Handling in Scripts

Recommended resources

Introduction

Hello! In our last lesson, you built a flexible cleanup_advanced.sh script that could be configured from the command line using getopts. This was a major step towards creating professional, reusable tools. However, what happens if a user provides an invalid path for the archive directory, or if a command within the script fails unexpectedly? Currently, the script might stop with a cryptic error or, worse, fail silently, leaving you thinking the job was done when it wasn't.

This lesson directly addresses that problem. Today, we'll learn how to handle errors and exit codes to make your scripts robust. A robust script is one that behaves predictably, reports failures clearly, and prevents unintended consequences. For your goal of becoming a Full Stack Developer, this is non-negotiable. When you deploy a Django application, you need your automation scripts to be completely reliable; you must know immediately and clearly if a step like a database migration or a file copy has failed.

By the end of this lesson, you will be able to write scripts that can anticipate problems and stop safely, making them trustworthy enough for real-world system administration and deployment tasks.


1. The Language of Commands: Exit Codes

Every command you run in the terminal, whether it succeeds or fails, reports its status back to the shell using a number called an exit code. By convention:

  • An exit code of 0 means the command was successful.
  • Any non-zero exit code (1, 2, 127, etc.) means the command failed.

Bash stores the exit code of the most recently executed command in a special variable: $?.

Let's watch a short video that explains this fundamental concept.

How To Write Bash Scripts In Linux - Complete Guide (Part 6 - Exit Codes)

This segment from the "How To Write Bash Scripts In Linux" series by Learn Linux TV clearly explains what exit codes are and demonstrates how to view them using echo $?.

Watch from the beginning to 04:07. Focus on: The difference between human-readable errors and the exit code. How the $? variable is used to see the exit code of the last command. The core rule: 0 is success, and anything else is failure.

To see this in action on your own system, open your terminal and try these commands:




# This command should succeed
ls /etc/os-release
echo $?



# Expected output: 0




# This command should fail
ls /nonexistent/directory
echo $?



# Expected output: A non-zero number, like 2
Demonstrating Bash Script Exit Codes
This image illustrates checking exit codes. The `date` command succeeds and returns `0`. The `cp` command fails because of a missing argument and returns `1`, clearly signaling an error.

This mechanism is the foundation of error handling in shell scripting. An automated script, like a backup or deployment script running overnight, can't "see" an error message on the screen. But it can check the exit code to know with certainty whether a critical step succeeded or failed.


2. Taking Action: Checking Exit Codes and Exiting

Knowing an error occurred is the first step. The next is deciding what to do about it. You can use if statements to check the value of $? and control your script's flow. However, there's a critical rule: you must check $? immediately after the command you're interested in. Any other command, even a simple echo, will run successfully and overwrite $? with 0, masking the original error.

You can also use the exit command to immediately terminate your script and return a specific exit code. This is useful for signaling to other scripts or monitoring tools that your script has failed.

This video demonstrates both checking $? in an if block and using the exit command. Pay close attention to the common pitfall explained partway through.

How To Write Bash Scripts In Linux - Complete Guide (Part 6 - Exit Codes)

Let's continue with the Learn Linux TV video. This part shows how to use if statements to handle different outcomes and highlights the critical mistake of not checking $? immediately.

Watch the segments from 05:30 to 11:16 and from 14:43 to 20:40. Focus on: (05:30 - 11:16) How an if statement can check if $? is equal to 0 to run different blocks of code. (14:43 - 17:00) This part is crucial. Understand why an echo statement between a failed command and checking $? can hide the error. (17:00 - 20:40) How the exit command gives you explicit control to stop the script and set its final exit code.

A more direct way to check a command's success in an if statement is to put the command itself in the condition. Bash treats a 0 exit code as "true" and a non-zero exit code as "false".

if cp source.txt destination.txt; then
    echo "File copied successfully."
else
    echo "Error: Could not copy the file." >&2 # Redirect error message to stderr
    exit 1 # Exit the script with a failure code
fi

This is cleaner and avoids the $? pitfall.


3. Fail Fast: Automating Error Handling with set

Checking every single command with an if statement can make your scripts long and cluttered. For many scripts, a simpler and safer approach is to adopt a "fail fast" policy: if any command fails, the script should stop immediately.

You can configure this behavior using the set command at the top of your script. This is often called "strict mode".

set -e
set -u
set -o pipefail

Let's watch a video that explains what each of these options does.

bash "strict mode": -euo pipefail (beginner - intermediate) anthony explains #201

This video from anthonywritescode provides a fast-paced and clear explanation of the three most important set options for writing safe Bash scripts.

Watch from 03:07 to 09:27. The video covers the options one by one: set -e (or set -o errexit): Exits the script immediately if any command fails (returns a non-zero exit code). set -u (or set -o nounset): Treats using an undefined variable as an error and exits. This helps catch typos in variable names. set -o pipefail: Makes a pipeline's exit code the code of the rightmost command to exit with a non-zero status, instead of just the last command. This prevents errors from being hidden inside a pipeline.

Understanding `set -o pipefail` with a Failing Command
This image shows `set -o pipefail` in action. The `Echo` command is misspelled and fails, but `wc -w` succeeds. Without `pipefail`, the pipeline's exit code would be `0` (from `wc -w`). With `pipefail`, the exit code is `127`, correctly reflecting the failure of the `Echo` command.

A common best practice is to start every script with set -euo pipefail. This single line dramatically increases the safety and reliability of your scripts. You can read a quick summary of these essential techniques in the following article.

Bulletproof Bash Scripts: Mastering Error Handling

This article, "Bulletproof Bash Scripts," reinforces the 'fail fast' principle. We'll just focus on the first part that summarizes the set options and introduces the trap command, which we'll use in our exercise.

Read the section 'Essential Bash Error Handling Techniques Every Developer Should Know'. Focus on the three subsections: 'Use set options to fail fast', 'Check exit codes explicitly', and 'Use trap to handle script termination'. This will give you a solid textual reference for these concepts.


Do-It-Yourself: Creating a Robust Cleanup Script

It's time to apply these techniques to make your cleanup_advanced.sh script truly robust. You will create a new script, cleanup_robust.sh, that can handle errors gracefully.

Your Goal: Modify the script so it stops immediately if something goes wrong, like being unable to create a directory, and that it performs a cleanup action on exit.

Instructions:

  1. Create the new script:
    In your ~/automation_practice directory, copy your previous script:

    cp cleanup_advanced.sh cleanup_robust.sh
    
  2. Enable "Strict Mode":
    At the top of cleanup_robust.sh, right after the #!/usr/bin/env bash line, add the following:

    set -euo pipefail
    
  3. Add a trap for Graceful Exits:
    A trap catches a signal (like EXIT or INT for Ctrl+C) and runs a command. This is perfect for cleanup. Add a simple trap function and register it.

    
    
    
    # Place this function definition near the top with your other functions
    cleanup() {
        echo "Cleanup script finished or was interrupted. Exiting."
    }
    
    
    
    
    # Place this command right after the set -euo pipefail line
    trap cleanup EXIT
    

    Now, no matter how your script exits (success, error, or Ctrl+C), it will print this final message.

  4. Make setup_archive_dir Robust:
    Your current setup_archive_dir function doesn't check if mkdir succeeded. With set -e, the script would exit if mkdir failed, but the user wouldn't know why. Let's add an explicit check for a clearer error message.
    Modify the function to look like this:

    setup_archive_dir() {
        local path="$1"
        if [[ ! -d "$path" ]]; then
            echo "Archive directory '$path' not found, creating it."
    
    
    
            # The -p option is still useful, as it creates parent directories
            if ! mkdir -p "$path"; then
                echo "Error: Failed to create archive directory '$path'." >&2
    
    
    
                # With 'set -e', the script would exit here anyway,
                # but an explicit 'exit 1' makes the intent clear.
                exit 1
            fi
        fi
    }
    
  5. Test Your Robust Script:
    First, let's create a situation where mkdir will fail. We'll create a file named archive, so the script can't create a directory with the same name.

    cd ~/automation_practice
    
    
    
    # Clean up old dirs and create a file to cause an error
    rm -rf archive backups
    touch archive
    
    
    
    
    # Run the script. It should now fail with your clear error message.
    ./cleanup_robust.sh "*.log" "*.tmp"
    

    You should see the "Failed to create archive directory" message, followed by the "Exiting" message from your trap.

    Now, test the successful path:

    
    
    
    # Clean up and create fresh test files
    rm -rf archive
    touch app-1.log app-2.log data.csv
    
    
    
    
    # Run the script. It should work as before.
    ./cleanup_robust.sh "*.log"
    ls archive # Should contain the log files
    

    You will see the "Exiting" message from trap at the end, confirming it runs on successful completion too.

Click here to see the complete `cleanup_robust.sh` script.



#!/usr/bin/env bash

set -euo pipefail




# --- Trap for cleanup on exit ---
trap cleanup EXIT




# --- Functions ---
cleanup() {



    # This function is called when the script exits.
    echo "Cleanup script finished or was interrupted. Exiting."
}

setup_archive_dir() {
    local path="$1"
    if [[ ! -d "$path" ]]; then
        echo "Archive directory '$path' not found, creating it."
        if ! mkdir -p "$path"; then
            echo "Error: Failed to create archive directory '$path'." >&2
            exit 1
        fi
    fi
}

archive_files() {
    local pattern="$1"
    local archive_path="$2"
    local files_found=0




    # Use find to locate files and check if any were found
    while IFS= read -r file; do
        if [[ -f "$file" ]]; then
            echo "Archiving '$file'..."
            cp "$file" "$archive_path/"
            rm "$file"
            files_found=1
        fi
    done < <(find . -maxdepth 1 -type f -name "$pattern")

    if [[ "$files_found" -eq 0 ]]; then
        echo "No files found matching pattern: $pattern"
    fi
}

usage() {
    echo "Usage: $0 [-a ARCHIVE_DIR] [-p PATTERN] [PATTERN...]"
    echo "  -a ARCHIVE_DIR   Specify the archive directory (default: archive)."
    echo "  -p PATTERN       Specify a file pattern to archive (can be used multiple times)."
    echo "  -h               Display this help message."
    echo "Any additional arguments are also treated as patterns."
}




# --- Default values ---
ARCHIVE_DIR="archive"
PATTERNS_TO_ARCHIVE=()




# --- Parse Options ---
while getopts "a:p:h" opt; do
    case ${opt} in
        a)
            ARCHIVE_DIR="$OPTARG"
            ;;
        p)
            PATTERNS_TO_ARCHIVE+=("$OPTARG")
            ;;
        h)
            usage
            exit 0
            ;;
        \?)
            echo "Invalid Option: -$OPTARG" 1>&2
            usage
            exit 1
            ;;
    esac
done




# --- Handle Remaining Arguments ---
shift $((OPTIND - 1))

if [[ $# -gt 0 ]]; then
    PATTERNS_TO_ARCHIVE+=("$@")
fi




# --- Validation ---
if [[ ${#PATTERNS_TO_ARCHIVE[@]} -eq 0 ]]; then
    echo "Error: No file patterns specified." >&2
    usage
    exit 1
fi




# --- Main Logic ---
TARGET_DIR="."
FULL_ARCHIVE_PATH="$TARGET_DIR/$ARCHIVE_DIR"

setup_archive_dir "$FULL_ARCHIVE_PATH"

for pattern in "${PATTERNS_TO_ARCHIVE[@]}"; do
    archive_files "$pattern" "$FULL_ARCHIVE_PATH"
done

echo "Cleanup complete."

Conclusion

Fantastic work today! You have taken a huge leap forward in writing professional-grade Bash scripts. By understanding and handling errors, you've transformed your script from a simple tool into a reliable piece of automation that you can trust.

Key Takeaways:

  • An exit code of 0 means success; any non-zero value means failure. Check the last exit code with $?.
  • You can check exit codes with if statements for fine-grained control or use the exit command to terminate the script.
  • The "fail fast" method using set -euo pipefail is the recommended best practice for making scripts automatically robust.
  • The trap command is a powerful tool for ensuring cleanup actions (like removing temporary files) run no matter how a script exits.

In our next lesson, we will put everything you've learned so far together. We'll write a practical automation script to back up a specified project directory. This script will use functions, parse command-line arguments, and incorporate the robust error handling you learned today to create a tool suitable for real-world use.