Zsh Scripting: I/O Redirection Complete Guide
Input/output (I/O) redirection is one of the most powerful features of any Unix shell, and Zsh takes it further with a rich set of operators and extensions. Whether you are writing a quick automation script or a robust CLI tool, mastering redirection lets you control where data comes from, where it goes, and how errors are handled. This guide walks you through everything you need to know about I/O redirection in Zsh scripting.
What Is I/O Redirection?
I/O redirection is the process of changing the default source or destination of a command's data streams. In Zsh, every command runs with three standard streams open by default:
stdin(file descriptor 0): standard input, where the command reads data.stdout(file descriptor 1): standard output, where the command writes normal results.stderr(file descriptor 2): standard error, where the command writes diagnostic and error messages.
Redirection operators let you point these streams at files, devices, other commands, or even in-memory constructs like here-documents. Zsh supports the POSIX redirection syntax and adds several useful extensions such as multios and process substitution refinements.
Why I/O Redirection Matters
Redirection is the glue that makes shell scripts composable and predictable. Without it, every script would have to hardcode file paths, error handling would be inconsistent, and combining tools would be far harder. Key benefits include:
- Separation of concerns: keep normal output and error messages on different channels.
- Reproducibility: capture logs to files for later inspection or auditing.
- Automation: feed input from files or generated content without manual typing.
- Composability: chain commands with pipes and process substitution.
- Robustness: discard unwanted output, prevent file truncation, and avoid noisy terminals.
The Standard Streams in Practice
Before diving into operators, observe the default behavior. The echo command writes to stdout, and a failed command writes to stderr:
#!/usr/bin/env zsh
echo "This goes to stdout"
ls /nonexistent/directory # This prints to stderr
When you run this script, both messages appear on the terminal because the terminal is the default destination for both stdout and stderr. Redirection lets you split them.
Basic Output Redirection
The most common operator is >, which redirects stdout to a file, overwriting it if it exists:
#!/usr/bin/env zsh
echo "First line" > output.txt
echo "Second line" > output.txt # overwrites; output.txt now contains only "Second line"
cat output.txt
To append instead of overwrite, use >>:
#!/usr/bin/env zsh
echo "First line" > log.txt
echo "Second line" >> log.txt
cat log.txt
Basic Input Redirection
The < operator redirects stdin from a file. Many commands accept filenames as arguments, but some tools only read stdin, making this essential:
#!/usr/bin/env zsh
# Read a file through stdin
while read -r line; do
print "Processed: $line"
done < data.txt
You can combine input and output redirection in a single command:
#!/usr/bin/env zsh
tr 'a-z' 'A-Z' < input.txt > upper.txt
Redirecting Standard Error
Each stream has a numeric file descriptor: 0 for stdin, 1 for stdout, 2 for stderr. You can prefix redirection operators with these numbers. To capture only errors:
#!/usr/bin/env zsh
ls /nonexistent/directory 2> errors.log
cat errors.log
To discard stderr entirely, redirect it to /dev/null:
#!/usr/bin/env zsh
find / -name 'config.yaml' 2> /dev/null
Combining stdout and stderr
A frequent requirement is to merge stderr into stdout so both go to the same place. The classic POSIX idiom is 2>&1, which means "redirect fd 2 to wherever fd 1 currently points":
#!/usr/bin/env zsh
# Send both stdout and stderr to the same file
ls /etc /nonexistent > combined.log 2>&1
cat combined.log
Order matters. The shell processes redirections left to right, so 2>&1 > file would send stderr to the old stdout (the terminal), not the file. Always put 2>&1 after the file redirection.
Zsh also supports the shorter &> and &>> operators to redirect both stdout and stderr at once:
#!/usr/bin/env zsh
ls /etc /nonexistent &> both.log
ls /etc /nonexistent &>> both.log # append form
Appending stderr
To append errors to a log file without clobbering previous content:
#!/usr/bin/env zsh
{
echo "Running backup..."
rsync -av /data /backup
} >> backup.log 2>&1
The braces group commands so the redirection applies to the whole block, not just the last command.
Here Documents
A here-document feeds a multi-line string into a command's stdin. The syntax uses << followed by a delimiter:
#!/usr/bin/env zsh
cat <<EOF
Hello, world.
This is a multi-line
here-document.
EOF
If you quote the delimiter, no variable expansion or command substitution occurs. This is useful for templates and code generation:
#!/usr/bin/env zsh
cat <<'EOF'
The variable $HOME will appear literally,
not expanded, because the delimiter is quoted.
EOF
Leading tabs (not spaces) can be stripped by using <<-, which helps keep scripts readable:
#!/usr/bin/env zsh
if true; then
cat <<-EOF
Indented content
with leading tabs removed.
EOF
fi
Here Strings
For single-line input, a here-string is more concise than a here-document. Use <<<:
#!/usr/bin/env zsh
grep "world" <<< "hello world from zsh"
# Variable expansion happens by default
name="Alice"
print "Hi" <<< "$name"
Pipes
A pipe (|) connects one command's stdout to another command's stdin. Pipes are the backbone of shell composition:
#!/usr/bin/env zsh
ps aux | grep zsh | wc -l
By default, only stdout flows through a pipe. To pipe both stdout and stderr, use |&, a Zsh/Bash convenience:
#!/usr/bin/env zsh
ls /etc /nonexistent |& grep -i "permission\|no such"
Process Substitution
Process substitution lets you treat a command's output (or input) as a file. Zsh supports <(cmd) for input and >(cmd) for output:
#!/usr/bin/env zsh
# Compare two command outputs as if they were files
diff <(ls /etc) <(ls /etc_backup)
# Send output to two places at once using tee via process substitution
echo "log entry" > >(tee stdout.log) 2> >(tee stderr.log >&2)
This is invaluable when a command expects filenames but you want to feed it generated data without creating temporary files.
Zsh Multios
Zsh has a feature called multios that lets a single redirection target multiple files. When enabled (it is on by default in interactive shells), you can write:
#!/usr/bin/env zsh
setopt multios
echo "broadcast" > file1.txt > file2.txt > file3.txt
This writes the same output to all three files. If multios is disabled, only the last redirection takes effect. You can also read from multiple files, in which case Zsh concatenates them:
#!/usr/bin/env zsh
setopt multios
cat < file1.txt < file2.txt
The exec Built-in and Permanent Redirections
The exec built-in can open or redirect file descriptors for the rest of the script's lifetime. This is handy for logging:
#!/usr/bin/env zsh
# Redirect all stdout and stderr from this point onward
exec > script.log 2>&1
echo "This goes to the log file"
ls /nonexistent # error also goes to the log
You can also open a file descriptor for reading and close it later:
#!/usr/bin/env zsh
exec 3< config.txt
while read -r line <&3; do
print "Config: $line"
done
exec 3<&- # close fd 3
To restore stdout to the terminal after redirecting it, save a copy first:
#!/usr/bin/env zsh
exec 3>&1 # save stdout to fd 3
exec > quiet.log # redirect stdout to a file
echo "logged"
exec 1>&3 3>&- # restore stdout and close fd 3
echo "back to terminal"
Null Input and the Empty File Trick
To truncate a file to zero bytes without removing it, redirect nothing into it:
#!/usr/bin/env zsh
> empty.txt # creates or truncates empty.txt
ls -l empty.txt
To provide empty input to a command that would otherwise wait on stdin:
#!/usr/bin/env zsh
command_needing_input < /dev/null
Redirecting to and from File Descriptors Explicitly
Zsh supports the full POSIX file descriptor syntax. You can move and duplicate descriptors precisely:
#!/usr/bin/env zsh
# Save stderr, redirect stderr to stdout, run command, restore stderr
exec 5>&2
exec 2>&1
some_noisy_command
exec 2>&5 5>&-
The &- suffix closes a descriptor. This level of control is rarely needed in everyday scripts but is essential for advanced tools and daemons.
Conditional Redirection with noclobber
To prevent accidental overwrites, enable noclobber. Then > will refuse to overwrite an existing file, and you must use >| to force it:
#!/usr/bin/env zsh
setopt noclobber
echo "data" > existing.txt # fails if existing.txt exists
echo "data" >| existing.txt # forces overwrite
This is a simple but effective safeguard in scripts that write important logs or reports.
A Practical Logging Function
Putting it all together, here is a reusable logging function that writes messages at different levels to both the terminal and a log file:
#!/usr/bin/env zsh
LOG_FILE="app.log"
log() {
local level="$1"
shift
local message="$*"
local timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
local entry="[$timestamp] [$level] $message"
case "$level" in
ERROR)
print -u 2 "$entry" | tee -a "$LOG_FILE"
;;
WARN|INFO|DEBUG)
print "$entry" | tee -a "$LOG_FILE"
;;
*)
print "$entry" | tee -a "$LOG_FILE"
;;
esac
}
log INFO "Application started"
log WARN "Disk space low"
log ERROR "Failed to connect to database"
The tee -a command duplicates stdout to both the terminal and the log file, while print -u 2 sends error messages to stderr so downstream pipes can distinguish them.
Best Practices
- Always separate stdout and stderr. Reserve stdout for data and stderr for diagnostics so your scripts can be composed in pipes safely.
- Use
2>&1after file redirections. Remember redirections are processed left to right; ordering mistakes silently misroute errors. - Quote here-document delimiters when you do not want expansion. This prevents subtle bugs in templates and generated code.
- Prefer
&>for clarity when you genuinely want both streams in one file, but document why. - Enable
noclobberin scripts that write important files and use>|only where overwriting is intentional. - Avoid
/dev/nullfor stderr unless you are sure. Silencing errors can hide real failures; log them instead. - Use
execredirections for whole-script logging rather than repeating redirections on every command. - Close file descriptors you open. Leaking descriptors can exhaust limits in long-running scripts.
- Test redirections with
set -xto verify the shell is doing what you expect, especially with complexexecmanipulations. - Be cautious with
multiosin portable scripts. It is a Zsh feature and may surprise users coming from Bash.
Common Pitfalls
- Reversed redirection order:
cmd 2>&1 > filesends stderr to the terminal, not the file. Always writecmd > file 2>&1. - Forgetting to append: using
>in a loop overwrites the file each iteration. Use>>to accumulate output. - Spaces around redirection operators:
cmd > fileis fine, butcmd >filealso works;cmd > =filedoes not mean what you might think in Zsh due to=expansion. Keep it simple. - Assuming pipes carry stderr: they do not by default. Use
|&or2>&1 |when you need errors in the pipeline. - Not closing here-documents: a missing terminator causes the shell to wait for more input, hanging your script.
Conclusion
I/O redirection is the connective tissue of Zsh scripting, turning isolated commands into flexible, composable pipelines. By understanding the three standard streams, mastering operators like >, >>, 2>&1, here-documents, here-strings, process substitution, and exec redirections, you gain precise control over how data flows through your scripts. Pair these techniques with disciplined best practices—separating stdout from stderr, guarding against accidental overwrites, and cleaning up file descriptors—and your Zsh scripts will be more robust, debuggable, and maintainable. Redirection may seem like a small detail, but it is often the difference between a script that merely works and one that scales gracefully in production.