In Module 1 you read that in Unix "plain text is the universal interface". It sounded like a statement of principle. In the previous lesson you saw the mechanism that makes it possible — pipes and redirection — and here come the tools that give it meaning: a set of small programs, written forty years ago, that know how to cut, sort, count, substitute and aggregate text, and that combined solve in one line what in another environment would require a program.

That is this lesson's concrete promise: bookings.csv and access.log are text files, and Marta needs reports. By the end you will be able to produce them without opening a spreadsheet and without writing code, and — more importantly — you will know when these tools stop being the right answer.

Contents

  1. cut: cutting out columns
  2. tr: translate, delete and squeeze
  3. sort: sorting properly
  4. uniq: counting occurrences
  5. paste, join and comm: comparing listings
  6. sed: the stream editor
  7. awk: the language of fields
  8. A decision table: which tool and when
  9. Case A: a billing report for Marta
  10. Cases B and C: error paths and anomalous IPs

  1. cut: cutting out columns

It extracts parts of each line, either by fields or by characters.

Option Effect
-d';' delimiter (a tab by default)
-f2,4 / -f2-5 fields: a list or a range
-c1-10 by character position
--complement everything except what is specified
--output-delimiter=X the output separator
operator@srv-tramontana:~$ head -3 /home/operator/data/bookings.csv
id;date;house;guest;nights;amount
1001;2026-07-03;mas-figueres;Nuria Bosch;4;520.00
1002;2026-07-05;can-ventos;Pere Rius;2;240.00
operator@srv-tramontana:~$ cut -d';' -f3,6 /home/operator/data/bookings.csv | head -2
house;amount
mas-figueres;520.00

cut is extremely fast and has one serious limitation: it does not understand repeated delimiters. With a file aligned by spaces, every extra space counts as an empty field:

operator@srv-tramontana:~$ echo 'GET   /bookings   200' | cut -d' ' -f2

Empty output: field 2 is the space between GET and the next thing. For output separated by variable numbers of spaces — which is what almost any command produces — the right tool is awk, which treats any block of whitespace as a single separator. cut for formats with a fixed delimiter (CSV, /etc/passwd); awk for everything else.

  1. tr: translate, delete and squeeze

tr operates character by character and only reads from stdin.

operator@srv-tramontana:~$ echo 'GET   /bookings   200' | tr -s ' ' | cut -d' ' -f2
/bookings
Option Effect
tr 'a' 'b' translates character by character
-d 'X' deletes the given characters
-s 'X' squeezes consecutive repetitions into one
-c complement: acts on the characters not listed

You have already seen it in action: tr -s ' ' solves the cut problem from the previous section. Other common uses: tr -d '\r' to clean up files coming from Windows — a classic source of incomprehensible failures — and tr -cd '[:print:]\n' to remove control characters from a dump.

  1. sort: sorting properly

Option Effect
-n numeric
-h numeric with human-readable suffixes (4K, 2M, 1G)
-V by version number (3.2.1 before 3.10.0)
-r reversed
-u removes duplicates
-f ignores case
-t';' field delimiter
-k3,3 sorts by field 3
-o file writes the output (allows the same file as input)
-s stable: does not reorder what was already tied

The -k syntax is the most widely misread. -kN means "from field N to the end of the line", not "by field N". To sort by one field only you have to write -kN,N.

operator@srv-tramontana:~$ tail -n +2 /home/operator/data/bookings.csv | sort -t';' -k6,6nr | head -3
1001;2026-07-03;mas-figueres;Nuria Bosch;4;520.00
1014;2026-08-02;mas-figueres;Jordi Camps;4;520.00
1007;2026-07-19;la-solana;Anna Serra;3;435.50

-t';' sets the separator, -k6,6nr sorts by field 6 numerically and in reverse. The n and r modifiers are attached to the -k so that they apply only to that field; on their own they would affect the whole line.

-V is specialised and very useful in our case: ls /opt/tramontana/releases/ | sort -V orders 3.1.0, 3.2.0, 3.2.1, 3.3.0 correctly, whereas a normal sort would put 3.10.0 before 3.2.0.

Remember from 03-01 that sort's result depends on the locale. When the output is going to be compared or version-controlled, LC_ALL=C sort.

  1. uniq: counting occurrences

Option Effect
-c prefixes each line with its number of repetitions
-d only the duplicated lines
-u only the ones appearing once
-i ignores case
-f N ignores the first N fields when comparing

uniq only compares adjacent lines. That is not a defect: it is what lets it process enormous files without memory. The consequence is that it requires sorted input.

operator@srv-tramontana:~$ printf 'a\nb\na\n' | uniq -c | tr '\n' ' '
      1 a       1 b       1 a
operator@srv-tramontana:~$ printf 'a\nb\na\n' | sort | uniq -c | tr '\n' ' '
      2 a       1 b

Hence the workhorse of log analysis, which you have already used without explanation:

sort | uniq -c | sort -rn

It groups equal things together, counts each group and sorts by descending frequency. It is the answer to any question of the form "what comes up most often?".

  1. paste, join and comm: comparing listings

paste sticks files together side by side (paste -d';' a.txt b.txt). join performs a relational join on a common field and requires both files to be sorted by it. comm compares two sorted files and produces three columns: only in the first, only in the second, in both.

operator@srv-tramontana:~$ cut -d';' -f3 /home/operator/data/bookings.csv | tail -n +2 | sort -u > /tmp/with-bookings.txt
operator@srv-tramontana:~$ comm -23 <(sort /home/operator/data/houses.txt) /tmp/with-bookings.txt

No output: all five houses in the catalogue have at least one booking. -23 suppresses columns 2 and 3 and leaves only "in the catalogue but with no bookings", which is exactly the question. <(...) is a process substitution: Bash delivers that command's output as if it were a file, which avoids creating temporaries.

  1. sed: the stream editor

sed reads the input line by line, and for each one it runs the commands you have given it and, unless you say otherwise, prints it. That cycle explains everything: sed does not load the file into memory, which is why it processes files of gigabytes.

Addresses

Address Selects
5 line 5
2,10 from 2 to 10
$ the last one
/regex/ the ones that match
/start/,/end/ from one to another
2~3 line 2 and then every 3rd
/regex/! the ones that do not match

The s command

s/pattern/replacement/flags, with these flags:

Flag Effect
g every occurrence in the line, not just the first
i ignores case
p prints the modified line (useful with -n)
3 only the third occurrence
w f writes the modified lines to f

The delimiter does not have to be /: any character will do, and using another one avoids the "picket fence of slashes" when the pattern contains paths.

operator@srv-tramontana:~$ echo '/opt/tramontana/releases/3.2.1' | sed 's|/opt/tramontana|/srv/app|'
/srv/app/releases/3.2.1

Capture groups work as in regexes: \1, \2… reuse them in the replacement and & stands for everything matched. With -E there is no need to escape parentheses or braces:

operator@srv-tramontana:~$ echo '1001;2026-07-03;mas-figueres' | sed -E 's/([0-9]{4})-([0-9]{2})-([0-9]{2})/\3\/\2\/\1/'
1001;03/07/2026;mas-figueres

Other commands

Command Does
d deletes (does not print) the line
p prints; combine it with -n
a text / i text appends after / inserts before
c text replaces the whole line
y/abc/xyz/ translates character by character, like tr
q quits (with sed '100q' you cut off at line 100)
operator@srv-tramontana:~$ sed -e '1d' -e 's/;/ | /g' /home/operator/data/bookings.csv | head -1
1001 | 2026-07-03 | mas-figueres | Nuria Bosch | 4 | 520.00

With -n, sed prints nothing of its own accord and only what p selects comes out: sed -n '/ERROR 500/p' errors.log returns the 41 error lines, that is, sed imitating grep. Several commands are chained with a repeated -e or by separating them with ;.

In-place editing: why -i.bak is mandatory

sed -i modifies the file directly. In production, never without a suffix.

operator@srv-tramontana:~$ sudo sed -i.bak-$(date +%F) 's/^log_level=debug$/log_level=info/' /etc/tramontana/app.conf
operator@srv-tramontana:~$ sudo diff -u /etc/tramontana/app.conf.bak-2026-08-18 /etc/tramontana/app.conf
@@ -6,2 +6,2 @@
-log_level=debug
+log_level=info

With -i.bak-$(date +%F), sed saves the original with that suffix before writing. Without it, if the pattern was wrong — and a badly written pattern can empty half a file without warning — there is no way back: sed does not ask, does not warn and does not undo. The course's prior-copy convention and this option are the same thing, built into a single command. Note as well the ^ and $ anchors: without them, the pattern would also match inside a comment.

The correct procedure is always in two steps: run sed without -i to see the result on screen, and only then add -i.bak-....

  1. awk: the language of fields

awk is a complete programming language disguised as a command. Its model is:

awk 'pattern { action }' file

For each line, if the pattern holds, it runs the action. With no pattern, it applies to all of them; with no action, it prints the line.

Fields and variables

Variable Contains
$0 the whole line
$1, $2… $NF the fields; $NF is the last one
NF the number of fields in the line
NR the line number
FS / OFS the input / output separator
FILENAME the name of the current file

By default FS is "any block of spaces or tabs", which is precisely what cut cannot do:

operator@srv-tramontana:~$ awk '{print $3, $4}' /var/log/tramontana/access.log | head -2
GET /bookings/1012
POST /bookings

For CSV it is changed with -F';'. The BEGIN and END blocks run before the first line and after the last one:

operator@srv-tramontana:~$ awk -F';' 'NR>1 {n++; s+=$6} END {printf "%d bookings, %.2f EUR, average %.2f\n", n, s, s/n}' \
    /home/operator/data/bookings.csv
25 bookings, 7842.50 EUR, average 313.70

NR>1 skips the header — a pattern with no braces is a condition — the variables create themselves with the value 0, and printf formats: %d integer, %.2f decimal with two figures.

Conditions and associative arrays

awk -F';' '$5 >= 5 {print $1, $3, $5}' bookings.csv returns the three stays of five nights or more. And now the feature that makes awk something different: associative arrays, which allow you to group and total in a single pass.

operator@srv-tramontana:~$ awk -F';' 'NR>1 {total[$3]+=$6} END {for (h in total) printf "%-14s %8.2f\n", h, total[h]}' \
    /home/operator/data/bookings.csv | sort -k2 -nr
mas-figueres    2450.00
can-ventos      1980.00
la-solana       1425.50
cal-ferrer      1120.00
el-moli          867.00

total[$3]+=$6 uses the house name as the index: there is nothing to declare and no need to know in advance how many houses there are. The for (h in total) loop walks the keys — in an unpredictable order, hence the final sort — and %-14s left-aligns in 14 characters.

Why awk replaces half a pipeline: that command does in one pass what would require cut | sort | uniq plus a sum that none of the three knows how to do. When you need to group and calculate at the same time, awk is not one option among several: it is the only one in the set that can.

  1. A decision table: which tool and when

I need to Tool
Cut out fields with a fixed delimiter cut
Cut out fields separated by variable spaces awk '{print $N}'
Change or delete individual characters tr
Filter lines by a pattern grep
Substitute text across many lines sed
Sort, deduplicate, count frequencies sort + uniq -c
Total, average, group by key awk
Conditions over several fields at once awk
Several files, JSON, complex state, more than 20 lines Python

The last row is professional advice, not a surrender. A six-link pipeline with two nested seds is harder to maintain than fifteen lines of readable Python. A reasonable cut-off point: if it does not fit on one screen or if you have to draw it to understand it, change tool. And for JSON, do not even try it with sed: jq exists.

  1. Case A: a billing report for Marta

The brief: total billed, average nights and amount per house for the period loaded into bookings.csv. It is built up in steps, verifying as you go.

A prior check with awk -F';' 'NR>1 {n++} END {print n, NF}': 25 records and 6 fields. If any line did not have 6 fields, any subsequent sum would be wrong; the validation from 03-02 already told us the format is correct.

operator@srv-tramontana:~$ awk -F';' '
  NR>1 { bkg[$3]++; nig[$3]+=$5; amt[$3]+=$6; tn+=$5; ta+=$6; tr_++ }
  END {
    printf "%-14s %5s %7s %10s\n", "HOUSE", "BKGS", "NIGHTS", "AMOUNT"
    for (h in bkg) printf "%-14s %5d %7d %10.2f\n", h, bkg[h], nig[h], amt[h]
    printf "%-14s %5d %7d %10.2f\n", "TOTAL", tr_, tn, ta
    printf "Average nights per booking: %.2f\n", tn/tr_
  }' /home/operator/data/bookings.csv
HOUSE           BKGS  NIGHTS     AMOUNT
mas-figueres       7      21    2450.00
can-ventos         6      17    1980.00
la-solana          5      13    1425.50
cal-ferrer         4      11    1120.00
el-moli            3       8     867.00
TOTAL             25      70    7842.50
Average nights per booking: 2.80

Three associative arrays indexed by the same key, three global accumulators and an END that formats. The awk program goes in single quotes and can span several lines: Bash touches nothing inside it. Note that the variable is called tr_ and not tr: inside awk there is no conflict with the command, but avoiding names that coincide with commands saves confusion when you reread it.

An indispensable final check: 7+6+5+4+3 = 25 bookings and the partial sums add up to the total. A report whose breakdown does not square with the total is not delivered.

  1. Cases B and C: error paths and anomalous IPs

B) Paths with the most errors. We extend the pipeline from 03-04 to all the 4xx and 5xx codes:

operator@srv-tramontana:~$ awk '$5 ~ /^[45][0-9][0-9]$/ {print $5, $4}' /var/log/tramontana/access.log \
    | sort | uniq -c | sort -rn | head -10
      9 500 /bookings
      5 503 /bookings/payment
      4 500 /bookings/payment
      4 404 /houses/el-molí
      1 500 /houses

Five combinations, not ten: the listing is short because the log is. They add up to 23, and the 500s add up to 14, which is exactly what we counted in 03-04. $5 ~ /regex/ is awk's pattern comparison, applied only to the status code field, which avoids the problem we solved in 03-02 by surrounding the pattern with spaces. And there is an incidental finding: the four 404s for /houses/el-molí carry an accent, whereas in the catalogue the house is el-moli. Somebody is generating links with the accented name. It is not a server failure: it is an application failure, and Luis has to be told.

C) IPs with the most requests.

operator@srv-tramontana:~$ awk -F'ip=' '{split($2, a, " "); c[a[1]]++} END {for (i in c) print c[i], i}' \
    /var/log/tramontana/access.log | sort -rn | head -5
138 10.0.2.77
94 10.0.2.31
61 10.0.2.44
38 10.0.2.52
27 10.0.2.18

-F'ip=' splits each line on that string, and split($2, a, " ") cuts the rest at the first space to keep the IP. It is a way of extracting a named field without depending on its position.

Reading the figure is the important part. 10.0.2.77 accounts for 138 of the 412 requests — 33% — against the 94 of the second. Is it an attack? We do not know yet, and saying yes would be jumping the gun. We look at what it is doing:

operator@srv-tramontana:~$ grep 'ip=10.0.2.77' /var/log/tramontana/access.log \
    | awk '{print $3, $4, $5}' | sort | uniq -c | sort -rn | head -3
    124 GET /bookings 200
      9 GET /bookings 500
      5 POST /bookings/payment 503

A uniform pattern, almost all successful GET /bookings at a constant rate: it looks more like an automated system — a monitor, a crawler, an integration — than a person. The report for Marta must say that and no more: there is one IP accounting for a third of the traffic with an automated pattern; the available data does not allow a legitimate service to be distinguished from abuse; whose address it is must be identified before any measure is taken. Distinguishing what the data proves from what it suggests is part of the job.

Common Mistakes and Tips

  • uniq -c without a sort in front. It only groups adjacent lines; the count will come out fragmented and will look correct.
  • sort -k3 expecting it to sort by field 3. It sorts from 3 to the end. Write -k3,3.
  • cut -d' ' over aligned output. Repeated spaces create empty fields. Use tr -s ' ' or awk.
  • sed -i with no backup suffix. There is no undo. Always -i.bak-$(date +%F), and test beforehand without -i.
  • Forgetting NR>1 in a CSV with a header. In awk a string is worth 0, so it gives no error: it silently falsifies the count.
  • Quoting an awk program with double quotes. Bash will expand $1. Single quotes always.
  • Comparing numbers as text. sort without -n puts 100 before 20. And sort first, format afterwards: printf's padding throws sort's fields out of alignment.
  • Tip: leave a wc -l at the end while you are building a pipeline; if the number changes where you did not expect it to, that is where the error is. And remember that awk accepts several files: FILENAME tells you which one it is processing and FNR is the line number within that file, as against NR, which is global.

Exercises

Exercise 1. Generate a listing of the houses sorted by average amount per night (total amount divided by total nights), from highest to lowest, with two decimal places. Explain why dividing the average amount by the average number of nights is not enough.

Exercise 2. The file /etc/tramontana/app.conf has max_connections=200 and query_timeout=30. Raise them to 400 and 60 respectively in a single command, with a prior copy and verification, and explain every element of the pattern you use.

Exercise 3. Marta asks what percentage of the requests in access.log ended in an error (4xx or 5xx) and which time slot they are concentrated in. Answer with a single pipeline for each question and write the conclusion in two sentences.

Solutions

Solution 1.

A first attempt with printf "%-14s %7.2f\n", h, amt[h]/nig[h] and sort -k2 -nr comes out unsorted: -k2 means "from field 2 to the end", and since %-14s pads with spaces, field 2 does not start where sort thinks it does. The correction:

operator@srv-tramontana:~$ awk -F';' 'NR>1 {nig[$3]+=$5; amt[$3]+=$6}
  END {for (h in nig) printf "%.2f %s\n", amt[h]/nig[h], h}' \
  /home/operator/data/bookings.csv | sort -rn
116.67 mas-figueres
116.47 can-ventos
109.65 la-solana
108.38 el-moli
101.82 cal-ferrer

Putting the number first and with no padding, sort -rn works unambiguously. It is a technique worth adopting: sort first, format afterwards.

On the question itself: dividing averages is not enough because the average of quotients is not the quotient of averages. A booking of 7 nights weighs far more in the total number of nights than one of 2, and dividing averages treats both the same. The correct calculation is the one we have done, adding up amounts and nights separately and dividing the totals.

Solution 2.

operator@srv-tramontana:~$ sudo sed -E 's/^(max_connections)=200$/\1=400/; s/^(query_timeout)=30$/\1=60/' \
    /etc/tramontana/app.conf | grep -E 'max_connections|query_timeout'
max_connections=400
query_timeout=60
operator@srv-tramontana:~$ sudo sed -i.bak-$(date +%F) -E \
    's/^(max_connections)=200$/\1=400/; s/^(query_timeout)=30$/\1=60/' /etc/tramontana/app.conf
operator@srv-tramontana:~$ sudo diff -u /etc/tramontana/app.conf.bak-2026-08-18 /etc/tramontana/app.conf
@@ -4,2 +4,2 @@
-max_connections=200
-query_timeout=30
+max_connections=400
+query_timeout=60

Element by element: -E so as not to escape the parentheses; ^ and $ so that the pattern matches the whole line and not a fragment inside a comment; the group (key) and its \1 so as not to repeat the name in the replacement, which avoids typos; the old value in the pattern, which acts as a check — if the file already had a different value, the substitution would not be applied instead of overwriting it blindly; ; to chain two substitutions in a single command; and -i.bak-$(date +%F) for the copy. The first command, without -i, is the dry run: only when its output is what you expect do you run the second.

Solution 3.

operator@srv-tramontana:~$ awk '$5 ~ /^[45]/ {e++} END {printf "%d of %d requests: %.1f%%\n", e, NR, 100*e/NR}' \
    /var/log/tramontana/access.log
23 of 412 requests: 5.6%
operator@srv-tramontana:~$ awk '$5 ~ /^[45]/ {print substr($2,1,2)":00"}' /var/log/tramontana/access.log \
    | sort | uniq -c | sort -rn | head -4
     15 03:00
      4 11:00
      2 09:00
      2 17:00

substr($2,1,2) takes the first two characters of the time field. %% prints a literal percent sign in printf.

5.6% of the 412 recorded requests ended in an error, a proportion that on its own is not alarming. However, 15 of the 23 errors — almost two thirds — are concentrated in the 03:00 slot, which points to a one-off problem during the night-time window rather than a general degradation of the service.

That concentration fits with what we had been seeing: the db_timeout entries in errors.log, the exhausted query_timeout=30 and the active_connections=200 sitting at the configured limit. Everything points to the night-time task competing for database connections with the users' requests.

Conclusion

You have turned two text files into defensible reports, and along the way you have closed the circle opened by the Unix philosophy of Module 1: plain text as the universal interface between programs that know nothing about each other.

  • cut cuts by a fixed delimiter, with its limitation in the face of repeated delimiters; tr translates, deletes and squeezes characters.
  • sort sorts by field with the real syntax -kN,N, with -n, -h, -V, -u and -o; uniq -c counts and requires sorted input, and the pattern sort | uniq -c | sort -rn answers any frequency question.
  • You know paste, join and comm for comparing listings, and the process substitution <(...) for avoiding temporary files.
  • sed processes line by line; you handle addresses, the s command with its flags and alternative delimiters, d, p with -n, the capture groups, and you know that -i.bak-$(date +%F) is not optional and that the test without -i always comes first.
  • awk gives you pattern { action }, the fields $1…$NF, NR, NF, FS, BEGIN/END, printf and the associative arrays that group and total in one pass, replacing half a pipeline.
  • You have a decision table and a criterion for knowing when it is time to move to Python.
  • And you have produced three real reports: the billing per house with its breakdown squaring, the paths with the most errors — discovering along the way an application bug with the accent in el-molí — and the IP accounting for a third of the traffic, with the honesty to say what the data proves and what it merely suggests.

Up to here you have worked with files. The next lesson changes its object: Process Management deals with programs while they are running. You will see what a PID is and how the process tree is organised from systemd, how to read ps aux and top column by column — including what the three load average numbers really mean — what signals exist and why a professional tries SIGTERM and waits before resorting to kill -9, and how to find out with lsof which process is holding a port or a file. The practical case will be waiting for you already set out: the Tramontana application hangs in the early hours, right in that 03:00 slot you have just identified, and it has to be diagnosed and restarted without losing the requests in flight.

Linux Course: From Beginner to System Administrator

Module 1: Introduction to Linux

Module 2: Basic Linux Commands

Module 3: Advanced Command-Line Skills

Module 4: Shell Scripting

Module 5: System Administration

Module 6: Networking and Security

Module 7: Advanced Topics

Module 8: Practical Projects

© Copyright 2026. All rights reserved