Signal9Signal9 — home
online
all lessons

Text Filters: Reshaping Lines with cut, sort, uniq, wc & tr

Beginner30 min6 steps
What you'll learn
  • Explain the filter model: a tool that reads lines on stdin, transforms them, and writes lines on stdout — chained with pipes
  • Pull columns out of each line with cut (-d delimiter, -f fields, -c characters)
  • Order data with sort (-n numeric, -r reverse, -k by field) and collapse duplicates with uniq — and know why uniq needs sorted input
  • Count with wc, translate/delete characters with tr, and slice the top or bottom with head/tail — then combine them into a count-and-rank pipeline
  • Compare two sorted files with comm (-23 = only in the first, -12 = in both) and merge them on a shared key with join
  1. A filter reads lines and writes lines

    See the shared shape of every text filter — and where it fits after finding files and searching them.

    You can now locate files (find) and search inside them (grep). The third everyday skill is reshaping the lines you get back — pulling out a column, sorting, counting, deduping. The tools that do this are filters.

    A filter follows one simple contract: it reads lines on standard input, does one small transformation, and writes lines on standard output. Because input and output are both just streams of lines, filters snap together with pipes — the output of one becomes the input of the next. This is the Unix philosophy in action: instead of one giant do-everything command, you chain a few tools that each do one job well.

    The filters worth knowing:

    • `cut` — pull out columns (fields or character ranges) from each line.
    • `sort` — put the lines in order.
    • `uniq` — collapse adjacent duplicate lines (and count them).
    • `wc` — count lines, words, or characters.
    • `tr` — translate or delete individual characters.
    • `head` / `tail` — take just the first or last few lines.

    Each reads lines and writes lines, so any of them drops into a pipeline. The art is picking the right small tools and ordering them.

    printf 'banana\napple\ncherry\napple\n' | sort | uniq
    apple
    banana
    cherry
    Three filters in a row: printf produces four lines, sort orders them, and uniq collapses the now-adjacent duplicate 'apple'. Each tool did one job and passed its lines along — that's the whole model.
  2. cut: pull columns out of each line

    Extract specific fields or character ranges from structured lines.

    Lots of data is columnar — each line is fields separated by a delimiter (a comma in a CSV, a colon in /etc/passwd, spaces in a log). `cut` pulls out the columns you want:

    • `-d` sets the delimiter (the separator character). -d',' for CSV, -d':' for /etc/passwd. The default is a tab.
    • `-f` picks the fields by number, starting at 1. -f1 is the first field; -f1,3 is fields 1 and 3; -f2-4 is a range.
    • `-c` picks character positions instead of fields: -c1-8 is the first eight characters of each line.

    So cut -d':' -f1 /etc/passwd prints just the username from each line — the text before the first colon. The mental model: cut slices every line at the delimiter and keeps the pieces you name.

    printf 'ada:1000:/home/ada\nlinus:1001:/home/linus\n' | cut -d':' -f1,3
    ada:/home/ada
    linus:/home/linus
    Split each line on ':' (-d':') and keep fields 1 and 3 (-f1,3) — the name and the home directory, dropping the number in the middle. cut re-joins the kept fields with the same delimiter.

    Check yourself

    To print only the first field of a comma-separated line, which two cut options do you need?

  3. sort and uniq: order, then dedupe and count

    Order lines the right way and collapse duplicates — and internalize why uniq needs sorted input.

    `sort` puts lines in order. By default it sorts lexically (as text), which surprises people with numbers: as text, 10 comes before 2 because it compares character by character. The flags fix this:

    • `-n` sorts numerically (so 2 before 10).
    • `-r` reverses the order (largest/last first).
    • `-k N` sorts by field N, not the whole line (pair with -t to set the field separator, like cut's -d).

    `uniq` collapses duplicate lines — but with a catch that trips up everyone: it only removes duplicates that are adjacent. It compares each line to the one right before it. So uniq on unsorted data misses duplicates that aren't next to each other. The fix is a rule: `sort` first, then `uniq`. Once the data is sorted, all copies of a line are adjacent, and uniq can collapse them.

    uniq's star flag is `-c`: it prefixes each line with a count of how many times it occurred. sort | uniq -c is the canonical "tally up how many of each" move.

    When all you want is the distinct values, `sort -u` (unique) sorts and drops the duplicates in one step: the same lines as sort | uniq, with one less process. Keep uniq for when you need what only it can do, like -c counts.

    printf '2\n10\n1\n2\n' | sort -n | uniq -c
          1 1
          2 2
          1 10
    sort -n orders the numbers correctly (1, 2, 2, 10 — not text order), which puts the two 2's next to each other. Then uniq -c collapses them and prints a count in front: '2 2' means the value 2 appeared twice. Without sort -n first, uniq would never see those 2's as adjacent.
    printf 'banana\napple\nbanana\ncherry\napple\n' | sort -u
    apple
    banana
    cherry
    -u
    unique: output only the first of each run of equal lines
    Five lines in, three out: sort -u put them in order and kept one copy of each. This is the one-flag answer to 'give me the distinct values, sorted', the same result as sort | uniq without the second process.

    Check yourself

    Sorting the lines 2, 10, 1 with plain sort (no -n) puts 10 before 2. Which flag makes sort treat them as numbers?

  4. wc, tr, head and tail: count, translate, slice

    Round out the toolkit with counting, character translation, and taking the top or bottom.

    Three more filters finish the everyday set:

    • `wc` counts. `wc -l` counts lines (the one you'll use most — 'how many matches?'), -w counts words, -c counts bytes. grep … | wc -l answers 'how many lines matched?'.
    • `tr` translates or deletes individual characters. tr 'a-z' 'A-Z' upper-cases; tr -d '\r' deletes carriage returns (handy for cleaning Windows line endings); tr -s ' ' squeezes repeated spaces into one. Note tr works on characters from stdin only — it takes no filename.
    • `head` and `tail` take a slice. `head -n 5` prints the first 5 lines; `tail -n 5` the last 5. tail has a superpower: `tail -f` follows a file, printing new lines as they're appended — the standard way to watch a live log.

    These are the 'ends and edges' tools: count how much, tidy the characters, or grab just the top or bottom of a stream.

    printf 'INFO ok\nERROR bad\nERROR worse\nWARN meh\n' | grep ERROR | wc -l
    2
    A two-filter pipeline: grep keeps the ERROR lines, then wc -l counts them. This 'grep X | wc -l' pattern — how many lines match? — is one of the most common one-liners you'll write.
    printf 'hello world\n' | tr 'a-z' 'A-Z'
    HELLO WORLD
    tr maps every character in the first set to the character at the same position in the second set, so a→A, b→B, and so on — upper-casing the line. tr reads from stdin and writes to stdout; it never takes a filename.
    printf 'Too   MANY    spaces\n' | tr -s ' ' | tr 'A-Z' 'a-z'
    too many spaces
    -s
    squeeze: collapse each run of a repeated character in the set to one
    Two more tr jobs in one pipe. tr -s ' ' squeezes every run of repeated spaces down to a single space, and tr 'A-Z' 'a-z' is the mirror of the last example: it lower-cases. No regex and no fields, because these are pure character jobs.
  5. comm and join: compare and combine two sorted files

    Answer 'what's in one list but not the other?' with comm, and merge two tables on a shared key with join.

    Every filter so far reads one stream. Two classic tools read two sorted files side by side, which answers a family of questions nothing else here can:

    • `comm A B` compares the files line by line and prints three columns: lines only in A, lines only in B (indented one tab), and lines in both (indented two tabs). You almost never want all three, so you suppress the ones you don't: -1, -2 and -3 hide columns 1, 2 and 3. comm -23 A B leaves only column 1, the lines only in A (a set difference). comm -12 A B leaves only column 3, the lines in both (an intersection). comm -13 A B is what B has that A lacks.
    • `join A B` is a relational join: for each key (the first field, by default) that appears in both files, it prints the key followed by the rest of A's line and the rest of B's. Keys found in only one file are dropped.

    Both tools walk the two files in step, which only works if both are sorted on the part they compare: the same rule as uniq, for the same reason. Sort first if you aren't sure.

    printf 'alice\nbob\ncarol\ndave\n' > all.txt && printf 'bob\ndave\nerin\n' > active.txt && comm all.txt active.txt
    alice
    		bob
    carol
    		dave
    	erin
    comm A B
    column 1 = only in A, column 2 = only in B, column 3 = in both
    Two sorted rosters and all three columns. alice and carol sit at the left margin (only in all.txt), erin is indented once (only in active.txt), and bob and dave are indented twice (in both). Read the indentation, not the order: comm interleaves the columns as it walks both files together.
    comm -23 all.txt active.txt; echo '--- in both ---'; comm -12 all.txt active.txt
    alice
    carol
    --- in both ---
    bob
    dave
    -23
    hide columns 2 and 3: lines only in the first file
    -12
    hide columns 1 and 2: lines in both files
    Suppress columns to ask a precise question. -23 hides 'only in active' and 'in both', leaving the accounts that are on the full roster but not active: a set difference in one command. -12 hides the two 'only' columns and leaves the intersection.
    printf '1 ada\n2 linus\n3 grace\n' > names.txt && printf '1 ops\n3 dev\n4 sec\n' > teams.txt && join names.txt teams.txt
    1 ada ops
    3 grace dev
    join A B
    pair lines whose first fields match; print key + rest of A + rest of B
    Two tables keyed on an id in field 1, both sorted by that id. join pairs the lines whose keys match and prints key, then names.txt's other fields, then teams.txt's: '1 ada ops'. Id 2 has no team and id 4 has no name, so neither appears. That is a database-style inner join with no database.

    Check yourself

    Two sorted files, old.txt and new.txt. Which comm command prints only the lines that appear in BOTH?

  6. Now do it — build a count-and-rank pipeline

    Chain the filters into the classic 'which values are most common?' one-liner.

    One continuous experiment building the most useful pipeline in all of text processing: cut a field, sort it, count each value, then rank them. This 'top-N' shape answers 'which IP hit us most?', 'which status code is most common?', 'who ran the most commands?' — all the same pattern.

    printf 'GET /a\nGET /b\nPOST /a\nGET /a\nPOST /c\n' > reqs.txt && cut -d' ' -f1 reqs.txt
    GET
    GET
    POST
    GET
    POST
    Make a small request log, then cut the first field (the HTTP method, split on a space). We've isolated just the column we want to count — the first stage of the pipeline.
    cut -d' ' -f1 reqs.txt | sort | uniq -c
          3 GET
          2 POST
    Add sort (so identical methods sit together) and uniq -c (to collapse and count). Now we have a tally: GET appeared 3 times, POST 2. The sort is essential — without it uniq couldn't see the GETs as adjacent.
    cut -d' ' -f1 reqs.txt | sort | uniq -c | sort -rn | head -n 1; echo '--- cleaning up ---'; rm reqs.txt
          3 GET
    --- cleaning up ---
    The final two stages: sort -rn ranks the tally by count, highest first (numeric, reversed), and head -n 1 keeps only the top line. The whole pipeline — cut | sort | uniq -c | sort -rn | head — is the count-and-rank idiom you'll reuse constantly. Five small filters, each doing one job.
Recap
  • A filter reads lines on stdin, does one transformation, and writes lines on stdout — so pipes chain them. Pick small tools and order them, rather than reaching for one big command.
  • cut pulls columns from each line: -d sets the delimiter, -f picks fields (from 1), -c picks character positions.
  • sort orders lines (-n numeric, -r reverse, -k by field); uniq collapses ADJACENT duplicates and -c counts them — which is why you almost always sort before uniq. sort -u sorts and dedupes in one step.
  • comm compares two sorted files into three columns (only in A, only in B, in both); suppress the ones you don't want: -23 = only in A, -12 = in both. join merges two sorted files on their first field. Both need sorted input, just like uniq.
  • wc -l counts lines, tr translates/deletes characters (stdin only), head/tail take the first/last lines (tail -f follows a live file). The count-and-rank idiom is cut | sort | uniq -c | sort -rn | head.
Text filters at a glance
You writeEffect
cut -d',' -f2 fileprint the 2nd comma-separated field of each line
sort -n / sort -rorder numerically / in reverse
sort -k2 -t:sort by the 2nd colon-separated field
sort | uniqremove duplicate lines (sort makes copies adjacent)
sort -usort and remove duplicates in one step
sort | uniq -ccount how many times each line occurs
grep X | wc -lcount how many lines match X
tr 'a-z' 'A-Z'upper-case every character
tr -s ' 'squeeze runs of spaces into one
comm -23 a blines only in a (both files sorted)
join a bmerge lines that share a first-field key
cut … | sort | uniq -c | sort -rn | headthe count-and-rank pipeline