set -x isn’t just for debugging; it’s your Zsh script’s internal monologue, revealing the exact sequence of commands and their arguments as Zsh executes them.

Let’s see it in action. Imagine a simple script to create a temporary directory and a file inside it:

#!/bin/zsh

create_temp_stuff() {
  local dir_name="my_temp_$(date +%Y%m%d%H%M%S)"
  local file_name="$dir_name/data.txt"

  mkdir "$dir_name"
  echo "Hello, Zsh!" > "$file_name"
  echo "Created: $file_name"
}

create_temp_stuff

Now, let’s run this with set -x. The xtrace output will show us exactly what’s happening:

$ zsh -x myscript.zsh
+create_temp_stuff
+local dir_name=my_temp_20231027103000
+local file_name=my_temp_20231027103000/data.txt
+mkdir my_temp_20231027103000
+echo 'Hello, Zsh!'
+echo 'Created: my_temp_20231027103000/data.txt'
Created: my_temp_20231027103000/data.txt

Notice the + prefix? That’s Zsh’s way of showing you each command after variable expansion and other substitutions, right before it’s executed. This is invaluable for pinpointing where a script deviates from your expectation. You can toggle set -x on and off within a script to narrow down debugging to specific sections.

#!/bin/zsh

echo "Starting script..."
set -x # Turn on tracing
# ... potentially complex code ...
if [[ -z "$SOME_VAR" ]]; then
  echo "Variable is empty!"
fi
set +x # Turn off tracing
echo "Finished section."

The typeset command is Zsh’s powerful way to declare and manipulate variables. Beyond just assigning a value, typeset lets you enforce types, set attributes, and control behavior. For instance, typeset -i count=0 declares count as an integer and initializes it to 0. If you later try count="hello", Zsh will raise an error, preventing type-related bugs.

$ zsh -c 'typeset -i num; num="abc"; echo $num'
zsh: number expected: abc

typeset -r readonly_var="fixed" makes readonly_var immutable. typeset -a array_var declares an array. The real power comes when you combine these with other options, like typeset -A associative_array.

$ zsh -c 'typeset -A config; config[host]="localhost"; config[port]=8080; print ${config[host]}'
localhost

Traps, on the other hand, are Zsh’s built-in signal handlers. They allow your script to react to specific events, most commonly signals like EXIT, INT (Ctrl+C), ERR (command failure), or DEBUG. The trap command registers a command to be executed when a signal is received.

#!/bin/zsh

cleanup() {
  echo "Performing cleanup..."
  rm -f /tmp/my_temp_file.tmp
}

trap cleanup EXIT # Execute cleanup when the script exits (normally or abnormally)
trap 'echo "Interrupted!"' INT # Execute on SIGINT

echo "Creating temporary file..."
touch /tmp/my_temp_file.tmp
echo "File created. Press Ctrl+C to interrupt, or let it finish."

# Simulate some work
sleep 5

echo "Script finished."

Running this script will demonstrate the traps. If you press Ctrl+C, you’ll see "Interrupted!". If you let it run to completion, you’ll see "Performing cleanup…".

The ERR trap is particularly useful for debugging. trap 'echo "Command failed on line $LINENO"' ERR will print a message with the line number whenever a command exits with a non-zero status. When combined with set -e (which causes the script to exit immediately if any command fails), it provides a robust error-handling mechanism.

#!/bin/zsh

set -e # Exit immediately if a command exits with a non-zero status.
trap 'echo "Error on line $LINENO: $BASH_COMMAND"' ERR # $BASH_COMMAND (works in zsh too!) shows the failing command

echo "This will succeed."
ls /nonexistent_directory # This will fail
echo "This will not be printed."

When this script runs, you’ll see:

This will succeed.
Error on line 7: ls /nonexistent_directory

The DEBUG trap executes before every command. While powerful, it can be very noisy, generating output for every single command, including internal shell operations. It’s best used for highly targeted debugging when set -x isn’t granular enough.

One subtle point about set -x is its interaction with command substitution. When a command substitution occurs, set -x will show the command inside the substitution, but the output of that substitution is then treated as a single "argument" by the outer command.

$ set -x
$+echo "Hello $(date +%Y-%m-%d)"
+date +%Y-%m-%d
+echo 'Hello 2023-10-27'
Hello 2023-10-27
$ set +x

Here, set -x shows date +%Y-%m-%d being executed, and then it shows echo being called with the result of that command substitution as a single argument. This can sometimes obscure the flow if you’re not expecting it.

The typeset command’s ability to enforce variable types is a proactive debugging tool. By declaring variables as integers (-i), arrays (-a), or associative arrays (-A), you catch type mismatches early, rather than chasing down subtle bugs later.

A common pitfall with traps is their scope. A trap command set within a function only applies to that function’s execution unless explicitly inherited or reset. If you need a trap to apply globally, set it at the top level of your script.

Finally, remember that set -x can be enabled for specific commands using bash -x or zsh -x when invoking a script, or by placing set -x and set +x directly within the script.

The next logical step after mastering these debugging tools is understanding Zsh’s advanced parameter expansion and globbing capabilities.

Want structured learning?

Take the full Zsh course →