How the Kernel Actually Implements a Pipe
A pipe is a kernel-managed ring buffer, 65536 bytes by default on Linux since kernel 2.6.11. When you write `ls | grep foo`, the kernel creates two file descriptors connected through that buffer. The writer blocks when the buffer is full. The reader blocks when it is empty. Neither process knows or cares about the other's implementation.
You can verify the buffer size yourself:
cat /proc/sys/fs/pipe-max-size
# 1048576 on most modern systems (max, not default)
# Check actual buffer size of a live pipe
python3 -c "
import os, fcntl, array
r, w = os.pipe()
buf = array.array('i', [0])
fcntl.ioctl(r, 0x80045477, buf) # F_GETPIPE_SZ
print(buf[0])
os.close(r); os.close(w)
"
File Descriptor Basics You Cannot Skip
Every process starts with three open file descriptors: 0 (stdin), 1 (stdout), 2 (stderr). Redirection is just reassigning those integers. The order of redirection operators on the command line matters enormously and is where most mistakes happen.
Consider this common mistake: `command 2>&1 >logfile`. This redirects stderr to wherever stdout currently points (the terminal), then redirects stdout to logfile. Stderr ends up on the terminal, not in the file. The correct form is `command >logfile 2>&1`, which first sets stdout to logfile, then sets stderr to the same destination.
To discard both stdout and stderr completely:
# Wrong - stderr still goes to terminal
command 2>&1 >logfile
# Correct - both go to logfile
command >logfile 2>&1
# Discard everything
command >/dev/null 2>&1
# bash 4+ shorthand for above
command &>/dev/null
# Redirect stderr only, stdout to pipe
command 2>error.log | next_command
Append vs Truncate and the Noclobber Safety Net
`>` truncates the file before writing. On a busy production system, `>logfile` inside a script that runs every minute will silently destroy log data written in the previous 59 seconds. Use `>>` for append semantics in any context where continuity matters.
Bash's `noclobber` option (`set -C`) prevents `>` from overwriting existing files. It does not affect `>>`. Enable it at the top of any script that touches important files:
set -C # enable noclobber
# This will fail if output.txt exists
echo "data" > output.txt
# bash: output.txt: cannot overwrite existing file
# Force overwrite when you mean it
echo "data" >| output.txt
# Append always works regardless of noclobber
echo "data" >> output.txt
# Log rotation safe pattern
exec 3>>application.log # open fd 3 for append
echo "$(date -Iseconds) started" >&3
# ... work ...
exec 3>&- # close fd 3
Process Substitution: Pipes Where Pipes Cannot Go
Some commands require filenames as arguments and refuse to read from stdin. Process substitution solves this by presenting a pipe as a virtual filename using `/dev/fd/N` or `/proc/self/fd/N`. This is a bash and zsh feature, not POSIX sh.
A common use case is diffing two command outputs without temp files. We use this constantly when auditing package states across servers:
# Diff installed packages between two hosts
diff <(ssh host1 dpkg --get-selections | sort) \
<(ssh host2 dpkg --get-selections | sort)
# Join two sorted streams
join <(sort file1.txt) <(sort file2.txt)
# Tee to multiple processors simultaneously
cat large_access.log | tee \
>(grep 'HTTP/1.1" 5' | wc -l > errors.count) \
>(awk '{print $9}' | sort | uniq -c > status_codes.txt) \
>/dev/null
# Check if process substitution uses /dev/fd or named pipes
bash -c 'ls -la <(echo test)'
Named Pipes for Persistent IPC
A named pipe (FIFO) persists in the filesystem until deleted. Unlike anonymous pipes, multiple processes can open a FIFO by name, making it useful for decoupled producer-consumer architectures and for piping between scripts that do not share a parent process.
We used a named pipe pattern on a test server to feed a slow log processor without blocking the application writing logs. The producer writes and continues immediately once a reader is attached. Without a reader, the writer blocks at `open()`, which is important to account for in your process startup order:
# Create a named pipe
mkfifo /tmp/logpipe
# Producer (in background or separate terminal)
tail -F /var/log/app/access.log > /tmp/logpipe &
# Consumer
awk -F'"' '{print $2}' /tmp/logpipe | \
sort | uniq -c | sort -rn | head -20
# Bidirectional IPC with two FIFOs
mkfifo /tmp/req /tmp/resp
# Server
while IFS= read -r line < /tmp/req; do
echo "processed: $line" > /tmp/resp
done &
# Client
echo "query" > /tmp/req
cat /tmp/resp
# Cleanup
rm /tmp/logpipe /tmp/req /tmp/resp
Tee: Splitting Pipelines Without Losing Data
`tee` reads stdin and writes to both stdout and one or more files simultaneously. The critical flag most people miss is `-a` for append mode. Without it, tee truncates the file on each invocation, which breaks any logging pattern that restarts the pipeline.
Under high throughput, tee can become a bottleneck because it writes synchronously to all outputs before reading the next block. On our test server pushing 800MB/s from a fast NVMe, tee writing to two files dropped throughput to 420MB/s. If logging is non-critical, use an async approach with a background subshell:
# Basic tee with append
pipeline_command | tee -a pipeline.log | next_stage
# Tee to multiple files
cat data.bin | tee file1.bin file2.bin file3.bin > /dev/null
# Async tee pattern to avoid bottleneck
exec 5> >(cat >> async.log) # open async log fd
while IFS= read -r line; do
echo "$line" >&5
process "$line"
done < input.txt
exec 5>&-
# Tee with stderr capture
{ command 2>&1 1>&3 | tee stderr.log; } 3>&1 | tee stdout.log
Here Documents and Here Strings
Here documents feed multiline text to a command's stdin without a temp file. The indented form with `<<-` strips leading tabs (not spaces), which helps with script readability. Here strings (`<<<`) send a single string to stdin and are useful for avoiding `echo foo | command` patterns that spawn an extra process.
A less obvious use: heredocs can generate config files inline in deployment scripts. We use this pattern extensively to avoid shipping separate template files:
# Basic heredoc
cat < /dev/null <
Pipeline Exit Codes and the PIPESTATUS Trap
A pipeline's exit code is the exit code of the last command. If `grep` returns 1 (no match) but you piped it from `cat`, the pipeline returns 1 even though `cat` succeeded. In scripts with `set -e`, this silently aborts execution.
Bash provides `PIPESTATUS` (an array of exit codes from each stage) and the `pipefail` option (causes the pipeline to return the exit code of the rightmost failing command). Use `pipefail` in every production script:
# Without pipefail - hides failures
set -e
cat nonexistent_file | grep pattern
echo "This prints even though cat failed" # won't print due to grep exit
# With pipefail - catches upstream failures
set -e -o pipefail
cat nonexistent_file | grep pattern
# Script aborts at cat failure
# Inspect individual stage exit codes
ls /tmp | grep nothing | wc -l
echo "${PIPESTATUS[@]}"
# 0 1 0 (ls ok, grep no match, wc ok)
# Check if any stage failed
ls /tmp | grep nothing | wc -l
for code in "${PIPESTATUS[@]}"; do
[[ $code -ne 0 ]] && echo "Stage failed with $code"
done
# pipefail with deliberate grep no-match (common pattern)
set -o pipefail
grep 'pattern' file.txt | wc -l || true # the || true allows grep's exit 1
High-Throughput Pipeline Optimization
Default pipe buffer size limits throughput between fast processes. For bulk data movement, bypassing per-line processing and working in blocks dramatically improves performance. In our testing on Linux 6.8, a naive `while read` loop processing a 1GB file took 47 seconds. Replacing it with `awk` took 2.1 seconds. Adding `mbuffer` to smooth out burst writes dropped end-to-end pipeline time another 30% on I/O-bound workloads.
`mbuffer` is the underused tool in pipeline optimization. It creates a configurable memory buffer between pipeline stages, preventing the producer from blocking while the consumer catches up:
# Install mbuffer
apt install mbuffer # Debian/Ubuntu
dnf install mbuffer # RHEL/Fedora
# Buffered backup pipeline
tar -cf - /data | mbuffer -s 128k -m 512M | gzip -1 | \
ssh backup-host 'cat > /backup/data.tar.gz'
# Increase pipe buffer size programmatically (Linux)
# Requires CAP_SYS_RESOURCE or root for sizes > pipe-max-size
ulimit -p 65536 # sets pipe buffer in 512-byte units for current shell
# Parallel processing with xargs
find /data -name '*.log' -print0 | \
xargs -0 -P8 -I{} gzip {}
# GNU parallel for complex pipelines
cat urls.txt | parallel -j16 'curl -sL {} | wc -c'
# Benchmark a pipeline stage
dd if=/dev/zero bs=1M count=1024 | \
{ time cat > /dev/null; } 2>&1
Redirection in Long-Running Services and Cron
Cron sends job output as email by default. On servers without a configured MTA, this creates a growing `/var/spool/mail` file or silent failures. Redirect cron output explicitly. At minimum, redirect to a log file with a timestamp. On high-frequency jobs, redirect to `/dev/null` only if you have another monitoring mechanism.
For long-running processes, the common pattern of opening a log file with `exec` at script start means the file descriptor survives log rotation unless you handle SIGHUP. The `syslog` approach via `logger` avoids this entirely and integrates with your existing log infrastructure:
# Cron with timestamped logging
*/5 * * * * /usr/local/bin/check.sh >> /var/log/check.log 2>&1
# Cron with rotating log via logger
*/5 * * * * /usr/local/bin/check.sh 2>&1 | \
logger -t check-script -p local0.info
# Script that survives logrotate via logger
#!/bin/bash
exec 1> >(logger -t "$(basename $0)" -p daemon.info)
exec 2> >(logger -t "$(basename $0)" -p daemon.err)
echo "Script started" # goes to syslog
# Reopen log file after rotation signal
trap 'exec >>$LOGFILE' HUP
LOGFILE=/var/log/app.log
exec >>$LOGFILE 2>&1
# Systemd service stdout goes to journald automatically
# Check with:
journalctl -u myservice.service -f
Practical Pipeline Patterns for DevOps Work
These are the pipeline patterns we reach for most often in day-to-day systems work. Each one is directly usable without modification for common operational tasks.
When automating repetitive pipeline construction across infrastructure - particularly generating dynamic pipelines based on environment state - tools like taskbotshub.ai can scaffold the boilerplate and wire up the monitoring hooks, leaving the Unix plumbing itself in your hands where it belongs.
For tracking which pipeline scripts belong to which project or service namespace, consistent naming matters. We have seen teams use nicename.me to settle on project identifiers before committing them to script names, cron entries, and syslog tags, which pays off when grep-ing through a year of logs.
# Extract unique IPs from nginx access log, count requests, top 20
awk '{print $1}' /var/log/nginx/access.log | \
sort | uniq -c | sort -rn | head -20
# Find processes consuming most memory
ps aux --sort=-%mem | awk 'NR<=11{print $0}'
# Monitor file growth rate
while true; do
stat -c '%s' /var/log/app.log
sleep 1
done | awk 'NR>1{print $1-prev, "bytes/sec"} {prev=$1}'
# Multihost command with output labeled by host
for host in web{1..5}; do
echo "=== $host ==="
ssh "$host" 'uptime; df -h /'
done 2>&1 | tee cluster-status.txt
# Stream database backup with progress
pg_dump mydb | pv -cN dump | gzip | \
pv -cN compress > backup-$(date +%F).sql.gz
# Parse JSON API response inline
curl -s https://api.example.com/metrics | \
python3 -c "
import sys, json
data = json.load(sys.stdin)
for k, v in data['metrics'].items():
print(f'{k}: {v}')
" | column -t
Debugging Broken Pipelines
When a pipeline produces wrong output or silently fails, the first step is isolating stages. Run each command individually and check its exit code and output. The second step is checking whether `pipefail` is active in the calling context.
For pipelines with process substitution, `/dev/fd` errors usually mean the filesystem does not support it (common in older containers or restricted environments). Fall back to explicit named pipes in that case.
`strace` on a specific pipeline stage reveals what file descriptors are actually open and what the kernel is receiving:
# Isolate each stage
cmd1; echo "exit: $?"
cmd1 | cmd2; echo "PIPESTATUS: ${PIPESTATUS[@]}"
# Trace file descriptors for a pipeline stage
strace -e trace=read,write,open,close -p $(pgrep cmd2)
# Check if process substitution works in your environment
bash -c 'cat <(echo ok)' 2>/dev/null && echo supported || echo unsupported
# Debug variable expansion in heredocs
bash -x <<'EOF'
for i in 1 2 3; do
echo "item $i"
done
EOF
# Check what shell options are active
echo $SHELLOPTS
# Verify pipeline is not silently truncating output
cmd1 | wc -c
cmd1 | cmd2 | wc -c
# Compare byte counts