Text & Pipes: How Small Tools Combine
"Every command speaks plain text. Pipes let them talk to each other."
So far you've run one command at a time and read its output on screen. Here you'll capture output into files, feed one command's output into another, and search, count, and reshape text the way experienced people do. This is where the terminal stops feeling like a museum piece: once output can move into files and into other commands, small tools multiply — and you'll recognize the same style in a lot of AI-suggested one-liners.
4.1 Redirection — Point the Output Somewhere Else
Every command writes its results to something called standard output — which is normally your screen. Redirection says: "don't print that, put it in a file instead."
One character does it: >.
echo "hello" > greeting.txt # Create the file (or OVERWRITE it!)
cat greeting.txt
hello
echo "hello again" >> greeting.txt # >> APPENDS to the end
cat greeting.txt
hello
hello again> truncates. The instant you press Enter, the target file is emptied — before the command even
runs. Redirect into an existing file and its old contents are gone, no confirmation, no
trash can. When in doubt, use >> (append) or redirect to a new filename. And when an AI hands you a command
containing >, check what's on the right side of the arrow before you run it.Standard output isn't the only channel — every command is born with three. Standard input (stdin) is where it reads from, normally your keyboard; standard output (stdout) is where its results go; and standard error (stderr) is a separate channel for its complaints, kept apart so a failure never gets mixed in
with the results. They're numbered 0, 1 and 2 — which is the whole story of the 2 in 2>. A plain > captures stream 1 only, so errors still land on your screen. To catch those too:
2> ls reports/ missing/ > listing.txt 2> errors.txt
cat listing.txt # The successful part
q1-summary.md
q2-summary.md
cat errors.txt # The complaint went to its own file
ls: missing/: No such file or directoryYou'll also meet > all.log 2>&1 in AI-generated commands, and it reads left to right: send stream 2 to wherever stream 1 is currently
pointing, which the first half of the line has just set to all.log. The &1 is what makes it a reference to a stream rather than a filename — drop the & and you create a file called 1. Finally, the arrow points the other way too: < feeds a file into a command as its input, as in sort < names.txt. It's rarer — most commands happily take a filename
argument — but it completes the picture: arrows move text in and out of files.
One member of the family points both ways at once. tee — named for the T-shaped
pipe fitting, and it is exactly that — sits in the middle of a stream, writes everything flowing
through it into a file, and passes the same text along untouched. It's the answer to a want the
plain arrow can't satisfy: "show me this and keep a copy."
tee — watch it and save it npm run build | tee build.log
# The build scrolls past as usual — AND lands in build.log
npm test 2>&1 | tee test.log # errors included, still watching liveWith a plain > you trade the live view for the file; tee refuses the trade. That makes it the receipt habit for anything long-running — let an agent's
build talk to the screen while tee keeps the transcript, and "what did it actually
say?" always has an answer. (tee -a appends instead of overwriting, the same courtesy >> extends.)
"Run the build and save all output — including errors — to build.log so I can read it later"
"Append today's date and a one-line status to my notes.txt without overwriting what's there"
Try It: Catch the Red Text
found.txt, the scary error into errors.txt — with > and 2>.Loading playground...
4.2 Pipes — Small Tools, Composed
Redirection sends output into a file. The pipe — | — sends it into another command. Whatever the left command prints becomes the right
command's input, no temporary file needed. This one character is the reason terminal users
never left.
ls ~/Downloads | wc -l # How many things are in Downloads?
47
history | tail -5 # The last 5 commands you ran
cat server.log | grep "ERROR" | wc -l # How many errors in the log?
12Two small things in there. history prints the shell's own record of what you've
typed — it keeps one, whether or not you asked, and History Superpowers puts that record to work. And tail -5 is shorthand for tail -n 5: for head and tail, a bare dash-and-number is a line count. Not every dash-and-number is:
the 9 in kill -9 is a signal number, which Stopping Things unpacks.
Read a pipeline left to right, like an assembly line: the text flows through each station and comes out transformed. Here's the last example as a picture:
This is the Unix philosophy, and it's
older than most programming languages you know: "Write programs that do one thing and do it
well," as Doug McIlroy — the inventor of the pipe — put it. grep only searches. wc only counts. sort only sorts. None of them is impressive alone — but because they all speak plain text, any of them
can feed any other, and a handful of tiny tools becomes thousands of combinations.
| and look again. This isn't just how you write pipelines — it's how you audit the ones your AI writes. A five-stage pipeline you can't follow is really five
one-stage commands you haven't run yet."Explain this pipeline stage by stage before I run it: cat access.log | grep POST | wc -l"
"Write me a pipeline that counts how many files in this folder end in .png"
4.3 Searching Text — grep
A server just crashed and the log is 10,000 lines long. Nobody scrolls that. grep prints only the lines that match a pattern — it's the terminal's search box, and probably the
most-used tool in this course.
That 10,000-line log is a log file — a
plain text file a program keeps appending to as it works, one line per event, oldest at the
top. Most programs tag each line with a severity word: DEBUG for chatter, INFO for normal, WARN and ERROR for trouble. That convention is why searching for the bare word ERROR works at all.
grep basics grep "ERROR" server.log # Lines containing ERROR
2026-07-11 14:02:11 ERROR db connection refused
2026-07-11 14:02:15 ERROR retry limit reached
grep -r "TODO" src/ # Search every file under src/
src/app.js:14: // TODO: handle empty cart
src/utils.js:3: // TODO: remove this hackBoth patterns above are plain words, which is why nothing surprising happened. The moment
you reach for a wildcard, though, you've changed languages. grep doesn't use globs. It uses regular expressions — a second, older pattern language that looks deceptively similar and means different things.
Here * means "zero or more of whatever came just before it", . matches any single character, and ^ and $ anchor a match to the start and end of a line. The trap isn't hypothetical: ERR* as a glob means "starts with ERR", but as a regular expression it means "ER followed by any number
of Rs" — so it matches ER anywhere in any line, and quietly hands you the wrong answer.
| Character | In a glob (filenames) | In a regular expression (grep) |
|---|---|---|
* | Any run of characters | Zero or more of the character before it |
? | Exactly one character | An ordinary question mark |
. | An ordinary dot | Any single character |
The glob column is the one you drilled in Wildcards, and it still
governs every filename you type. The regex column governs what's inside the quotes you hand
to grep, and later to sed and awk. Same characters, different room.
Five flags cover 95% of real-world grep:
-i — ignore case
Matches error, Error, and ERROR. Use it by default when hunting in logs.
-n — line numbers
Prefixes each match with where it lives — essential when you're about to open the file and fix it.
-v — invert
Keep the lines that don't match. Perfect for filtering noise out: grep -v "DEBUG".
-c — count
Print how many lines matched instead of the lines themselves — a built-in | wc -l.
-r — recursive
Search a whole directory tree instead of one file. grep -rn "api_key" . is how developers answer "where in this codebase is that used?"
And because grep reads standard input, it slots into any pipeline as a filter.
This is the pattern you'll type every single day:
grep as a pipeline filter history | grep "cd" # Every cd you've ever run
tail -50 server.log | grep -i "error" # Only recent errors
grep "ERROR" server.log | grep -v "retry" # Errors, minus the noisy onesTry It: Find the Crash
server.log has hundreds of lines. Use grep — with -i, -n, and -v to cut the noise — and pipes to wc -l to pin down exactly when and why it crashed.Loading playground...
"Search server.log for errors, ignoring case, and show me the line numbers"
"Find every file in this project that still mentions the old function name 'fetchUser'"
4.4 Counting & Shaping — wc, sort, uniq, cut
grep finds lines. These four tools reshape them: count them, order them, de-duplicate them,
and slice out columns. Individually they're almost boring — together they answer real questions,
like "who's hammering my website?"
wc -l server.log # Count lines (-w words, -c bytes)
843 server.log
sort names.txt # Alphabetical order (-n numeric, -r reversed)
uniq names.txt # Collapse REPEATED ADJACENT lines into one
cut -d',' -f2 users.csv # Slice column 2, using ',' as the delimiterThat last line hides a rule: some flags take a value. -d',' is one flag — d for delimiter — with its value jammed straight onto the letter, and -f2 is f for field, value 2. Other commands want a space between the two, as in -n 3, and many take either. So -f2 is not two clustered flags the way ls -la is — where the letter ends and its value begins is each command's own decision, and man cut is what tells you.
uniq gotcha: uniq only collapses duplicates that are next to each other. Given a b a, it removes nothing. That's why it practically always appears as sort | uniq — sort herds the duplicates together first, then uniq collapses
them.Try It: Count Before You Fix
grep ERROR server.log into wc -l to count the error lines, and save the total to a file.Loading playground...
Now the payoff. Your web server writes one line per visit to access.log, and
you want to know your top visitors. This is the most famous pipeline in Unix history, and
we'll build it one stage at a time — exactly the way you should build every pipeline.
head -4 access.log
203.0.113.9 GET /home
198.51.100.4 GET /pricing
203.0.113.9 GET /docs
192.0.2.55 GET /homeTwo things to read before you start slicing. The number at the front is the visitor's IP address — each machine on a network has one, and What Is localhost? takes it apart.
And /home and /pricing are URL paths — the part after the site's address — not folders on this machine. Same
slashes, different tree.
cut: keep only the IP column cut -d' ' -f1 access.log
203.0.113.9
198.51.100.4
203.0.113.9
192.0.2.55
...
# -d' ' = columns are separated by spaces; -f1 = give me field 1sort: herd the duplicates together cut -d' ' -f1 access.log | sort
192.0.2.55
198.51.100.4
198.51.100.4
203.0.113.9
203.0.113.9
203.0.113.9
...uniq -c: collapse and count cut -d' ' -f1 access.log | sort | uniq -c
1 192.0.2.55
2 198.51.100.4
3 203.0.113.9
...
# -c = count: each collapsed group carries a tally of how many lines it stood for.
# (Not grep's -c, which prints one total and no lines at all.)sort -n: rank by the count cut -d' ' -f1 access.log | sort | uniq -c | sort -n
1 192.0.2.55
2 198.51.100.4
3 203.0.113.9
# Biggest number last — your top visitor is 203.0.113.9Try It: Build a Pipeline
access.log is waiting in the playground. Build the classic pipeline stage by stage — cut, then | sort, then | uniq -c, then | sort -n — and identify your site's top visitor. Run each stage before adding
the next.Loading playground...
"From access.log, show me the top 5 most-requested pages with their counts"
"How many unique visitors are in this log file? Walk me through the pipeline you use"
4.5 Finding Files — find
grep searches inside files. But sometimes the question is "where is that file?"
— a config you know exists, every Markdown file in a project, that one script you wrote last
month. find walks an entire directory tree and prints every path that matches your criteria.
find essentials find . -name '*.md' # Every Markdown file below here
./README.md
./docs/setup.md
./docs/notes/ideas.md
find . -name '*.md' -type f # -type f: files only
find . -type d -name 'test*' # -type d: directories only
find ~ -name '.zshrc' # Start the search from your home folder'*.md' with quotes, not bare *.md. Remember Copy, Move, Delete: the shell expands
wildcards before the command ever runs. Unquoted, *.md becomes a list of the Markdown files in your current folder — and find receives that list instead of the pattern, searching for the wrong thing entirely. Single quotes
are the fully literal kind, so the pattern reaches find intact and gets applied
at every level of the tree — the rule for both kinds of quote is in Paths.Keep the two search tools straight — they're a team, not rivals:
find — searches filenames
"Where are the files called *.md?" It never opens a file; it only looks
at names, types, and locations.
grep — searches contents
"Which lines contain TODO?" It reads inside files but doesn't care what
they're named.
And naturally, they compose. Find the files by name, then grep inside exactly
those:
find + grep, together find . -name '*.js' | xargs grep -n "TODO"
./src/app.js:14: // TODO: handle empty cart
./src/utils.js:3: // TODO: remove this hack
# (For a whole tree, grep -rn "TODO" . is the simpler everyday version.)xargs is there because grep wants filenames as arguments, not on standard input. Pipe a list of paths straight into grep and it dutifully searches the text of those paths instead of the files they name —
a pipeline that runs, returns nothing, and looks like an answer. xargs collects the lines arriving on standard input and lays them out as arguments, which is the shape grep was waiting for. Reach for it when you want to narrow the file set first — by name, type or depth
— and reach for grep -rn when you want everything under a folder.
Try It: Hunt Down Every TODO
find . -name '*.js' -type f to locate the source files (quote that glob!), then combine with grep to list every TODO with its file and line number.Loading playground...
"Find every Markdown file in this project, including ones in nested folders"
"List all the TODO comments left anywhere in src/ with their file names and line numbers"
Notice what all of these have in common: they find and count — none of
them changes a single character. That's deliberate, and it's why they're safe to experiment
with. When you're ready to make text different — swap a word across a file, drop every DEBUG line, pull one
column out of a table — that's sed and awk, waiting in Text Surgery.
Loading challenge...