learn/CompTIA Linux+

Text processing

Finding one line in a million

Lesson 20 of 76·Working knowledge·21 min read·updated ·linuxlinux-plusshellgrepawksed

On the examCompTIA Linux+ XK0-006 (V8)

  • 1.5Given a scenario, manage a Linux system using common shell operations.Domain 1.0 System Management·23% of the exam

All 29 objectives, and which are covered

Before this

What you will be able to do

  • Search a file with grep and a basic regular expression
  • Pull one column out of structured text and count what is in it
  • Rewrite text with sed and select fields with awk
  • Build the sort, uniq -c, sort -rn pipeline and say what each stage does

Before you read. A web server has been misbehaving. Its log has 1.2 million lines in it. Somewhere in there is the answer.

You cannot read 1.2 million lines. You cannot open it in an editor. And you do not yet know what you are looking for, so you cannot search for it either.

How do you get from a million lines to a handful without knowing in advance which handful you want?

By asking questions of the shape of the data rather than its content. Which addresses appear most often. Which status codes. How many of each. Those answers fit on a screen, and one of them will tell you what to search for.

That is one pipeline, four commands long, and it is the single most useful thing in this lesson. The rest is the vocabulary that lets you build variations of it.

The example log throughout is small enough to check by eye, so you can verify that each command did what the prose claims. Everything works identically at a million lines.

Some words you will need

regular expression
A pattern language for describing text. grep and sed both take one.
field
One column of structured text, decided by a separator. Field 1 of a log line is usually the address.
delimiter
The character separating fields. A space in a log, a colon in /etc/passwd, a comma in a CSV.
filter
A command that reads stdin, changes it, and writes stdout. Everything here is one, which is why they compose.

What breaks without this

Investigation stops at the size of the file. Anything you cannot read by eye becomes unusable, which is every log on every real system.

You answer the wrong question. “Is the error in there?” is a yes-or-no with little value. “Which client is causing them and how many?” is the useful one, and it needs counting rather than searching.

Reports become manual work. Anything you do by reading and tallying is something a four-command pipeline does correctly every time.

grep: finding lines

# Debian 13 (trixie), x86_64
$ cd /tmp; grep 403 access.log
10.0.0.14 - - [07/Aug/2026:09:14:11] "GET /admin HTTP/1.1" 403 199
10.0.0.14 - - [07/Aug/2026:09:16:02] "GET /admin HTTP/1.1" 403 199
10.0.0.14 - - [07/Aug/2026:09:17:01] "GET /admin HTTP/1.1" 403 199
10.0.0.14 - - [07/Aug/2026:09:19:55] "GET /admin HTTP/1.1" 403 199

grep prints lines that match. That is the whole idea, and the flags are where the value is:

Flag Does
-i Ignore case
-v Invert: print lines that do not match
-c Count matching lines instead of printing them
-n Show line numbers
-r Search a directory tree
-l Print only the names of files that matched
-w Match whole words only
-A 3 / -B 3 / -C 3 Also print lines after, before, or around each match
-E Extended regular expressions
-q Print nothing, answer with the exit status

-v is the one people forget and it is half the job. Excluding the noise is frequently faster than describing the signal: grep -v ' 200 ' access.log gives you every request that was not a success.

-C 3 is what you want when reading a stack trace or a service log, because the line that matched is rarely the whole story.

Enough regular expression to be useful

Pattern Matches
error that literal text, anywhere in the line
^error only at the start of a line
error$ only at the end
. any single character
.* any number of any characters
[0-9] one digit
[a-z]* any number of lowercase letters
\. a literal dot

Anchors are the highest-value two characters here. ^root finds the root account in /etc/passwd; plain root also finds every account whose home is under /root and every comment mentioning it.

grep -E turns on extended expressions, which add +, ?, |, and () without backslashes: grep -E 'error|warning|fatal' logfile is the everyday use.

The pipeline that answers most questions

cut -d" " -f1 access.log | sort | uniq -c | sort -rn, four commands. Before you look, say what each one contributes and why sort appears twice.
# Debian 13 (trixie), x86_64
$ cd /tmp; echo "--- who is hitting us, most first ---"; cut -d" " -f1 access.log | sort | uniq -c | sort -rn
--- who is hitting us, most first ---
      5 10.0.0.14
      2 10.0.0.9
      2 10.0.0.31
      1 10.0.0.7
Four commands in a pipeline, and what the data looks like between them Each stage hands its output to the next as plain text. cut keeps the first field of every line, which is the client address. sort brings identical addresses next to each other. uniq -c collapses runs of identical adjacent lines and prefixes each with a count, which is why the sort before it is not optional: uniq only ever compares neighbouring lines. The final sort -rn orders those counts numerically, largest first. cut -d" " -f1 one address per line sort identical ones adjacent uniq -c counts each run sort -rn biggest count first uniq compares neighbours only, so removing the first sort silently gives wrong counts it still runs, still exits 0, and reports a count for every run it happens to find each stage passes plain text to the next
The accented stage is the one people leave out, and leaving it out does not produce an error. uniq only ever compares a line with the one before it, so on unsorted input it happily reports several separate counts for the same address and the total still adds up. The result looks plausible, which is what makes it dangerous.

cut -d" " -f1 keeps only the first space-separated field of each line, the client address, and throws away the rest.

sort puts identical addresses next to each other. This is the stage people leave out, and it is required by the next one.

uniq -c collapses runs of identical adjacent lines into one, with a count. uniq only compares neighbouring lines, which is exactly why the sort has to come first: unsorted input gives you a count of 1 for nearly everything.

sort -rn sorts the result numerically (-n) and in reverse (-r), putting the biggest count at the top. Without -n it sorts as text, where 9 comes after 10.

The answer: 10.0.0.14 made five of the ten requests, more than twice anyone else. That is the line to investigate, and we found it without knowing in advance that it existed.

This pipeline generalises to anything. Change the field number and you are counting status codes, or URLs, or usernames. It is the workhorse.

Reading it as a sentence: take one column, group the same values together, count each group, show the biggest first.

If you already administer Linux: sort's real flags, and why LC_ALL matters

sort is not sorting the way you think. It uses locale collation, so in most locales it ignores punctuation and case, and the order changes with LC_COLLATE. Two machines with different locales sort the same file differently, which breaks diff on generated files and produces build artefacts that are not reproducible. LC_ALL=C sort forces plain byte order and is what belongs in any script whose output is compared.

The flags worth knowing beyond -n and -r:

-h sorts human-readable sizes, so du -h | sort -h orders 1.5K, 2M, 1G correctly, which plain -n cannot do.

-k sorts by a field: sort -k3 -n file on the third column, and sort -t: -k3 -n /etc/passwd sorts accounts by UID. Note -k3 alone means “from field 3 to the end of the line”, and -k3,3 means field 3 only; the difference produces wrong answers that look plausible.

-u deduplicates, so sort -u replaces sort | uniq with one process.

-s is a stable sort, preserving the previous order within equal keys, which is what lets you chain two sorts to get a secondary ordering.

uniq has more than -c: -d shows only the duplicated lines, -u only the ones that appeared exactly once, and -f n skips the first n fields when comparing, which is how you dedupe log lines that differ only by timestamp.

If you already administer Linux: the three regex dialects, and why your pattern works in one tool and not another

“Regular expressions” on Linux means three different languages, and a pattern that works in grep -E failing in grep is not a bug.

| Dialect | Used by | + ? { } ( ) | | | — | — | — | | BRE, basic | grep, sed | Must be backslash-escaped to be special | | ERE, extended | grep -E, awk, sed -E | Special as written | | PCRE, Perl-compatible | grep -P, most languages | Special, plus much more |

So the same idea, three ways:

grep    '^[0-9]\{3\}-[0-9]\{4\}$'    # BRE: braces escaped to be special
grep -E '^[0-9]{3}-[0-9]{4}$'        # ERE: braces are special already
grep -P '^\d{3}-\d{4}$'              # PCRE: \d, lookarounds, non-greedy

The rule that makes BRE make sense: in basic expressions, + ? { } ( ) | are literal characters and you escape them to get the special meaning. In extended, it is the reverse. That inversion is why a pattern copied from a web page into grep matches nothing. It was written for ERE.

Use grep -E by default. It is what almost everyone means, it is POSIX, and it matches awk’s dialect so patterns move between them unchanged. egrep is the same thing under a deprecated name that now prints a warning.

grep -P is where the useful extras live (\d, \s, \b, lookahead and lookbehind, non-greedy *?) and it is worth knowing it is not universally available. It is a compile-time option, absent on some minimal builds and on macOS and the BSDs, so a script relying on it is less portable than it looks. perl -ne or python3 -c are the fallbacks when you genuinely need lookbehind.

Two portability notes that cost real time:

sed -E is GNU and BSD both, but sed -r is GNU only, and they mean the same thing. Prefer -E.

Character classes are locale-sensitive. [a-z] depends on the collation order of the current locale and can match accented characters, or not, depending on LC_ALL. [[:lower:]] is the portable form, and setting LC_ALL=C in a script both fixes the semantics and makes grep significantly faster on large files by skipping multibyte decoding.

cut, and its limitation

cut pulls out columns. -d sets the delimiter, -f picks the fields:

cut -d: -f1,7 /etc/passwd     # login name and shell
cut -d, -f2 data.csv          # second column of a CSV
cut -c1-8 file                # characters 1 to 8, ignoring fields entirely

cut treats every delimiter as significant, which is fine for /etc/passwd where the colons are exact and a real problem for text aligned with spaces. Two spaces between columns means an empty field between them, and -f2 returns nothing.

There is no cut flag that fixes this. awk is the answer, and that is largely why awk exists.

awk: fields, done properly

The second command below filters and prints in one pass: $8 == 403 selects the lines, {print $1} prints the address from each.

cut needed the delimiter spelled out and broke on runs of spaces. What does awk use to separate fields by default, and does it need telling?
# Debian 13 (trixie), x86_64
$ cd /tmp; echo '--- awk picks fields by number ---'; awk '{print $1, $8}' access.log | head -4; echo '--- and can filter as it goes ---'; awk '$8 == 403 {print $1}' access.log | sort -u
--- awk picks fields by number ---
10.0.0.14 200
10.0.0.9 200
10.0.0.14 403
10.0.0.31 200
--- and can filter as it goes ---
10.0.0.14

$1 is the first field, $8 the eighth, $0 the whole line, NF the number of fields. Any run of whitespace separates them, so the cut problem above does not arise.

An awk program is pattern { action }, and either half can be left out:

Program Does
{print $1} no pattern, so every line
/error/ {print} lines matching a regex
$8 == 403 {print $1} lines where field 8 equals 403
NF > 5 {print} lines with more than five fields
$9 > 1000 {print $1, $9} numeric comparison on a field

-F sets the separator for text that is not whitespace-delimited: awk -F: '{print $1}' /etc/passwd.

And awk can count as it goes, which is where it stops being a better cut:

# Debian 13 (trixie), x86_64
$ cd /tmp; echo '--- sed replaces ---'; sed 's/10\.0\.0\./10.0.0.x/' access.log | head -3; echo '--- how many bytes served in total ---'; awk '{total += $9} END {print total}' access.log
--- sed replaces ---
10.0.0.x14 - - [07/Aug/2026:09:14:02] "GET /index.html HTTP/1.1" 200 5120
10.0.0.x9 - - [07/Aug/2026:09:14:07] "GET /style.css HTTP/1.1" 200 812
10.0.0.x14 - - [07/Aug/2026:09:14:11] "GET /admin HTTP/1.1" 403 199
--- how many bytes served in total ---
17194

END { } runs once after the last line, which is how you accumulate a total. BEGIN { } runs once before the first, for headers and setting FS.

awk '{a[$1]++} END {for (k in a) print a[k], k}' replaces the whole sort | uniq -c pipeline with one pass and no sorting. The associative array is awk’s real party trick, and it is the version that scales to a file too large to sort.

sed: rewriting

sed 's/old/new/' substitutes. The pieces:

Means
s/a/b/ replace the first a on each line with b
s/a/b/g replace every occurrence
s/a/b/gi every occurrence, ignoring case
/pattern/d delete matching lines
-n '5,10p' print only lines 5 to 10
-i edit the file in place

In the capture above, 10\.0\.0\. has escaped dots, because an unescaped . matches any character. The dots are the point of the exercise: 10.0.0. would also match 100000x and a great deal else.

sed -i deserves suspicion. It does not edit in place: it writes a new file and renames it over the original, which breaks hard links, changes the inode, and needs free space. Run it without -i first and read the output. sed -i.bak keeps a copy, which costs nothing and has saved a great many afternoons.

If you already administer Linux: grep's faster relatives, and when to stop using these tools

grep -F searches for a fixed string with no regex interpretation, and is substantially faster on large files. grep -F -f patterns.txt bigfile matches against a list of literal strings from a file, which is the tool for checking a log against a list of known-bad addresses.

grep -P uses Perl-compatible expressions where they are compiled in, adding lookahead, non-greedy *?, and \d. Portable scripts should not rely on it. It is unavailable on some builds and -E covers most needs.

ripgrep (rg) and ag are dramatically faster on trees because they parallelise and skip what .gitignore excludes. Neither is on the exam and both are worth installing on a machine you use daily.

zgrep, zcat, zless read gzipped files without decompressing them first, which matters because rotated logs are compressed and /var/log/syslog.2.gz is where yesterday’s answer lives. There are bz and xz equivalents.

Know when to stop. These tools are for lines of text. The moment the data is JSON, jq is the correct answer and a grep-based approach will produce something that works on your sample and fails on the first nested object. For YAML, yq. For CSV with quoted fields containing commas, a CSV-aware tool, because cut -d, cannot parse it and neither can awk without help. Reaching for grep on structured data is a common way to build something subtly wrong.

awk is a full programming language with functions, arrays, and control flow. If your awk program has grown past a few lines, that is the signal to write it in something with a test suite rather than a signal to write more awk.

Everything else, briefly

Command Does
wc -l / -w / -c count lines, words, bytes
head -n 20 / tail -n 20 first or last lines
tr 'a-z' 'A-Z' translate characters
tr -d '\r' delete characters, this one fixes Windows line endings
tr -s ' ' squeeze runs of a character into one
xargs turn input lines into arguments for another command
sdiff a b diff in two columns, with the differing lines marked between them

tr -d '\r' earns its place. A config file edited on Windows has \r\n line endings, and the trailing carriage return becomes part of the last value on every line, so PORT=8080\r is not 8080, and the error message will not mention it. file thefile says with CRLF line terminators and dos2unix fixes it properly.

xargs bridges the gap between commands that produce lines and commands that take arguments:

grep -rl 'old.example.com' /etc | xargs sed -i.bak 's/old/new/g'

-r on xargs stops it running at all when the input is empty, which prevents a command running with no arguments and doing something unintended. -0 with find -print0 handles filenames containing spaces.

If you already administer Linux: the classic pipelines, and the ones worth memorising

A few that come up often enough to type from memory.

Top talkers in a log, the pipeline from earlier with a field number swapped:

awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -20

Status code distribution, which is the first thing to look at on a web server behaving oddly:

awk '{print $8}' access.log | sort | uniq -c | sort -rn

Largest directories, when a disk fills:

du -h --max-depth=1 /var | sort -h | tail -20

sort -h rather than -n, because the sizes have suffixes.

Which process is using a deleted file, when df and du disagree:

lsof +L1

Failed SSH logins by source, which is both a security check and a good demonstration that these tools are the whole toolkit:

journalctl -u sshd --since '24 hours ago' \
  | grep 'Failed password' \
  | awk '{print $(NF-3)}' \
  | sort | uniq -c | sort -rn

$(NF-3) counts backwards from the end of the line, which is how you address a field in text where the number of leading fields varies, a genuinely useful trick when the timestamp format is inconsistent.

Two files, what is in one and not the other:

comm -23 <(sort a.txt) <(sort b.txt)

comm needs sorted input and prints three columns (only in A, only in B, in both) and -23 suppresses the second and third, leaving only-in-A. The process substitution is from the previous lesson.

Across distributions

The tools are GNU coreutils, GNU grep, GNU sed, and GNU awk on every distribution this exam covers, so they behave identically. Two things to know anyway:

Note
awk Usually gawk, sometimes mawk on Debian minimal. mawk is faster and lacks some GNU extensions.
sed -i without a suffix GNU accepts it. BSD and macOS require an argument, so sed -i '' ....
grep -P Present on most builds, absent on some. -E is the portable choice.

The sed -i difference is the one that catches people writing on a Mac and deploying to Linux, and it fails loudly rather than subtly, which is the better kind of incompatibility.

Prove it

Build pipelines one stage at a time, checking after each:

# Stage 1: is the field right
cut -d" " -f1 access.log | head

# Stage 2: add the sort, confirm grouping
cut -d" " -f1 access.log | sort | head

# Stage 3: add the count
cut -d" " -f1 access.log | sort | uniq -c | head

# Stage 4: order it
cut -d" " -f1 access.log | sort | uniq -c | sort -rn | head

| head after each stage keeps the output readable while you check that the stage did what you meant. Building the whole thing and running it once produces output you cannot verify, and a wrong field number looks exactly like a correct one until you check.

What trips people up

1. uniq without sort first

uniq compares adjacent lines only. Unsorted input gives a count of 1 for almost everything, and the output looks plausible.

Always sort | uniq -c. Or sort -u when you want deduplication without counts.

2. A dot matches anything

grep 10.0.0.1 also matches 100.0.0.1, 10x0y0z1, and more. In a regular expression . is any character.

grep -F 10.0.0.1 for a literal string, or escape them: 10\.0\.0\.1.

.* is greedy in the same way and matches as much as it can, which is why s/".*"/X/ on a line with two quoted strings replaces everything between the first and last quote rather than each pair.

3. cut cannot cope with runs of spaces

Two spaces mean an empty field between them, so -f2 on aligned output returns nothing.

awk '{print $2}' treats any run of whitespace as one separator. Use cut for exact delimiters like /etc/passwd, awk for anything spaced by eye.

4. sed -i on a file you have not backed up

It rewrites the file with no undo, and a regex that matches more than you intended does so on every line at once.

Run it without -i and read the output. Then sed -i.bak.

5. Building the whole pipeline before testing any of it

A six-stage pipeline that produces nothing gives no clue which stage is at fault. Build up, checking with head after each.

Work it through

A web server is slow. The log is 1.2 million lines. Nobody knows what changed.

Reason through the order before reading on.

Do not search yet. You do not know what you are looking for, and grep requires knowing. Start with distributions, which need no hypothesis.

What status codes are we returning?

awk '{print $8}' access.log | sort | uniq -c | sort -rn

A normal shape is mostly 200s with a scattering of 304s and 404s. A large count of 500s means the application; a large count of 403s means something is being refused repeatedly; a large count of 499s or 408s means clients giving up, which points at slowness rather than errors.

Who is generating the traffic?

awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -20

If one address has a large share, that is a scraper, a broken client, or a health check that somebody set to one second. If it is flat, the load is genuine and this is a capacity question rather than an incident.

What are they asking for?

awk '{print $7}' access.log | sort | uniq -c | sort -rn | head -20

One expensive endpoint dominating is the common answer, and it names the thing to fix.

Now you know enough to search. Cross the two:

awk '$1 == "10.0.0.14" {print $7, $8}' access.log | sort | uniq -c | sort -rn | head

which tells you what that specific client was doing and whether it was being served or refused.

And check the time distribution, because “slow since when” is the question everyone asks next:

awk '{print substr($4, 14, 5)}' access.log | sort | uniq -c

substr pulls the hour and minute out of the timestamp field. A count per minute turns “slow today” into “started at 09:15”, and 09:15 is something you can correlate with a deployment.

Now the point worth extracting: every one of those is the same pipeline with a different field number. Extract a column, group, count, order. Learn it once and the only thing you vary is which column and which filter, which is why this lesson is short and covers more ground than it looks.

And the habit: start with distributions, not searches. A search needs a hypothesis; a distribution produces one. Beginning with grep means you find only what you already suspected, which on a problem nobody understands is nothing.

Try it

Optional, on any machine. /var/log/ has real material.

  1. grep -c bash /etc/passwd, then grep -v nologin /etc/passwd | wc -l.
  2. cut -d: -f1,7 /etc/passwd | head. Then cut -d: -f7 /etc/passwd | sort | uniq -c | sort -rn, how many accounts use each shell.
  3. awk -F: '$3 >= 1000 {print $1}' /etc/passwd, every real human account.
  4. awk -F: '{total += $3} END {print total}' /etc/passwd, which is meaningless and proves the mechanism.
  5. du -h --max-depth=1 /var 2>/dev/null | sort -h | tail.
  6. Build a pipeline one stage at a time with | head after each, and notice how much easier it is to spot a wrong field number.
  7. echo "hello world" | tr 'a-z' 'A-Z', then tr -s ' ' on text with runs of spaces.

Verification step. You have it when, given an unfamiliar structured file, you can produce a count of the most common value in any column without looking anything up.

Check yourself

Why must sort come before uniq -c, and what happens without it?

uniq only compares adjacent lines. It collapses runs of identical lines, not all identical lines anywhere in the file.

Without a preceding sort, identical values scattered through the file are never adjacent, so almost every line reports a count of 1. The output looks like a valid result (it has counts, it has values) and it is wrong.

sort brings identical values together so uniq can see them as a run.

sort -u is the shortcut when you only want deduplication and not counts, and it does both jobs in one process.

grep 10.0.0.1 access.log returns lines containing 100.0.0.14. Why, and what are two fixes?

. matches any single character in a regular expression. So the pattern matches 10, any character, 0, any character, 0, any character, 1, which 100.0.0.14 satisfies, along with a great many strings that are not addresses at all.

Fix one: escape them. grep '10\.0\.0\.1' makes each dot literal.

Fix two: grep -F 10.0.0.1, which turns off regex interpretation entirely and searches for the fixed string. It is also faster.

Worth adding -w in this case regardless: even a correct literal match for 10.0.0.1 matches 10.0.0.14, because it is a substring. grep -Fw handles both problems at once.

When would you reach for awk instead of cut, and why?

When the fields are separated by whitespace rather than by an exact character. cut treats every delimiter as significant, so two spaces mean an empty field between them and -f2 returns nothing. awk treats any run of whitespace as a single separator, which is what aligned output requires.

Also when the task involves anything beyond selection: comparing a field numerically ($8 == 403), accumulating a total in END, addressing a field from the end ($(NF-3)), or counting into an array.

cut remains the right tool for exact-delimiter data (/etc/passwd with its colons, a simple CSV) where it is simpler to read and marginally faster.

What does sed -i actually do to the file, and why should that make you careful?

It does not edit in place, despite the name. It writes the result to a new file and renames that over the original.

Three consequences. Hard links to the original are broken, because the new file is a different inode. The inode number changes, which matters to anything tracking it. And it needs free space for a second copy, so on a full disk it can fail partway, which is exactly when you were editing a file to free space.

Add the risk that a regex matching more than intended does so on every line at once, with no undo.

sed -i.bak keeps the original with a suffix and costs nothing. And running it without -i first, to read the output, is the habit worth having.

You have a 1.2 million line log and no hypothesis. What do you run first, and why not grep?

A distribution, not a search. Something like:

awk '{print $8}' access.log | sort | uniq -c | sort -rn

which counts each status code, or the same with $1 for client addresses.

grep requires knowing what you are looking for. It can only confirm or deny a suspicion you already hold, so on a problem nobody understands it returns either nothing or exactly the thing you already assumed, neither of which is information.

A distribution needs no hypothesis and produces one. It fits on a screen regardless of the input size, and the anomaly in it is what you then search for.

Start broad, narrow once the data has told you where to look.

References

  • grep(1) - Linux man-pages project. Accessed 2026-08-07.
  • sed(1) - Linux man-pages project. Accessed 2026-08-07.
  • cut(1) - Linux man-pages project. Accessed 2026-08-07.
  • sort(1) - Linux man-pages project. Accessed 2026-08-07.
  • uniq(1) - Linux man-pages project. Accessed 2026-08-07.
  • regex(7) - Linux man-pages project. Accessed 2026-08-07.
  • GNU Awk User’s Guide - GNU Project. Accessed 2026-08-07.

Every block above with a distribution and architecture header was captured by running the command on a Debian 13 (trixie) container. Blocks without one are illustrative.