Finding text and files the Unix way โ grep, awk, sort, and the pipe that ties them together.
Everything below runs on one sample file: a fake web server log called access.log. A log is ideal because it has repeated IPs and status codes, so counting and ranking commands produce real, interesting output.
Each line looks like this:
203.0.113.42 - - [10/Oct/2025:09:14:28 +0000] "GET /admin HTTP/1.1" 200 47045 "-" "PostmanRuntime/7.29"
The whitespace-separated fields you'll use most:
Grab the ready-made file straight onto your Linux box and follow along โ no setup, no scripts:
wget https://golu.net/access.log
# or, if you prefer curl:
curl -O https://golu.net/access.logIt's 500 lines of realistic traffic โ repeated IPs, mixed status codes and response sizes โ so every command below produces real, interesting output. โฌ Download access.log directly
The pipe is the whole trick. It takes the output of one command and feeds it as the input of the next. Instead of one giant command, you chain small ones, each doing one job:
command1 | command2 | command3
Read it left to right: do this, then pass it to that, then to that. This one idea powers every recipe on this page.
head shows the start of a file, tail shows the end. Both default to 10 lines.
head access.log # first 10 lines (default) head -n 5 access.log # first 5 tail access.log # last 10 lines tail -n 20 access.log # last 20 tail -f app.log # "follow" โ stream new lines as they arrive
Your "top 10" is literally head โ the default count. tail -f is the one to remember for live logs: it keeps printing new lines until you hit Ctrl-C.
-nhead 10 fails โ head reads 10 as a filename and errors out. Use head -n 10, or the shorthand head -10. Only the bare number with no dash is broken.
This is the one you'll use most. It keeps only the lines that match a pattern.
grep "404" access.log # lines containing 404 grep -i "post" access.log # -i: case-insensitive (matches POST) grep -n "500" access.log # -n: show line numbers grep -v "200" access.log # -v: invert โ lines that DON'T match grep -c "404" access.log # -c: count matching lines grep -r "TODO" ./src/ # -r: search a folder recursively
In grep, * means "zero or more of the previous character" โ not "anything." So grep 203* means 20 followed by zero-or-more 3s, which also matches plain 200. And . means "any single character." To match a literal IP, escape the dots: grep '203\.0\.113\.42'. When in doubt, drop the star: grep 203 just finds the substring.
Wrap any pattern containing *, ., ?, [, or $ in single quotes so the shell doesn't try to expand it as a filename before grep runs.
Different job from grep. find looks for files by name, size, or type โ it doesn't look at what's inside them.
find . -name "*.log" # all .log files under here find . -type f -size +10M # files bigger than 10 MB find /etc -name "ssh*" # files starting with "ssh"
grep searches inside files. find searches for files.
The single most important habit. Whatever comes first in the pipe runs first โ so where you put head relative to grep decides what you actually get.
head -n 10 access.log | grep 404 # 404s WITHIN the first 10 lines only grep 404 access.log | head -n 10 # search the whole file, then first 10 matches
Same tools, opposite meaning. Rule of thumb: head before grep = "search only the top of the file." grep before head = "find matches everywhere, then trim to 10." The second is almost always what you want.
cat file | grep x can just be grep x file โ grep reads files directly. Harmless either way, but good to know.
Reorders lines. Alphabetical by default; the flags are where the power is.
sort access.log # alphabetical (text) sort -r access.log # -r: reverse sort -n numbers.txt # -n: NUMERIC sort sort -k9 -n access.log # -k9: sort by the 9th field sort -u names.txt # -u: sort + drop duplicates
By default sort compares character by character, so it thinks "100" comes before "9" (it sees 1 < 9). Use -n to sort by actual numeric value. This is why the top-10 recipe uses sort -rn โ reverse and numeric, so the biggest counts land on top.
Find the five heaviest responses by sorting on the size field:
awk '{print $10, $7}' access.log | sort -rn | head -n 5uniq -c collapses duplicate lines and prepends a count. But there's a catch that trips everyone up:
uniq only collapses duplicates that are next to each other โ it compares each line to the one before it, nothing more. If identical lines are scattered, it misses them. sort first pulls them together so uniq can see them as a block.
Put it together and you get the classic "top N most frequent" idiom โ here, the most active IPs:
awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -n 10Reading it stage by stage:
awk '{print $1}' โ pull out just the IPsort โ group identical IPs together (required before uniq)uniq -c โ collapse duplicates, prepend a countsort -rn โ rank by count, biggest firsthead -n 10 โ keep the top 10 131 203.0.113.42
93 192.168.1.10
71 10.0.0.5
69 198.51.100.7
46 8.8.8.8
31 172.16.0.1
30 192.168.1.55
29 10.0.0.99Notice this uses two different sorts doing two different jobs: the first groups identical lines; the last ranks the counts. Swap $1 for $9 to rank status codes instead.
awk reads a file one line at a time and runs your instructions on each. The whole model is:
awk 'PATTERN { ACTION }' file"For every line where PATTERN is true, do ACTION." Leave out the pattern and the action runs on every line. Leave out the action and it just prints the line.
awk splits each line on whitespace and hands you numbered pieces:
$1, $2, โฆ โ the fields$0 โ the entire lineNF โ number of fields; $NF โ the last fieldNR โ the current line numberawk '{print $1}' access.log # the IP
awk '{print $1, $9}' access.log # IP and status (comma = space between)This is where awk replaces grep: it can compare a column to a number.
awk '$9 == 404 { print $0 }' access.log # status equals 404
awk '$10 > 40000 { print $7, $10 }' access.log # responses over 40 KB
awk '$9 == 404 && $10 > 20000' access.log # two conditions
awk '/admin/ { print $1 }' access.log # regex match, like grepBEGIN runs once before any lines; END runs once after all of them. Perfect for totals.
awk '{ total += $10 } END { print total }' access.log # sum all bytes
awk '{ t += $10; n++ } END { print t/n }' access.log # average size
awk '$9 == 404 { c++ } END { print c }' access.log # count 404sAn associative array is a tally sheet keyed by anything you choose:
# bytes sent to each IP โ grep/cut simply can't do this
awk '{ bytes[$1] += $10 } END { for (ip in bytes) print bytes[ip], ip }' access.log | sort -rnawk {print $1} fails two ways: the shell splits on the space (awk sees a broken {print โ "missing }"), and it expands $1 to the shell's own first argument (usually empty) before awk sees it. Single quotes tell the shell "hands off," so awk gets the intact program and reads $1 its own way โ as the first field. Use single quotes, never double (double still expands $).
grep: does this line contain X? ยท cut: give me column N. ยท awk: column N, but only when column M > 500, and sum column K. The moment you compare a column's value or do math, it's awk's job.
Ragged output is hard to read. column -t pads every column to line up like a table.
awk '{print $1, $9}' access.log | column -t | headAdd a header row via awk's BEGIN and let column align it with the data:
awk 'BEGIN {print "IP","STATUS"} {print $1,$9}' access.log | column -t | headIP STATUS 198.51.100.7 404 203.0.113.42 200 10.0.0.5 404
For fixed widths you control yourself, use printf inside awk โ %-16s means "string, 16 wide, left-aligned":
awk '{printf "%-16s %-8s\n", $1, $9}' access.log | headUse column -t only when a person reads the output. Drop it before wc, sort, or another grep โ those don't care about alignment, so it's wasted work.
There is no count command in Linux. The line counter is wc -l (word count, lines).
grep 404 access.log | wc -l # how many lines matchedWithout wc -l โ you see the actual lines (the content). With wc -l โ you see one number, the count. Same pipeline, two questions: "what are they?" vs "how many?"
Since counting matches is so common, grep does it in one step with -c:
grep -c 404 access.log # same result, no extra pipegrep -c '203' access.log scans whole lines, so it also catches 203 inside timestamps or byte sizes. Narrowing with awk first ($1 only) counts fewer. To count real IP matches: grep -c '203\.0\.113\.42' access.log.
Workflow: look first, then count. Run without wc -l to confirm you're matching the right stuff, then add | wc -l once you trust it.
Pipes send output to another command. Redirection sends it to a file.
grep 404 access.log > errors.txt # write to file (OVERWRITES) grep 500 access.log >> errors.txt # APPEND to the end head 10 2> whoops.txt # send ERRORS to a file (stream 2)
> wipes the file and writes fresh; >> adds to the end. Mixing these up is how people erase things โ burn it in early.
Normal output is "stream 1," errors are "stream 2" โ hence 2> for capturing error messages separately. You can redirect the end of any pipe:
awk '{print $1}' access.log | sort | uniq -c | sort -rn | head > top10.txtgrep -A 2 500 access.log # match + 2 lines After grep -B 2 500 access.log # 2 lines Before + match grep -C 2 500 access.log # 2 lines of Context both sides grep -oE 'HTTP/1.1" [0-9]+' access.log # -o: print only the matching part
cut -d' ' -f1 access.log | head # -d' ': split on space, -f1: field 1Same as awk '{print $1}' here. Use cut to slice a column; use awk when you need logic or math.
grep GET access.log | less # space=scroll, /word=search, q=quitThese mistakes teach more than the commands. Every one traces back to the same few causes.
| What broke | Why | Fix |
|---|---|---|
head 10 | 10 read as a filename | head -n 10 or head -10 |
grep 203* matched too much | * = zero-or-more of prev char (regex), so it matched 20 | grep 203 or escape: '203\.0' |
awk {print $1} | shell split the space & ate $1 | single-quote it: awk '{print $1}' |
... | count | no such command | ... | wc -l |
head | grep found nothing | only searched the first 10 lines | put grep before head |
1. Quote your patterns and awk programs (single quotes). 2. Mind the order of the pipe โ first runs first. 3. Know whether the next stage is a human (align it) or a machine (keep it raw).
| Task | Command |
|---|---|
| First / last 10 lines | head file ยท tail file |
| Follow a live log | tail -f file |
| Find matching lines | grep 'pat' file |
| Count matches | grep -c 'pat' file |
| Lines that don't match | grep -v 'pat' file |
| Find files by name | find . -name '*.log' |
| Pull out a column | awk '{print $1}' file |
| Filter by column value | awk '$9==404' file |
| Sum a column | awk '{s+=$10} END{print s}' file |
| Top 10 most frequent | โฆ | sort | uniq -c | sort -rn | head |
| Align as a table | โฆ | column -t |
| Count lines | โฆ | wc -l |
| Save (overwrite / append) | โฆ > f ยท โฆ >> f |
| Scroll long output | โฆ | less |
One small challenge a day, all on access.log. Try each yourself first, then reach for a hint only if stuck.
wc -l and with grep -c).top10.txt, then cat it back.Build it left to right and delete one stage at a time from the right end. Watching what changes at each step is the fastest way to understand why every piece is there.