I/O and Pipelines#

Three byte streams (stdin, stdout, stderr) are the universal interface every Unix program agrees on. Zsh’s job is to wire them up. Redirection points one stream at a file or another file descriptor. MULTIOS is zsh’s own twist that lets one redirection fan out to many destinations. Pipes chain programs together. Process substitution (<(cmd) and zsh-only =(cmd)) turns a command’s output into a path. Named pipes persist a FIFO on disk.

This page covers the wiring layer. For the language-level reading and writing (read, print, printf, positional parameters), see Overview.

Streams#

Every process the kernel hands the operator inherits three open file descriptors.

FD

Name

Used for

0

stdin

Input the program reads

1

stdout

Normal output (the result)

2

stderr

Diagnostics, warnings, errors (a separate channel so pipelines don’t eat them)

        flowchart LR
  IN[(stdin · fd 0)] --> P[process]
  P --> OUT[(stdout · fd 1)]
  P --> ERR[(stderr · fd 2)]
    

stdout is for the answer; stderr is for the commentary. The discipline matters because pipelines consume stdout; a script that prints “log: starting” to stdout pollutes whatever reads it.

$ print -- "answer"            # to stdout
$ print -u2 -- "log: starting" # to stderr (zsh's idiom for >&2)

Redirection#

Redirection rewires a file descriptor for one command before the command runs. Spaces around the operator are optional.

Operator

Effect

> file

Write stdout to file, truncating

>> file

Append stdout

< file

Read stdin from file

2> file

Write stderr to file, truncating

2>> file

Append stderr

2>&1

Merge stderr into stdout

>&2

Write stdout to stderr

&> file

Both streams to file

&>> file

Both streams, appending

< /dev/null

Disconnect stdin

> /dev/null

Discard stdout

2> /dev/null

Discard stderr (use sparingly)

n> file / n< file

Redirect arbitrary fd n

n>&m

Point fd n at the same target as fd m

n>&- / n<&-

Close fd n

<<< "text"

Here-string

<<EOFEOF

Heredoc

Order matters. cmd > all.log 2>&1 sends both streams to all.log. cmd 2>&1 > all.log sends stderr to the terminal and only stdout to the file, because zsh processes redirections left to right and 2>&1 is evaluated before > all.log rebinds fd 1.

$ cmd > all.log 2>&1        # both streams → all.log
$ cmd 2>&1 > all.log        # stderr → terminal, stdout → all.log
$ cmd &> all.log            # shorthand for the first form

MULTIOS#

Zsh’s MULTIOS option (on by default) makes one redirection fan out to many destinations. Repeat > to tee, repeat < to concatenate inputs. Cleaner than a real tee call when the operator just wants two copies of the output.

$ print -- "hello" > a.txt > b.txt        # writes both files
$ cat < a.txt < b.txt                     # concatenates both inputs

The behavior surprises operators coming from bash. unsetopt MULTIOS reverts to one-target redirection.

exec and persistent redirection#

exec without a command applies a redirection to the rest of the shell or script. Useful at the top of a script for logging.

$ exec 3< input.txt              # open fd 3 for reading
$ read -r first <&3              # read one line from fd 3
$ exec 3<&-                       # close fd 3

$ exec >script.log 2>&1           # log the rest of the script

Heredocs#

A heredoc uses the next lines of the script as the command’s stdin, up to a delimiter line. Variables and command substitution expand by default; quote the delimiter to turn that off.

$ cat <<EOF
  $ user is $USER
  $ today is $(date +%F)
$ EOF
user is operator
today is 2026-05-15

Quote the delimiter (<<'EOF') for a literal, unexpanded body.

$ cat <<'EOF' > /etc/cron.d/backup
  $ # variables left literal
  $ 0 3 * * * root /usr/local/bin/backup --target=$HOME
$ EOF

<<-EOF strips leading tabs from every line so the heredoc can be indented with the surrounding code.

Here-string (<<<) is the one-line cousin.

$ wc -w <<< "the operator has the conch"
$ grep -E '^[0-9]+$' <<< "$candidate"

Pipes#

A pipeline chains commands so the stdout of each stage feeds the stdin of the next. Zsh sets up the kernel pipes, forks one process per stage, and waits for all of them. By default the pipeline’s exit code is the last stage; setopt PIPE_FAIL makes it the rightmost non-zero exit.

$ ps -ef | grep sshd | grep -v grep
        flowchart LR
  A[ps -ef] -->|stdout → stdin| B[grep sshd]
  B -->|stdout → stdin| C[grep -v grep]
  C --> OUT[(terminal stdout)]
    

Exit code of a pipeline. $? is the last stage’s exit code. $pipestatus (1-based array) holds every stage’s exit code. setopt PIPE_FAIL rewrites $? so silent failures stop hiding.

$ false | true; echo "$?"           # 0 — last stage masked the failure
$ false | true; echo "$pipestatus"  # 1 0

$ setopt pipe_fail
$ false | true; echo "$?"           # 1

The subshell trap that goes away. In bash, each stage of a pipeline runs in its own subshell, so loop variables vanish. In zsh, the last stage runs in the current shell, so this works out of the box:

$ count=0
$ printf 'a\nb\nc\n' | while read -r _; do
  $ (( count++ ))
$ done
$ echo "$count"                     # 3

The earlier stages still run in subshells, so mutate state only on the right end of the pipe.

Merge stderr into the pipeline. |& is zsh / bash shorthand for 2>&1 |.

$ build.sh |& tee build.log         # stdout + stderr captured

Process Substitution#

Process substitution turns a command’s output (or input) into a path. Zsh ships two flavors. <(cmd) and >(cmd) are the same as bash, backed by /dev/fd/N pipes. =(cmd) is zsh-only and backs the substitution with a real temporary file, useful for tools that demand seekable input.

Form

Effect

<(cmd)

cmd’s stdout becomes a /dev/fd/N path (read)

>(cmd)

cmd’s stdin reads from a /dev/fd/N path (write)

=(cmd)

cmd’s stdout is written to a temp file; path substituted

        flowchart LR
  subgraph SUB["<(cmd)"]
    direction TB
    C[cmd] --> FIFO["/dev/fd/N"]
  end
  FIFO --> CALLER[caller reads it as a file]
    

Compare two pipelines without temp files.

$ diff <(sort a.txt) <(sort b.txt)

Feed a tool that wants a seekable file. =( ) cures the “this binary mmap’s its input” class of bug.

$ less =(curl -fsSL https://example.com/big.json | jq .)

Tee to multiple consumers. tee >(...) is the standard fan-out form (works in bash too); zsh’s MULTIOS gets you most of the way without it.

$ build_output |& tee >(grep -E 'ERROR|WARN' > issues.log) \
$                   >(gzip > full.log.gz) \
$                 > /dev/null

Named Pipes (FIFOs)#

mkfifo creates an on-disk named pipe. Reads block until a writer arrives and vice versa. The reach when two independent processes (not one pipeline) need to talk.

$ mkfifo /tmp/work.fifo
$ ( while read -r job; do                    # consumer
  $   print -- "processing $job"
  $ done < /tmp/work.fifo ) &

$ for j in alpha bravo charlie; do
  $ print -- "$j" > /tmp/work.fifo           # producer
$ done

$ rm /tmp/work.fifo

Backgrounding#

Trailing & runs a command in the background; the shell prints [job-id] pid and returns to the prompt. jobs lists work; wait blocks until the named job (or all) finish; $! is the PID of the most recent background job.

$ long-task &
$ jobs
$ wait $!                                    # wait for the last one
$ wait                                       # wait for every job

Fan out work across a list and wait for the whole batch.

$ pids=()
$ for h in web01 db01 cache01; do
  $ ssh "$h" 'uptime' &
  $ pids+=($!)
$ done
$ for p in $pids; do wait "$p"; done

Zsh’s coproc keyword opens a bidirectional pipe to a background command, exposing it through >&p (write) and <&p (read).

$ coproc bc -l
$ print -p "scale=4; 22/7"
$ read -p result
$ print -- "$result"          # 3.1428

Common Tasks#

Tee output to a file while keeping it on the terminal.

$ cmd | tee out.log

Write the same output to two files at once (MULTIOS).

$ cmd > a.log > b.log

Tee both streams to a file.

$ cmd |& tee out.log

Silence stdout but keep errors.

$ cmd > /dev/null

Silence everything.

$ cmd > /dev/null 2>&1

Send stdout to a file, stderr to a different file.

$ cmd > out.log 2> err.log

Send a script’s whole output to a logfile from line one.

$ exec >script.log 2>&1
$ # everything below this line goes to script.log

Read a file line by line, mutating outer state.

$ count=0
$ while IFS= read -r line; do
  $ (( count++ ))
$ done < input.txt
$ echo "$count"

Compare two sorted streams without temp files.

$ diff <(sort a.txt) <(sort b.txt)

Feed a seekable consumer from a pipeline.

$ less =(curl -fsSL https://example.com/big.csv)

Stream both stderr and stdout into ``grep``.

$ make 2>&1 | grep -E 'error|warning'
$ make |& grep -E 'error|warning'             # shorthand

Probe a pipeline for which stage failed.

$ setopt pipe_fail
$ cmd1 | cmd2 | cmd3; echo "$pipestatus"

References#

  • man 1 zsh, man 1 zshmisc (the REDIRECTION and COMMAND EXECUTION sections).

  • man 7 fifo, man 2 pipe (kernel-level documentation).

  • Overview for the language-level reading and writing (read, print, printf, positional parameters).

  • Patterns for strict-mode and trap idioms that pair with pipeline error handling.

  • Standard I/O for the platform-level view of standard streams.

  • Coreutils for tee, grep, sort, cut, awk.

  • zsh.sourceforge.io / Redirection