Zsh Scripting: Debugging Scripts Complete Guide
Zsh (Z shell) is a powerful shell that extends the capabilities of traditional Bash with richer scripting features, advanced globbing, and a more expressive syntax. However, the same features that make Zsh scripts concise can also make them harder to debug when something goes wrong. This guide walks you through everything you need to know about debugging Zsh scripts, from built-in tracing options to advanced techniques and tooling.
What Is Zsh Script Debugging?
Debugging a Zsh script is the process of identifying, isolating, and fixing errors or unexpected behavior in a script written for the Z shell. These errors can range from syntax mistakes and undefined variables to logic errors, race conditions, and unexpected interactions with external commands.
Unlike compiled languages, Zsh scripts are interpreted line by line. This means errors often surface only at runtime, making proactive debugging techniques essential. Zsh provides several native debugging facilities, including execution tracing, verbose output, strict mode options, and trap-based error handling.
Why Debugging Matters
Even a small Zsh script can produce subtle bugs that are difficult to trace. Debugging matters because:
- Reliability: Scripts often run in automation pipelines where failures can cascade.
- Maintainability: Debuggable scripts are easier to read, extend, and hand off to other developers.
- Security: Unhandled errors can expose sensitive data or leave systems in inconsistent states.
- Performance: Debugging helps identify slow commands, redundant calls, and inefficient loops.
- Confidence: A well-tested and debugged script reduces anxiety when deploying to production environments.
Enabling Debugging Options
Zsh exposes several options that change how the shell executes and reports script activity. These options can be set on the command line or inside the script using setopt.
The -x Trace Option
The -x option (also known as xtrace) prints each command and its arguments after expansion but before execution. This is the most common debugging tool in Zsh.
#!/usr/bin/env zsh
setopt xtrace
name="World"
echo "Hello, $name"
When run, the output will include each command prefixed with + (or another trace prompt character), showing exactly what the shell executes.
The -v Verbose Option
The -v option prints each line of input as it is read, before any expansion. This is useful for catching issues in control flow and here-documents.
#!/usr/bin/env zsh
setopt verbose
for i in {1..3}; do
echo "Iteration $i"
done
Combining -x and -v
Using both options together gives you a complete picture: the raw input line followed by the expanded command.
#!/usr/bin/env zsh
setopt xtrace verbose
greeting="Hello"
target="Zsh"
echo "$greeting, $target!"
Customizing the Trace Prompt
By default, Zsh prefixes traced lines with +. You can customize this with the PS4 variable, which is especially useful for adding line numbers or script names.
#!/usr/bin/env zsh
# Show script name and line number in trace output
PS4='+ ${0##*/}:${LINENO}: '
setopt xtrace
echo "Starting process"
for file in *.txt; do
echo "Processing $file"
done
This produces output like + script.zsh:7: echo 'Starting process', making it trivial to locate the exact line being executed.
Using Strict Mode in Zsh
Strict mode helps catch common mistakes early by making the shell fail fast on errors or undefined variables. While Bash has a well-known strict mode pattern, Zsh has its own equivalents.
Failing on Errors
The err_exit option causes the script to exit immediately if any command returns a non-zero status.
#!/usr/bin/env zsh
setopt err_exit
echo "Before failure"
false # This command fails
echo "After failure" # This line will not run
Failing on Undefined Variables
The unset option causes an error when an unset parameter is referenced. Combine it with err_exit to halt execution.
#!/usr/bin/env zsh
setopt err_exit unset
echo "Value is $undefined_var"
Failing on Pipe Failures
By default, the exit status of a pipeline is the status of the last command. The pipe_fail option makes the pipeline fail if any command in it fails.
#!/usr/bin/env zsh
setopt err_exit pipe_fail
false | true # Pipeline fails because of `false`
Debugging with Traps
Zsh supports traps, which let you execute code when certain signals or conditions occur. The DEBUG trap runs before every command, and the ERR trap runs when a command fails.
The DEBUG Trap
#!/usr/bin/env zsh
trap 'echo "About to run: $BASH_COMMAND" >&2' DEBUG
echo "Step one"
echo "Step two"
echo "Step three"
trap - DEBUG # Disable the trap
Note: In Zsh, $BASH_COMMAND is not available. Instead, use $ZSH_DEBUG_CMD or rely on xtrace for command-level tracing.
The ERR Trap
#!/usr/bin/env zsh
setopt err_return
trap 'echo "Error on line $LINENO" >&2' ERR
echo "Before error"
false
echo "After error"
Logging and Output Redirection
For scripts that run unattended, logging is critical. Redirect trace output to a log file while keeping normal output on the console.
#!/usr/bin/env zsh
LOGFILE="/tmp/script_debug.log"
# Send trace output to the log file
exec 2>> "$LOGFILE"
setopt xtrace
echo "This goes to stdout"
false
echo "More output"
You can also split stderr and stdout into separate files for cleaner analysis:
#!/usr/bin/env zsh
exec 1>> /tmp/script_stdout.log
exec 2>> /tmp/script_stderr.log
setopt xtrace
echo "Normal output"
echo "Error output" >&2
Interactive Debugging with zsh -i
Sometimes the fastest way to debug is to load the script in an interactive Zsh session. You can source the script and inspect variables and functions manually.
$ zsh -i
% source ./my_script.zsh
% print $my_variable
% which my_function
This approach lets you experiment with functions and variables defined in the script without re-running the entire file.
Using print and echo for Inspection
Strategic print statements remain one of the most effective debugging techniques. Zsh's print builtin offers more formatting options than echo.
#!/usr/bin/env zsh
process_data() {
local input=$1
print -P "%F{yellow}DEBUG:%f input = $input" >&2
# ... processing logic ...
print -P "%F{green}DEBUG:%f result = $result" >&2
}
process_data "sample"
The -P flag enables prompt expansion, allowing you to use color codes for clearer debug output.
Debugging Functions
When a script is split into functions, you can enable tracing only for specific functions to reduce noise.
#!/usr/bin/env zsh
helper() {
echo "Helper called with: $@"
}
main() {
helper "alpha"
helper "beta"
}
# Enable tracing only inside helper
functions[helper]="${functions[helper]/#/setopt xtrace\n}"
functions[helper]="${functions[helper]%\$*}\nunsetopt xtrace"
main
Alternatively, you can wrap a function call with setopt xtrace and unsetopt xtrace for a simpler approach:
setopt xtrace
helper "debug me"
unsetopt xtrace
Handling Arrays and Quoting Issues
Zsh arrays and word splitting behave differently from Bash, which is a common source of bugs. Use print -l to inspect array elements line by line.
#!/usr/bin/env zsh
files=( *.txt )
print -l $files
# Check the number of elements
print "Array length: ${#files}"
If you suspect quoting issues, enable rc_quotes or inspect expansions carefully:
#!/usr/bin/env zsh
setopt xtrace
args=( "one two" "three" )
for arg in $args; do
print "Arg: $arg"
done
Debugging Globbing Problems
Zsh's globbing is more powerful than Bash's, but this can lead to unexpected matches. The null_glob option prevents errors when a pattern matches nothing, while no_nomatch leaves the pattern unchanged instead of erroring.
#!/usr/bin/env zsh
setopt null_glob
for f in *.nonexistent; do
print "Found: $f"
done
print "Globbing completed without error"
Use setopt xtrace alongside globbing to see exactly what patterns expand to.
Using External Tools
Beyond Zsh's built-in features, several external tools can assist with debugging.
- ShellCheck: A static analysis tool that catches common shell scripting mistakes. While primarily Bash-focused, it flags many issues applicable to Zsh.
- strace: Traces system calls, useful for diagnosing file or permission issues.
- bat: A
catclone with syntax highlighting, helpful for reviewing scripts. zsh -n script.zsh: Performs a syntax check without executing the script.
# Syntax check without execution
zsh -n my_script.zsh
# Run ShellCheck (if installed)
shellcheck --shell=bash my_script.zsh
Best Practices for Debuggable Zsh Scripts
- Use
setopt err_exit pipe_fail unsetat the top of scripts to catch errors early. - Declare variables with
localinside functions to avoid polluting the global namespace. - Quote all variable expansions to prevent word splitting and globbing surprises.
- Use meaningful variable names and add comments explaining non-obvious logic.
- Break scripts into small functions that can be tested independently.
- Log to files for scripts that run in cron jobs or CI pipelines.
- Customize
PS4with line numbers when tracing complex scripts. - Test edge cases such as empty inputs, missing files, and special characters in filenames.
- Avoid relying on Bash-isms unless you explicitly enable
sh_word_splitoremulate sh. - Version control your scripts so you can diff changes when a bug appears.
Putting It All Together: A Debuggable Script Template
#!/usr/bin/env zsh
# Strict mode
setopt err_exit pipe_fail unset
# Debugging configuration (uncomment to enable)
# PS4='+ ${0##*/}:${LINENO}: '
# setopt xtrace
# Log file setup
LOGFILE="${LOGFILE:-/tmp/$(basename $0).log}"
exec 2>> "$LOGFILE"
# Helper function for debug output
debug() {
print -P "%F{cyan}[DEBUG]%f $*" >&2
}
# Main logic
main() {
debug "Starting $0 with args: $@"
local input_file=$1
if [[ ! -f "$input_file" ]]; then
print "Error: $input_file not found" >&2
return 1
fi
local lines=( "${(@f)$(< $input_file)}" )
debug "Read ${#lines} lines"
for line in $lines; do
print "Processed: $line"
done
debug "Completed successfully"
}
main "$@"
This template combines strict mode, optional tracing, logging, and a debug helper function. It provides a solid foundation that you can adapt for any Zsh script.
Conclusion
Debugging Zsh scripts effectively requires a combination of built-in shell options, disciplined coding practices, and strategic use of logging and inspection tools. By leveraging xtrace, strict mode, traps, and thoughtful function design, you can quickly pinpoint and resolve issues in even the most complex scripts. The key is to build debugging into your scripts from the start rather than treating it as an afterthought. With the techniques and best practices covered in this guide, you are well-equipped to write robust, maintainable, and debuggable Zsh scripts that behave predictably in any environment.