Bash scripts can be surprisingly tricky to debug, especially when they start acting in ways you don’t expect. The most surprising thing about set -x and set -e is that they actually make debugging easier by forcing you to confront exactly what’s happening, even if it’s a mess.

Let’s see them in action. Imagine a simple script that tries to create a directory and then write a file into it.

#!/bin/bash

# Script to create a directory and a file

NEW_DIR="/tmp/my_test_dir_$(date +%s)"
NEW_FILE="$NEW_DIR/output.txt"

echo "Creating directory: $NEW_DIR"
mkdir "$NEW_DIR"

echo "Writing to file: $NEW_FILE"
echo "Hello, world!" > "$NEW_FILE"

echo "Done."

This script looks straightforward. But what if something goes wrong? Let’s introduce a problem: what if mkdir fails because the directory already exists (unlikely with the timestamp, but for demonstration)? Or what if the user doesn’t have permissions to write in /tmp?

Now, let’s add set -x and set -e to the top of our script:

#!/bin/bash
set -x
set -e

# Script to create a directory and a file

NEW_DIR="/tmp/my_test_dir_$(date +%s)"
NEW_FILE="$NEW_DIR/output.txt"

echo "Creating directory: $NEW_DIR"
mkdir "$NEW_DIR"

echo "Writing to file: $NEW_FILE"
echo "Hello, world!" > "$NEW_FILE"

echo "Done."

When you run this modified script, you’ll see output like this:

+ NEW_DIR=/tmp/my_test_dir_1678886400
+ NEW_FILE=/tmp/my_test_dir_1678886400/output.txt
+ echo 'Creating directory: /tmp/my_test_dir_1678886400'
Creating directory: /tmp/my_test_dir_1678886400
+ mkdir /tmp/my_test_dir_1678886400
+ echo 'Writing to file: /tmp/my_test_dir_1678886400/output.txt'
Writing to file: /tmp/my_test_dir_1678886400/output.txt
+ echo 'Hello, world!'
+ NEW_FILE=/tmp/my_test_dir_1678886400/output.txt
+ echo "Done."
Done.

Notice the + at the beginning of each line. That’s set -x (or xtrace) showing you exactly what command is being executed, with all variables expanded. It’s like a step-by-step trace of your script’s execution.

set -e (or errexit) is the silent partner here. It means your script will exit immediately if any command fails (returns a non-zero exit status). In the output above, you don’t see set -e directly, but you would see the script stop if, say, mkdir failed for some reason. Without set -e, a failed mkdir would just let the script continue, and the echo "Hello, world!" > "$NEW_FILE" command would likely fail too, possibly with a cryptic "No such file or directory" error, and you’d be left wondering which step was the real culprit.

The problem set -x and set -e solve is the "black box" effect of scripts. You write them, you run them, and sometimes they just don’t work. set -x peels back the curtain, showing you the actual commands Bash is running, including how it interprets variables and arguments. set -e ensures that you don’t stumble through a series of cascading failures; it stops you at the first sign of trouble, which is usually the root cause.

To build your mental model:

  • set -x: "Show me everything Bash is doing, verbatim." This is your debugger’s "step into" or "trace" functionality. It prints each command to stderr before it’s executed, with variable substitutions performed.
  • set -e: "If anything goes wrong, stop immediately." This prevents silent failures. A command that exits with a non-zero status will cause the script to terminate. This is crucial because often, a single failed command will lead to subsequent commands failing in ways that are harder to diagnose.

Let’s consider a more complex scenario. Suppose you’re processing files and want to rename them.

#!/bin/bash
set -x
set -e

for file in *.txt; do
  if [ -f "$file" ]; then
    NEW_NAME="${file%.txt}_processed.txt"
    echo "Processing $file to $NEW_NAME"
    mv "$file" "$NEW_NAME"
  fi
done

echo "All files processed."

If you run this and there are no .txt files, set -e might cause the script to exit if the for loop itself or the if condition is interpreted as a command that failed (this behavior can be subtle and depend on shell options and syntax). More commonly, if mv fails (e.g., permissions, destination already exists and is not a directory), the script will stop right there. With set -x, you’ll see exactly which file variable was being processed and the exact mv command that failed.

For example, if mv "myfile.txt" "myfile_processed.txt" fails because myfile_processed.txt already exists and is not a directory, the output might look like:

+ file=myfile.txt
+ [ -f myfile.txt ]
+ NEW_NAME=myfile_processed.txt
+ echo 'Processing myfile.txt to myfile_processed.txt'
Processing myfile.txt to myfile_processed.txt
+ mv myfile.txt myfile_processed.txt
mv: cannot move 'myfile.txt' to 'myfile_processed.txt': Device or resource busy

The script would then exit because of set -e. Without set -x, you’d just see the mv error. With set -x, you see the context: which file was being processed and the exact command.

A common pitfall with set -e is commands that are expected to fail, like grep when searching for a pattern that might not exist. If grep 'pattern' file.txt doesn’t find 'pattern', it exits with status 1, and set -e would terminate the script. To handle this, you can use || true to ensure the command always returns a zero exit status:

set -x
set -e

# This grep might fail if 'pattern' isn't found
grep 'pattern' file.txt || true

# If grep found something, it exited 0. If not, it exited 1,
# but '|| true' made the whole 'grep ... || true' command exit 0.
# The script continues.
echo "Grep finished."

The most powerful aspect of set -x is that it’s not just for debugging errors. It’s also for debugging logic. You might think your variables are set correctly, or that a conditional is evaluating as you expect, but set -x will show you the exact strings Bash is working with. This reveals subtle issues like trailing whitespace, incorrect quoting, or unexpected variable expansions that can completely derail your script’s logic without causing an explicit error.

The next concept you’ll likely run into is managing different exit statuses for specific commands without disabling set -e entirely.

Want structured learning?

Take the full Bash course →