Bash Streams & Redirection

File descriptors, redirection operators, and stream manipulation.

Stream Fundamentals

# Three standard streams
# stdin  (0) - Input
# stdout (1) - Output
# stderr (2) - Errors

# Basic redirection
command > file                               # stdout to file (overwrite)
command >> file                              # stdout to file (append)
command 2> file                              # stderr to file
command 2>> file                             # stderr append
command < file                               # stdin from file

# Combine streams
command &> file                              # stdout AND stderr to file
command &>> file                             # Both append
command > file 2>&1                          # Same as &> (POSIX portable)
command 2>&1 > file                          # WRONG ORDER: stderr still to terminal!

# Discard output
command > /dev/null                          # Discard stdout
command 2> /dev/null                         # Discard stderr
command &> /dev/null                         # Discard both

# Redirect stderr to stdout for pipeline
command 2>&1 | next_command                  # Both streams in pipeline
command |& next_command                      # Bash shorthand for same

Here-Documents and Here-Strings

# Here-document: Multi-line input
cat << 'EOF'
This is literal text.
Variables like $HOME are NOT expanded.
Useful for templates.
EOF

cat << EOF
This text DOES expand variables.
Home is: $HOME
User is: $USER
EOF

# Indent-friendly (<<-)
cat <<- EOF
	Tabs at start are stripped.
	Content stays aligned.
	Great for functions.
EOF

# Here-document for SSH commands
ssh vault-01 << 'EOF'
hostname
uptime
df -h /
EOF

# Here-document for config files
cat > /tmp/nginx.conf << 'EOF'
server {
    listen 80;
    server_name example.com;
    root /var/www/html;
}
EOF

# Here-string: Single-line input
grep "pattern" <<< "search in this string"

while read -r word; do
    echo "Word: $word"
done <<< "one two three"

# Variable content to stdin
json='{"key": "value"}'
jq '.key' <<< "$json"

# Command output as input
bc <<< "scale=2; 100/3"

# Here-document in function
create_service_file() {
    local name="$1"
    local exec="$2"
    cat << EOF
[Unit]
Description=$name Service
After=network.target

[Service]
ExecStart=$exec
Restart=always

[Install]
WantedBy=multi-user.target
EOF
}

create_service_file "MyApp" "/usr/bin/myapp" > /tmp/myapp.service

Custom File Descriptors

# Open file descriptor for reading (3-9 are free)
exec 3< /etc/passwd
while read -r line <&3; do
    echo "$line"
done
exec 3<&-                                    # Close descriptor

# Open for writing
exec 4> /tmp/output.log
echo "Log entry 1" >&4
echo "Log entry 2" >&4
exec 4>&-                                    # Close

# Open for read/write
exec 5<> /tmp/data.txt
read -r line <&5                             # Read
echo "appended" >&5                          # Write
exec 5>&-

# Duplicate stdin/stdout for restoration
exec 3>&1                                    # Save stdout to fd 3
exec 1> /tmp/output.log                      # Redirect stdout
echo "This goes to file"
exec 1>&3                                    # Restore stdout
exec 3>&-                                    # Close fd 3
echo "This goes to terminal"

# Swap stdout and stderr
command 3>&1 1>&2 2>&3 3>&-

# Log to file while preserving terminal output
exec 3>&1 4>&2                               # Save original stdout/stderr
exec 1> >(tee -a /tmp/script.log) 2>&1       # Redirect with tee
echo "This logs and displays"
exec 1>&3 2>&4                               # Restore
exec 3>&- 4>&-                               # Close

# Multiple log files
exec 3>> /var/log/app/info.log
exec 4>> /var/log/app/error.log
echo "Info message" >&3
echo "Error message" >&4
exec 3>&- 4>&-

Process Substitution for Streams

# <() creates readable pseudo-file from command
diff <(cat file1) <(cat file2)

# Compare remote files
diff <(ssh vault-01 cat /etc/hosts) <(ssh vault-02 cat /etc/hosts)

# Compare sorted
diff <(sort file1.txt) <(sort file2.txt)

# >() creates writable pseudo-file
command | tee >(gzip > output.gz)

# Fan-out to multiple processors
generate_data | tee >(process_a > a.out) >(process_b > b.out) > combined.out

# Log levels to separate files
./app 2>&1 | tee >(grep ERROR >> errors.log) >(grep WARN >> warnings.log)

# Feed multiple commands from same source
<(cat file.txt) >(grep pattern1) >(grep pattern2)

# Avoid subshell with process substitution
count=0
while read -r line; do
    ((count++))
done < <(cat /etc/passwd)                    # No subshell!
echo "Count: $count"                         # Correct value

Advanced Redirection Patterns

# Redirect for entire script
exec > /var/log/myscript.log 2>&1
echo "All output goes to log file"

# Redirect for code block
{
    echo "Line 1"
    echo "Line 2"
    echo "Line 3"
} > output.txt

# Redirect for loop
for host in vault-01 ise-01 bind-01; do
    echo "Checking $host..."
    ping -c1 "$host"
done > hosts_status.log 2>&1

# Conditional redirection
verbose=true
if $verbose; then
    exec 3>&1                                # Normal stdout
else
    exec 3> /dev/null                        # Discard
fi
echo "Maybe visible" >&3

# Redirect stdin for entire script
exec < /etc/passwd
while read -r line; do
    echo "$line"
done

# Read password without echo (terminal control)
read -s -p "Password: " password
echo ""                                      # Newline after hidden input

# Redirect from string
read -r name age <<< "John 25"
echo "Name: $name, Age: $age"

# Append with sudo (can't just sudo >>)
echo "new line" | sudo tee -a /etc/myconfig > /dev/null

Coprocesses (Bidirectional Communication)

# Start coprocess
coproc my_coproc { bc -l; }

# Write to coprocess stdin
echo "scale=4; 22/7" >&"${my_coproc[1]}"

# Read from coprocess stdout
read -r result <&"${my_coproc[0]}"
echo "Result: $result"                       # 3.1428

# Close and wait
exec {my_coproc[1]}>&-
wait "$my_coproc_PID"

# Coprocess with named pipes
coproc MATH { bc -l; }

# Multiple calculations
for expr in "2+2" "10/3" "sqrt(2)"; do
    echo "scale=4; $expr" >&"${MATH[1]}"
    read -r result <&"${MATH[0]}"
    echo "$expr = $result"
done

exec {MATH[1]}>&-

# Interactive session
coproc SSH { ssh -tt vault-01; }
echo "hostname" >&"${SSH[1]}"
read -r hostname <&"${SSH[0]}"
echo "Host is: $hostname"
exec {SSH[1]}>&-

Infrastructure Stream Patterns

# Session logging with timestamps
exec > >(while read -r line; do echo "$(date '+%Y-%m-%d %H:%M:%S') $line"; done | tee -a /var/log/session.log) 2>&1

# Separate stdout/stderr logs
exec 1> >(tee -a /var/log/app/stdout.log)
exec 2> >(tee -a /var/log/app/stderr.log >&2)

# Configuration from heredoc
cat << 'EOF' | ssh vault-01 "sudo tee /etc/vault.d/config.hcl > /dev/null"
listener "tcp" {
  address     = "0.0.0.0:8200"
  tls_disable = false
}

storage "raft" {
  path = "/opt/vault/data"
}
EOF

# Parallel command output to separate files
for host in vault-01 ise-01 bind-01; do
    ssh "$host" "df -h /" > "/tmp/${host}_disk.txt" 2>&1 &
done
wait

# Feed dACL content to netapi
netapi ise ers dacl create --name "Linux-AD-Auth" --content - << 'EOF'
permit udp any any eq domain
permit tcp any any eq domain
permit udp any host ${AD_IP} eq 88
permit tcp any host ${AD_IP} eq 88
permit udp any host ${AD_IP} eq 123
deny ip any any
EOF

# k8s secrets from heredoc
kubectl create secret generic vault-config --from-file=config.hcl=/dev/stdin << 'EOF'
listener "tcp" {
  address = "0.0.0.0:8200"
}
EOF

# Multi-line git commit message
git commit -m "$(cat << 'EOF'
feat(auth): Add LDAP authentication

- Integrate with Active Directory
- Support nested groups
- Add caching for performance
EOF
)"

Error Stream Handling

# Capture stderr only
errors=$(command 2>&1 >/dev/null)

# Capture stdout and stderr separately
{ stdout=$(command 2>&1 1>&3 3>&-); } 3>&1
stderr=$(command 2>&1 >/dev/null)

# Check if command produced errors
if stderr=$(command 2>&1 >/dev/null) && [[ -z "$stderr" ]]; then
    echo "No errors"
else
    echo "Errors: $stderr"
fi

# Log errors with context
run_with_logging() {
    local cmd="$*"
    local output
    local exit_code

    output=$("$@" 2>&1)
    exit_code=$?

    if (( exit_code != 0 )); then
        echo "[ERROR] Command failed: $cmd" >&2
        echo "[ERROR] Exit code: $exit_code" >&2
        echo "[ERROR] Output: $output" >&2
    fi

    return $exit_code
}

# Conditional error display
command 2>&1 | grep -v "expected warning"

# Prefix stderr lines
command 2> >(sed 's/^/[stderr] /' >&2)

# Timestamped error log
exec 2> >(while read -r line; do
    echo "$(date '+%Y-%m-%d %H:%M:%S') [ERROR] $line"
done >> /var/log/errors.log)

Stream Gotchas

# WRONG: Order matters!
command 2>&1 > file                          # stderr to terminal, stdout to file
command > file 2>&1                          # CORRECT: Both to file

# WRONG: Redirect in wrong place
sudo echo "text" > /etc/protected            # Redirection runs as user, not root!

# CORRECT: Use tee
echo "text" | sudo tee /etc/protected > /dev/null

# WRONG: Lost file descriptor
{
    exec 3> /tmp/out
    echo "test" >&3
}                                            # fd 3 closed at block end!

# CORRECT: Manage scope properly
exec 3> /tmp/out
echo "test" >&3
exec 3>&-

# WRONG: Here-document in subshell
(
    cat << EOF
$var is expanded in subshell
EOF
)                                            # $var might not be what you expect

# WRONG: Expecting read from closed descriptor
exec 3< file.txt
cat <&3                                      # Reads entire file
read -r line <&3                             # Nothing left to read!

# CORRECT: Seek not possible, reopen
exec 3< file.txt
read -r line <&3
exec 3< file.txt                             # Reopen to "reset"

# WRONG: Buffering in pipeline to file
tail -f log | grep pattern > matches.txt    # Buffered, delayed writes!

# CORRECT: Unbuffered
tail -f log | stdbuf -oL grep pattern > matches.txt

# WRONG: Here-doc in function loses stdin
myfunc() {
    cat << EOF
Some content
EOF
    read -r input                            # Can't read! stdin consumed by heredoc
}

# CORRECT: Use fd 0 explicitly if needed
myfunc() {
    cat << EOF
Some content
EOF
    read -r input <&0                        # Still won't work - stdin is heredoc
}

Special Files (/dev/)

/dev/null — discard output (the black hole)
command > /dev/null 2>&1              # Suppress everything
command &> /dev/null                  # Same (bash shorthand)
which git > /dev/null 2>&1 && echo "installed"   # Quiet existence check (zsh-compatible)
cat /dev/null > file.txt              # Truncate a file
/dev/zero — infinite zeros
dd if=/dev/zero of=bigfile.bin bs=1M count=1024   # Create 1GB file
dd if=/dev/zero of=sparse.img bs=1 count=0 seek=10G  # Sparse 10GB image
/dev/urandom — random data
cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 16    # Random password (e.g. QDaXzhMCFf6wAmwG)
head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n'    # Random hex string (no xxd needed)
od -An -tu4 -N4 /dev/urandom | tr -d ' '               # Random integer
/dev/tty — force terminal I/O (even when stdin is redirected)
# Inside a pipeline, stdin is the pipe — /dev/tty reaches the actual terminal
cat file.txt | while read line; do
    echo "Processing: $line"
    read -p "Continue? " answer < /dev/tty
done
/dev/stdin, /dev/stdout, /dev/stderr — fd as file path
tar czf /dev/stdout files/ | ssh server 'cat > backup.tar.gz'
echo "Error: critical" > /dev/stderr
program --input /dev/stdin < inputfile.txt

Practical I/O Patterns

Timestamped logging
command 2>&1 | while IFS= read -r line; do
    echo "$(date '+%Y-%m-%d %H:%M:%S') $line"
done | tee -a /var/log/myapp.log
Error-only logging (stdout passes through, stderr captured)
command 2> >(tee -a errors.log >&2)
Pipeline debugging with tee at each stage
cat data.txt |
    tee /tmp/step1.txt |
    grep "pattern" |
    tee /tmp/step2.txt |
    sort |
    tee /tmp/step3.txt |
    uniq -c
# Inspect /tmp/step{1,2,3}.txt to find where data drops
Progress indicator on stderr (stdout stays clean for piping)
n=0
while read line; do
    ((n++))
    ((n % 1000 == 0)) && echo "Processed $n lines" >&2
done < input.txt > output.txt
Conditional verbose logging
#!/usr/bin/env bash
VERBOSE=${VERBOSE:-0}

log() {
    if ((VERBOSE)); then
        echo "[$(date '+%H:%M:%S')] $*" >&2
    fi
}

log "Starting process"
VERBOSE=1 ./myscript.sh     # Enable with env var
Multiplex output — tee to log AND syslog simultaneously
exec > >(tee -a output.log | logger -t myscript) 2>&1
echo "This goes to file, syslog, and screen"
Password prompt when stdin is redirected
#!/usr/bin/env bash
exec 3<&0                           # Save original stdin

read -s -p "Password: " pass < /dev/tty
echo

while read -u 3 line; do            # Read data from saved fd 3
    echo "Processing: $line"
done

exec 3<&-                           # Close fd 3

Quick Reference Tables

Table 1. Output redirection
Syntax Description

cmd > file

Stdout to file (overwrite)

cmd >> file

Stdout to file (append)

cmd 2> file

Stderr to file

cmd &> file

Both stdout+stderr to file

cmd > file 2>&1

Both to file (POSIX)

cmd 2>&1 > file

WRONG ORDER — stderr to terminal

Table 2. Input redirection
Syntax Description

cmd < file

Stdin from file

cmd << EOF

Heredoc (multi-line input)

cmd <<< "string"

Here string

cmd << 'EOF'

Heredoc without expansion

Table 3. File descriptors
Syntax Description

exec N> file

Open fd N for writing

exec N< file

Open fd N for reading

exec N<> file

Open fd N read/write

exec N>&-

Close fd N

cmd >&N

Redirect stdout to fd N

cmd N>&M

Redirect fd N to fd M

Table 4. Special files
File Purpose

/dev/null

Discard output

/dev/zero

Infinite zeros

/dev/urandom

Random bytes

/dev/tty

Current terminal

/dev/stdin

fd 0 as file path

/dev/stdout

fd 1 as file path

/dev/stderr

fd 2 as file path

Mind-Blowing Stream Tricks

Process Substitution — Commands as Files

Process substitution <(cmd) creates a virtual file from command output. No temp files, no pipes — the command’s output IS a file.

Compare two git log windows side by side
diff <(git log --oneline -5) <(git log --oneline --skip=5 -5)
# Output: shows what changed between your last 5 commits and the 5 before that
# No temp files created — diff reads two virtual files
Paste two command outputs as columns
paste <(find docs/modules/ROOT/examples/codex/grep -name '*.adoc' | xargs -n1 basename | sort) \
      <(find docs/modules/ROOT/examples/codex/grep -name '*.adoc' -exec wc -l {} \; | awk '{print $1}')
# Output:
# conditional-scripting.adoc  34
# context-extraction.adoc     35
# gotchas.adoc                37
# ...
# Two independent commands merged into one table — no temp files
Merge two data sources like a database join
paste -d'|' \
    <(find docs/modules/ROOT/pages/codex -mindepth 1 -maxdepth 1 -type d | xargs -n1 basename | sort) \
    <(find docs/modules/ROOT/pages/codex -mindepth 1 -maxdepth 1 -type d | while read d; do
        find "$d" -name '*.adoc' -type f | wc -l
    done) | column -t -s'|'
# Output:
# assembly       6
# awk            17
# bash           4
# ...34 rows — category names joined with file counts

The Variable Preservation Trick

Pipes run in subshells — variables set inside a pipe are LOST when the pipe ends. Process substitution solves this.

WRONG — pipe loses the count
count=0
find docs/ -name '*.adoc' | while read -r line; do ((count++)); done
echo "Count: $count"    # Always 0! The while loop ran in a subshell
CORRECT — process substitution preserves variables
count=0
while read -r line; do ((count++)); done < <(find docs/modules/ROOT/examples/codex/grep -name '*.adoc')
echo "Files counted: $count"
# Output: Files counted: 7 — variable survives because no subshell

The < <(cmd) syntax reads: "redirect stdin FROM the output of this command." The < is input redirection, <(cmd) is the process substitution. Together they feed command output to the while loop WITHOUT a pipe.

Tee Multiplexing — Split Streams to Multiple Destinations

Route errors and warnings to separate files simultaneously
echo -e "ERROR: disk full\nINFO: backup started\nWARN: low memory\nERROR: timeout\nINFO: done" | \
    tee >(grep ERROR > /tmp/errors.txt) >(grep WARN > /tmp/warns.txt) > /tmp/all.txt
# Three files created from ONE stream:
# /tmp/errors.txt → ERROR lines only
# /tmp/warns.txt  → WARN lines only
# /tmp/all.txt    → everything
Pipeline audit — see what each stage does
find docs/modules/ROOT/pages/codex -name '*.adoc' -type f | \
    tee >(wc -l | sed 's/^/  Stage 1 (find):  /' >&2) | \
    grep 'index' | \
    tee >(wc -l | sed 's/^/  Stage 2 (grep):  /' >&2) | \
    awk -F/ '{print $7}' | sort -u | \
    tee >(wc -l | sed 's/^/  Stage 3 (unique): /' >&2) > /dev/null
# Output on stderr:
#   Stage 1 (find):  152
#   Stage 2 (grep):  33
#   Stage 3 (unique): 2
# You see the data shrink at each stage — invaluable for debugging pipelines

Heredoc Tricks

Generate JSON from live system data
cat << EOF
{
  "kernel": "$(uname -r)",
  "shell": "$SHELL",
  "user": "$(whoami)",
  "git_branch": "$(git branch --show-current)",
  "codex_categories": $(find docs/modules/ROOT/pages/codex -mindepth 1 -maxdepth 1 -type d | wc -l),
  "total_adoc_files": $(find docs/modules/ROOT -name '*.adoc' -type f | wc -l)
}
EOF
# Output:
# {
#   "kernel": "6.19.10-arch1-1",
#   "shell": "/usr/bin/zsh",
#   "git_branch": "main",
#   "codex_categories": 34,
#   "total_adoc_files": 4321
# }
# Every $() executes at runtime — the JSON is always fresh
Heredoc as inline test data — no temp file needed
sort -t: -k2 -rn << 'DATA'
grep:7
sed:5
awk:5
find:4
xargs:4
jq:4
tee:3
yq:3
DATA
# Output: sorted by second field, descending — instant test data
Heredoc to function — inline config parser
configure() {
    while IFS='=' read -r key val; do
        [[ -z "$key" || "$key" == \#* ]] && continue
        printf "%-15s → %s\n" "$key" "$val"
    done
}

configure << 'CONF'
# Infrastructure config
bind_ip=10.50.1.90
mail_ip=10.50.1.91
domain=inside.domusdigitalis.dev
smtp_port=25
CONF
# Output:
# bind_ip         → 10.50.1.90
# mail_ip         → 10.50.1.91
# domain          → inside.domusdigitalis.dev
# smtp_port       → 25

Here-String One-Liners

Quick field extraction without a file
awk -F: '{printf "%-12s uid=%-5s shell=%s\n", $1, $3, $7}' <<< "$(head -5 /etc/passwd)"
# Output:
# root         uid=0     shell=/usr/bin/bash
# bin          uid=1     shell=/usr/bin/nologin
# daemon       uid=2     shell=/usr/bin/nologin
Read a file backwards
tac <(git log --oneline -5)
# Output: commits in chronological order (oldest first)
# tac reverses lines, <() feeds git log as a virtual file

Swap Stdout and Stderr

grep only stderr — swap the streams first
{ echo "normal output"; echo "ERROR: something broke" >&2; } 3>&1 1>&2 2>&3 | grep ERROR
# Output: ERROR: something broke
# The 3>&1 1>&2 2>&3 dance:
#   fd 3 = copy of stdout (save it)
#   fd 1 = stderr (stdout now goes where stderr went)
#   fd 2 = saved stdout (stderr now goes where stdout went)
# Result: pipe sees what was stderr, terminal sees what was stdout

Named Pipes — Persistent Channels Between Processes

Producer/consumer pattern
mkfifo /tmp/demo-pipe
# Terminal 1: producer writes
(echo "message 1"; echo "message 2"; echo "message 3") > /tmp/demo-pipe &
# Terminal 2 (or same): consumer reads
cat < /tmp/demo-pipe
# Output: message 1, message 2, message 3
rm /tmp/demo-pipe
# The pipe blocks until both sides connect — built-in synchronization

See Also