You have spent three lessons writing 2>/dev/null, | and > without anybody having fully explained to you what they are. You have copied them because they work. This lesson takes the mechanism apart, and it is probably the most important one in the module: redirection and pipes are the mechanism through which the Unix philosophy of Module 1 — small programs that do one thing well and compose together — stops being a nice phrase and becomes something you type.
This is not a subject where memorising symbols is enough. There are a handful of cases — 2>&1 >file against >file 2>&1, the exit code of a pipeline, sudo echo x > /etc/... — where intuition misleads you and everybody gets it wrong at least once. We are going after them in enough detail that it does not happen to you.
Contents
- The three standard streams and why they exist
- Seeing them for real in
/proc - Output redirection
- Input redirection
- Error redirection and the order of evaluation
- The special files in
/dev - Here-documents and here-strings
- Pipes: what they really are
- The exit code of a pipeline
teeand the| sudo teepattern- Buffering: when the output does not appear
- Composing a pipeline step by step
- The three standard streams and why they exist
Every process on Linux is born with three channels already open. They are not a Bash convention: the system opens them and they are identified by a number, the file descriptor.
| Descriptor | Name | Points by default to | What it is for |
|---|---|---|---|
0 |
stdin | the keyboard | data input |
1 |
stdout | the screen | the program's results |
2 |
stderr | the screen | error and diagnostic messages |
The interesting question is why there are two output channels if both go to the screen. The answer is the key to the whole design: so that they can be separated when needed. If the results and the errors travelled down the same channel, you could not save just the useful part to a file, nor chain one program to another without the warnings contaminating the data.
operator@srv-tramontana:~$ ls /var/log/tramontana /nonexistent > /tmp/output.txt
ls: cannot access '/nonexistent': No such file or directory
operator@srv-tramontana:~$ cat /tmp/output.txt
/var/log/tramontana:
access.log
errors.log> redirected only stdout to the file. The error stayed on the screen because it travels down stderr. That separation is deliberate and it is what allows a find walking /etc with permission denied warnings to still produce a clean list.
flowchart LR
T["keyboard / file / pipe"] -->|"0 stdin"| P["process<br/>(e.g. grep)"]
P -->|"1 stdout<br/>results"| S["screen / file / another process"]
P -->|"2 stderr<br/>errors"| E["screen / file / /dev/null"]
- Seeing them for real in
/proc
/procSince everything is a file, the descriptors can be looked at:
operator@srv-tramontana:~$ ls -l /proc/$$/fd
lrwx------ 1 operator operator 64 Aug 18 12:04 0 -> /dev/pts/0
lrwx------ 1 operator operator 64 Aug 18 12:04 1 -> /dev/pts/0
lrwx------ 1 operator operator 64 Aug 18 12:04 2 -> /dev/pts/0
lrwx------ 1 operator operator 64 Aug 18 12:04 255 -> /dev/pts/0All three point to /dev/pts/0, your pseudo-terminal. Number 255 is for Bash's internal use. Now look at what happens inside a redirection:
operator@srv-tramontana:~$ ls -l /proc/self/fd > /tmp/fds.txt 2>&1; cat /tmp/fds.txt
lr-x------ 1 operator operator 64 Aug 18 12:06 0 -> /dev/pts/0
l-wx------ 1 operator operator 64 Aug 18 12:06 1 -> /tmp/fds.txt
l-wx------ 1 operator operator 64 Aug 18 12:06 2 -> /tmp/fds.txtDescriptors 1 and 2 no longer point to the terminal but to the file. Redirection is not a function of the command: it is something Bash does to the child process before starting it. That is why it works with any program, without the program having to know anything about it.
- Output redirection
| Operator | Effect |
|---|---|
> file |
stdout to the file, truncating it |
>> file |
stdout to the file, appending at the end |
| `> | file` |
> truncates the file before running the command, and creates it if it does not exist. That means a > over a file with data destroys it even if the command fails afterwards.
operator@srv-tramontana:~$ set -o noclobber
operator@srv-tramontana:~$ echo test > /tmp/fds.txt
bash: /tmp/fds.txt: cannot overwrite existing file
operator@srv-tramontana:~$ echo test >| /tmp/fds.txtnoclobber turns > into an operation that fails if the destination exists, and leaves >| as the explicit way of saying "yes, overwrite". It is a good setting for a working session in production; check it with set -o | grep noclobber and disable it with set +o noclobber. The course convention — a .bak-$(date +%F) copy before touching anything — and noclobber reinforce each other.
- Input redirection
< file connects stdin to the file. Many commands accept the file name as an argument, so the difference looks cosmetic. It is not:
operator@srv-tramontana:~$ wc -l /home/operator/data/bookings.csv
26 /home/operator/data/bookings.csv
operator@srv-tramontana:~$ wc -l < /home/operator/data/bookings.csv
26With <, the program does not know the file's name: it only receives a stream of bytes, and that is why it does not print it. It is what you want when the number is going to feed another calculation. (26 lines: the header plus the 25 records.)
- Error redirection and the order of evaluation
| Form | What it does |
|---|---|
2> file |
stderr to the file |
2>> file |
stderr, appending |
2>&1 |
"make 2 point wherever 1 points now" |
> file 2>&1 |
both to the file |
&> file |
both to the file (a Bash shortcut) |
&>> file |
both, appending |
2>/dev/null |
discards the errors |
Here is the point where everybody goes wrong. Bash processes redirections from left to right, and 2>&1 does not mean "join the two streams forever": it means "copy 1's current destination onto 2". It is a photograph of the state at that instant.
operator@srv-tramontana:~$ ls /var/log/tramontana /nonexistent > /tmp/ok.txt 2>&1
operator@srv-tramontana:~$ cat /tmp/ok.txt
ls: cannot access '/nonexistent': No such file or directory
/var/log/tramontana:
access.log
errors.logStep by step: > /tmp/ok.txt points 1 at the file; then 2>&1 copies that destination onto 2. Both end up in the file. Correct.
operator@srv-tramontana:~$ ls /var/log/tramontana /nonexistent 2>&1 > /tmp/wrong.txt
ls: cannot access '/nonexistent': No such file or directory
operator@srv-tramontana:~$ cat /tmp/wrong.txt
/var/log/tramontana:
access.log
errors.logStep by step: 2>&1 copies 1's current destination, which is still the terminal, onto 2; then > /tmp/wrong.txt moves 1 to the file, but 2 was left pointing at the terminal. Result: the error comes out on screen and only the normal output goes to the file. It is exactly the opposite of what most people think they are writing.
The practical rule: 2>&1 always goes at the end. And if you do not need compatibility with other shells, &> file is unambiguous and does not allow the mistake.
There is a legitimate use for the "wrong" order: command 2>&1 >/dev/null | grep something sends only the errors into the pipe and discards the normal output. It is unusual, but when you need it, it is the only way.
- The special files in
/dev
/dev| File | What it is |
|---|---|
/dev/null |
a black hole: everything written is discarded; reading it returns EOF |
/dev/zero |
an infinite source of null bytes |
/dev/stdout, /dev/stderr, /dev/stdin |
the process's streams, as paths |
/dev/urandom |
random bytes |
/dev/full |
always gives "disk full" when written to (for testing errors) |
2>/dev/null is the noise silencer you were already using in 03-03. Use it with judgement: it discards all the errors, including the ones you did want to see. If you only want to ignore the permission denied ones, filtering is better than silencing blindly.
/dev/stdout as a path is useful when a command demands a file name and you want its output in the pipeline, as in tar -cf /dev/stdout. And /dev/zero is handy for generating a file of a known size to test a backup with:
operator@srv-tramontana:~$ dd if=/dev/zero of=/tmp/test.bin bs=1M count=10 status=none
operator@srv-tramontana:~$ ls -lh /tmp/test.bin
-rw-r----- 1 operator operator 10M Aug 18 12:22 /tmp/test.bin
- Here-documents and here-strings
A here-document feeds stdin with text written on the command line itself, up to a delimiter.
operator@srv-tramontana:~$ cat <<END > /tmp/notice.txt
Deployment scheduled for $(date +%F)
Active release: $(readlink /opt/tramontana/app)
END
operator@srv-tramontana:~$ cat /tmp/notice.txt
Deployment scheduled for 2026-08-18
Active release: releases/3.2.1With the delimiter in single quotes nothing is expanded:
operator@srv-tramontana:~$ cat <<'END'
The value of $HOME is not expanded, nor is $(date +%F)
END
The value of $HOME is not expanded, nor is $(date +%F)| Form | Expansion of $VAR and $(...) |
|---|---|
<<END |
yes |
<<'END' or <<"END" |
no |
<<-END |
yes, and it also strips the leading tabs |
The rule: quote the delimiter unless you deliberately want expansion. It is the same quoting rule from 03-01, applied to blocks.
A here-string <<< is the single-line version, and it avoids the echo ... | people write out of habit:
- Pipes: what they really are
A pipe is not a temporary file. It is a buffer in kernel memory — typically 64 KiB — with two ends: the standard output of the process on the left and the standard input of the one on the right.
And here is the point to understand: the two processes run at the same time, not one after the other. The one on the right starts consuming as soon as there is data. If the buffer fills up because the consumer is slower, the kernel puts the producer to sleep until there is room; if the buffer empties, it puts the consumer to sleep. That automatic synchronisation is why you can do cat 50-GB-file | grep something without exhausting memory.
Two simultaneous processes. grep writes into the buffer, wc reads from it, and neither of them knows the other exists: one thinks it is writing to the screen and the other thinks it is reading from the keyboard. That is exactly what makes it possible to compose programs that were never designed to work together.
A pipe only carries stdout. The errors from the command on the left still go to the screen. To put them into the pipe as well, |& (equivalent to 2>&1 |):
operator@srv-tramontana:~$ ls /nonexistent | wc -l
ls: cannot access '/nonexistent': No such file or directory
0
operator@srv-tramontana:~$ ls /nonexistent |& wc -l
1
- The exit code of a pipeline
By default, $? returns the code of the last command in the pipeline. The previous ones are lost, and that hides failures:
operator@srv-tramontana:~$ cat /nonexistent | wc -l
cat: /nonexistent: No such file or directory
0
operator@srv-tramontana:~$ echo $?
0cat failed, but wc finished cleanly and the pipeline reports success. A procedure that checks $? would accept as good an operation that read nothing at all.
The PIPESTATUS array holds the code of each element:
operator@srv-tramontana:~$ cat /nonexistent | wc -l > /dev/null
operator@srv-tramontana:~$ echo "${PIPESTATUS[@]}"
1 0And set -o pipefail makes the pipeline return the code of the last command that failed:
operator@srv-tramontana:~$ set -o pipefail
operator@srv-tramontana:~$ cat /nonexistent | wc -l > /dev/null; echo $?
1pipefail is indispensable in serious scripts and you will see it built into set -euo pipefail in Module 4. Here just take away the idea: a pipeline can lie to you about its success, and you know two ways of stopping it.
tee and the | sudo tee pattern
tee and the | sudo tee patterntee reads from stdin and writes at the same time to stdout and to one or more files. It forks the stream.
operator@srv-tramontana:~$ grep 'ERROR 500' /var/log/tramontana/errors.log \
| tee /tmp/errors-500.txt | wc -l
41You see the count and you also keep the lines in a file. With -a it appends instead of truncating, and with - as an extra destination it can duplicate to the screen in the middle of a pipeline, which makes it an excellent debugging tool.
Why sudo echo x > /etc/... fails
operator@srv-tramontana:~$ sudo echo 'log_level=info' > /etc/tramontana/app.conf
bash: /etc/tramontana/app.conf: Permission deniedIt looks contradictory: we wrote sudo. The explanation lies in the division of labour. The redirection is carried out by Bash, not by sudo. Bash opens the destination file before launching anything, and it does so with operator's privileges, and he cannot write to a file owned by root. The sudo affects echo, which is precisely the part that did not need privileges.
The solution is to make the privileged process the one that writes:
Now tee runs under sudo and it is the one that opens the file. The trailing > /dev/null discards the copy tee sends to the screen, which adds nothing here. And to append, | sudo tee -a, never >>, for the same reason. Applied with the course convention:
operator@srv-tramontana:~$ sudo cp /etc/tramontana/app.conf /etc/tramontana/app.conf.bak-$(date +%F)
operator@srv-tramontana:~$ echo 'log_level=info' | sudo tee -a /etc/tramontana/app.conf > /dev/null
operator@srv-tramontana:~$ sudo diff -u /etc/tramontana/app.conf.bak-$(date +%F) /etc/tramontana/app.conf
@@ -6,3 +6,4 @@
query_timeout=30
log_level=debug
+log_level=infoThe diff reveals something important: we have added a second log_level key instead of correcting the existing one. The file now has two, and which one wins depends on how the application reads it. This is why the convention demands the diff -u: not to admire the change, but to discover that it was not the one you wanted. Correcting in place is sed's job, in the next lesson.
- Buffering: when the output does not appear
A program writes into a buffer and only flushes when it fills up or when it finishes. The C library applies a rule that catches people out: if stdout is a terminal, the buffer is line-based; if it is a pipe or a file, it is block-based in 4 KiB chunks.
The practical consequence: tail -F access.log | grep 'ERROR 500' may show nothing for minutes, not because there are no errors, but because grep is accumulating 4 KiB before releasing them. It looks as though the pipeline is broken and it is working perfectly.
stdbuf -oL forces line buffering on the following command. Alternatives: grep --line-buffered, awk with fflush(), sed -u. Remember that the course convention is tail -F and never -f in production, because -F survives the file being rotated.
- Composing a pipeline step by step
The goal: the paths with the most 500 errors in access.log. It is built incrementally, verifying each link, just as you did with the regexes.
operator@srv-tramontana:~$ wc -l < /var/log/tramontana/access.log
412
operator@srv-tramontana:~$ grep ' 500 ' /var/log/tramontana/access.log | wc -l
14
operator@srv-tramontana:~$ grep ' 500 ' /var/log/tramontana/access.log | head -2
2026-08-18 03:12:44 POST /bookings 500 ip=10.0.2.31 ms=30012
2026-08-18 03:12:58 POST /bookings 500 ip=10.0.2.77 ms=30008Of 412 lines, 14 remain. Now we extract just the path and group:
operator@srv-tramontana:~$ grep ' 500 ' /var/log/tramontana/access.log \
| grep -oE ' /[^ ]+ ' | sort | uniq -c | sort -rn
9 /bookings
4 /bookings/payment
1 /housesNine out of fourteen on /bookings, and the ms=30012 values in the sample are just under 30 seconds, which is exactly the query_timeout=30 in app.conf. The pipeline has not produced a fact: it has produced a hypothesis — the database queries are hitting the time limit — which fits with the active_connections=200 we saw in 03-03 and with the configured max_connections=200. That is what you take to Marta.
Go over the composition again: grep filters, grep -oE extracts, sort groups equal things together, uniq -c counts, sort -rn orders by frequency. Five programs that know nothing about each other, chained by a kernel buffer. None of them knows how to produce the report; together, they do.
Common Mistakes and Tips
- Writing
2>&1before the output redirection. It is the classic mistake. It goes at the end, or use&>. command > filewith the same file as input.sort data.txt > data.txtempties it: Bash truncates the destination beforesortreads. Usesort -o data.txt data.txtor a temporary file.sudo command > /protected/file. The redirection is done by your shell. Use| sudo tee.- Trusting
$?after a pipeline. It is the last command's. UsePIPESTATUSorpipefail. - Silencing with
2>/dev/nullas a reflex. You are also discarding the errors you needed. Look at them first. - Believing that a pipeline is sequential. The processes run at the same time; that is why it works with enormous files and why buffering can delay the output.
- Forgetting to quote a here-doc's delimiter. If the text contains
$or quotes, it will be expanded. - Tip: insert
| tee /tmp/step1.txt |in the middle of a long pipeline to inspect what is travelling through that point without breaking the chain. - Tip:
command | catdisables colour and column output in many programs, because they detect that they are not writing to a terminal. If you are missing the colour,--color=alwaysforces it.
Exercises
Exercise 1. Run a find over /etc as a normal user, so that the list of results ends up in /tmp/findings.txt and the permission errors in /tmp/failures.txt, with nothing appearing on screen. Then count how many errors there were and explain why you could not have separated them with &>.
Exercise 2. Add the line # reviewed 2026-08-18 to the end of /etc/tramontana/app.conf keeping the convention of a prior copy and a subsequent verification. Explain why sudo echo ... >> does not work.
Exercise 3. Build step by step a pipeline that shows the five IP addresses with the most requests in access.log, while saving the complete listing to /tmp/ips.txt at the same time. Verify that the total number of requests counted matches the file's 412 lines and explain what you would do if it did not match.
Solutions
Solution 1.
operator@srv-tramontana:~$ find /etc -name '*.conf' > /tmp/findings.txt 2> /tmp/failures.txt
operator@srv-tramontana:~$ wc -l < /tmp/findings.txt
187
operator@srv-tramontana:~$ wc -l < /tmp/failures.txt
6
operator@srv-tramontana:~$ head -1 /tmp/failures.txt
find: '/etc/ssl/private': Permission deniedTwo independent redirections, each stream to its own file, and the screen stays clean. &> does not work because it mixes both streams into a single destination, which is precisely the opposite of what the exercise asked for. The six errors are directories such as /etc/ssl/private, legitimately closed to a normal user: reviewing them in their own file is better practice than discarding them with 2>/dev/null, because an unexpected error in there would be a signal you would not want to lose.
Solution 2.
operator@srv-tramontana:~$ sudo cp -p /etc/tramontana/app.conf /etc/tramontana/app.conf.bak-$(date +%F)
operator@srv-tramontana:~$ echo '# reviewed 2026-08-18' | sudo tee -a /etc/tramontana/app.conf > /dev/null
operator@srv-tramontana:~$ sudo diff -u /etc/tramontana/app.conf.bak-$(date +%F) /etc/tramontana/app.conf
@@ -7,3 +7,4 @@
log_level=debug
log_level=info
+# reviewed 2026-08-18
operator@srv-tramontana:~$ sudo ls -l /etc/tramontana/app.conf
-rw-r----- 1 root tramontana 545 Aug 18 12:41 /etc/tramontana/app.confsudo echo '...' >> /etc/tramontana/app.conf fails because the redirection is carried out by your shell before sudo is invoked, and your shell runs as operator, who has no write permission on a 640 file owned by root. The sudo would apply to echo, which needs no privilege at all to print text. With | sudo tee -a, the process that opens the file is tee, and that one does run as root. The cp -p preserves permissions and ownership in the copy, and ls -l confirms that the original is still 640 root:tramontana: writing with tee under sudo has not changed the file's ownership, because tee writes into the existing file instead of creating it anew.
Solution 3.
operator@srv-tramontana:~$ grep -oE 'ip=[0-9.]+' /var/log/tramontana/access.log | wc -l
412
operator@srv-tramontana:~$ grep -oE 'ip=[0-9.]+' /var/log/tramontana/access.log \
| sort | uniq -c | sort -rn | tee /tmp/ips.txt | head -5
138 ip=10.0.2.77
94 ip=10.0.2.31
61 ip=10.0.2.44
38 ip=10.0.2.52
27 ip=10.0.2.18
operator@srv-tramontana:~$ awk '{s+=$1} END {print s}' /tmp/ips.txt
412The first command is the indispensable verification: 412 matches for 412 lines, so every line has exactly one ip= field. If it had come out lower, there would be lines in another format silently being left out of the report; if higher, some line with two IPs and an inflated count. In either case you would have to examine the discordant lines with grep -vc 'ip=' before going on, because a report built on an unverified assumption is a report that can be contradicted.
tee forks: the file receives the complete listing and head -5 keeps the podium. The final sum with awk — which you will study in the next lesson — closes the circle: 412 again, so no request has been lost or duplicated along the way. And there is a finding: 10.0.2.77 accounts for 138 requests, a third of the total and almost 50% more than the next one. It deserves investigation.
Conclusion
You have gone from copying symbols to understanding where every byte travels.
- You know the three standard streams — stdin 0, stdout 1, stderr 2 — you know why there are two output channels, and you have seen them for real in
/proc/<pid>/fd. - You handle
>,>>,noclobberwith>|, and<, knowing that with<the program does not know the file's name. - You have the classic mistake sorted:
2>&1copies 1's current destination, it is evaluated from left to right and that is why it goes at the end;&>is the unambiguous shortcut. - You use
/dev/null,/dev/zeroand/dev/stdoutwith judgement, knowing that silencing errors blindly hides the ones that did matter. - You write here-documents and here-strings, and you quote the delimiter when you do not want expansion.
- You know that a pipe is a kernel buffer between two processes running at the same time, that it only carries stdout, and that
|&includes the errors. - You do not trust a pipeline's exit code: you know
PIPESTATUSandset -o pipefail. - You apply
| sudo teeto write to protected files, and you can explain whysudo echo x > /etc/...fails: the redirection is done by your shell, not bysudo. - You recognise block buffering when a log does not appear in real time and you fix it with
stdbuf -oL. - And you have composed a pipeline incrementally until 412 lines of log became a defensible diagnostic hypothesis.
sort, uniq -c and even an awk have turned up in those pipelines without being explained. That is the debt the next lesson settles. Text Processing: cut, sort, uniq, sed and awk closes the circle opened by the Unix philosophy of Module 1: plain text as the universal interface between programs. You will learn to cut out fields, sort by whichever column you like, count occurrences, transform with sed without destroying the original, and group and total with awk until half a pipeline is replaced by a single command. By the end of it you will be able to hand Marta the monthly billing report from bookings.csv and the top error paths from access.log without writing a line of code.
Linux Course: From Beginner to System Administrator
Module 1: Introduction to Linux
- What Is Linux?
- History of Linux
- Linux Distributions
- Installing Linux
- First Contact with the System
- The Linux File System Structure
Module 2: Basic Linux Commands
- Introduction to the Command Line
- Getting Help and System Documentation
- Navigating the File System
- File and Directory Operations
- Viewing and Editing Files
- Hard and Symbolic Links
- File Permissions and Ownership
Module 3: Advanced Command-Line Skills
- The Shell Environment: Variables, Aliases and History
- Using Wildcards and Regular Expressions
- Searching Files and Content: find, locate and grep
- Pipes and Redirection
- Text Processing: cut, sort, uniq, sed and awk
- Process Management
- Scheduling Tasks with Cron
- Networking Commands
Module 4: Shell Scripting
- Introduction to Shell Scripting
- Variables and Data Types
- Script Input, Output and Arguments
- Control Structures
- Functions and Libraries
- Debugging and Error Handling
- Production Scripts: Best Practices
Module 5: System Administration
- User and Group Management
- sudo and Special Permissions
- Package Management
- Disk Management
- systemd and Service Management
- System Logs: journald and syslog
- System Monitoring and Performance Tuning
- Backup and Restore
Module 6: Networking and Security
- Network Configuration
- SSH and Remote Access
- Firewalls and Perimeter Security
- Intrusion Detection Systems
- Secrets Management and TLS Certificates
- Securing Linux Systems
Module 7: Advanced Topics
- The Boot Process and System Recovery
- Advanced Diagnostics: strace, perf and eBPF
- Linux Kernel Tuning
- Virtualization with Linux
- Linux Containers and Docker
- Automation with Ansible
- High Availability and Load Balancing
