← Back to DevBytes

Zsh Scripting: I/O Redirection Complete Guide

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:

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:

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

Common Pitfalls

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles