← Back to DevBytes

Zsh Scripting: Debugging Scripts Complete Guide

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:

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.

# 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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles