06 — Text Processing Pipelines: grep, sed, awk & Log Analysis Internals
grep: Regex Engine Internals
bash
# ── grep has THREE regex modes ──
# grep (BRE — Basic Regex): . * [..] ^ $ — + ? | () need backslash escaping
# grep -E (ERE — Extended Regex): . * + ? | () {n,m} — unescaped (use this)
# grep -P (PCRE — Perl Regex): \d \w \s \b lookaheads (GNU grep only, NOT on macOS BSD grep)
# ── BRE (default) — need to escape +, ?, |, () ──
grep "error\|warning" log.txt # BRE: \| means alternation (ugly)
grep "foo\+" log.txt # BRE: \+ means one or more
# ── ERE (preferred) — unescaped +, ?, |, () ──
grep -E "error|warning" log.txt # ERE: clean alternation
grep -E "foo+" log.txt # ERE: one or more 'o'
grep -E "(error|warning|critical)" log.txt # ERE: grouping
# ── PCRE (GNU only) — \d, \w, \s, lookaheads ──
grep -P "\d{4}-\d{2}-\d{2}" log.txt # PCRE: \d (GNU Linux, not macOS BSD)
# macOS fallback: use [0-9] with -E:
grep -E "[0-9]{4}-[0-9]{2}-[0-9]{2}" log.txt
# ── Flags that matter ──
grep -i "error" log.txt # case-insensitive
grep -v "debug" log.txt # INVERT: lines NOT matching
grep -n "error" log.txt # line numbers
grep -c "error" log.txt # count of matching lines
grep -r "error" . # recursive (search all files in dir)
grep -rl "error" . # -l: print FILENAMES only (files containing match)
grep -w "error" log.txt # whole word only (won't match "errors")
grep -x "error" log.txt # whole LINE match (line is exactly "error")
grep -A 3 "error" log.txt # 3 lines After match
grep -B 3 "error" log.txt # 3 lines Before
grep -C 3 "error" log.txt # 3 lines Context (both before and after)
grep -q "error" log.txt # quiet — exit status only (for if conditions)
grep -o "error" log.txt # only output the MATCHING part (not whole line)
grep -oE "[0-9.]+" log.txt # extract all numbers (only matching parts)
grep -m 5 "error" log.txt # stop after 5 matches
grep -f patterns.txt log.txt # read patterns from file (one per line)
grep -e "error" -e "warning" log.txt # multiple patterns
grep --include="*.log" -r "error" . # recursive, only .log files
grep --exclude="*.tmp" -r "error" . # recursive, exclude .tmp files
grep --exclude-dir=node_modules -r "error" . # exclude directories
Anti-Pattern: grep Without set -e Awareness
bash
set -e
# ❌ NAIVE — grep returns 1 if no match, kills the script!
grep "error" log.txt
echo "reached" # NOT reached if no match (grep exits 1, set -e kills script)
# ✅ CORRECT — explicit "no match is OK"
grep "error" log.txt || true # || true: make it always succeed
if grep -q "error" log.txt; then # test form: no set -e issue
echo "found"
fi
grep -q "error" log.txt && echo "found" # short-circuit: no set -e issue
# ── grep exit codes ──
# 0: at least one match
# 1: no match (NOT an error!)
# 2: error (file not found, bad regex, etc.)
# Distinguish: grep ... || { [[ $? -eq 2 ]] && die "grep error"; }
sed: Stream Editor Internals
bash
# ── sed operates line-by-line: read line → apply commands → output line ──
# ── Substitution (s) ──
sed 's/old/new/' file.txt # replace FIRST occurrence per line
sed 's/old/new/g' file.txt # replace ALL occurrences (global)
sed 's/old/new/2' file.txt # replace 2nd occurrence per line
sed 's/old/new/gi' file.txt # global + case-insensitive
sed 's/old/new/3g' file.txt # 3rd and all subsequent occurrences
sed 's/^/prefix /' file.txt # add prefix to every line
sed 's/$/ suffix/' file.txt # add suffix to every line
sed 's/ */ /g' file.txt # collapse multiple spaces to single space
# ── Multiple commands: -e ──
sed -e 's/foo/bar/g' -e 's/baz/qux/g' file.txt # apply both substitutions
sed 's/foo/bar/g; s/baz/qux/g' file.txt # same with semicolons
# ── Address ranges ──
sed -n '5,10p' file.txt # print lines 5-10 (-n suppresses default output)
sed -n '5p' file.txt # print only line 5
sed -n '/pattern/,/pattern2/p' file.txt # print from first match to second match
sed -n '/error/,$p' file.txt # print from first "error" to end of file
# ── Delete (d) ──
sed '/pattern/d' file.txt # delete lines matching pattern
sed '5d' file.txt # delete line 5
sed '5,10d' file.txt # delete lines 5-10
sed '/^$/d' file.txt # delete blank lines
sed '/^[[:space:]]*$/d' file.txt # delete blank lines (including whitespace-only)
# ── Alternative delimiters (critical for paths) ──
# ❌ HARD TO READ — escaping slashes
sed 's/\/old\/path/\/new\/path/g' file.txt
# ✅ USE DIFFERENT DELIMITER
sed 's|/old/path|/new/path|g' file.txt # | delimiter
sed 's#/old/path#/new/path#g' file.txt # # delimiter
sed 's:/old/path:/new/path:g' file.txt # : delimiter
# sed accepts ANY character as the delimiter (not just /)
Anti-Pattern: sed -i Cross-Platform Breakage
bash
# ── GNU sed (Linux) vs BSD sed (macOS) have DIFFERENT -i syntax ──
# GNU sed: sed -i 's/old/new/g' file.txt ← no extension needed
# BSD sed: sed -i '' 's/old/new/g' file.txt ← requires '' (empty extension)
# ❌ NAIVE — works on Linux, fails on macOS
sed -i 's/old/new/g' file.txt
# macOS: "sed: 1: \"file.txt\": invalid command code j" (treats 's' as backup extension!)
# ✅ PORTABLE — create backup, then remove it
sed -i.bak 's/old/new/g' file.txt && rm -f file.txt.bak
# Works on BOTH GNU and BSD sed (creates .bak, then we delete it).
# ✅ PORTABLE (alternative) — use a variable for the extension
if sed --version 2>/dev/null | grep -q GNU; then
SED_INPLACE=(-i)
else
SED_INPLACE=(-i '')
fi
sed "${SED_INPLACE[@]}" 's/old/new/g' file.txt
# ✅ BEST — avoid sed -i entirely; use a temp file or sponge
sed 's/old/new/g' file.txt > file.txt.tmp && mv file.txt.tmp file.txt
# Or: install `moreutils` and use `sponge`:
sed 's/old/new/g' file.txt | sponge file.txt # sponge holds output, writes atomically
awk: The Mini Programming Language
bash
# ── awk structure: BEGIN { } pattern { action } END { } ──
# BEGIN: runs once before any input
# pattern { action }: for each line, if pattern matches, run action
# END: runs once after all input
# ── Field access ──
awk '{print $1}' file.txt # first field (whitespace-delimited)
awk '{print $1, $3}' file.txt # fields 1 and 3
awk '{print $0}' file.txt # whole line
awk '{print $NF}' file.txt # last field
awk '{print $(NF-1)}' file.txt # second-to-last field
awk '{print NR, $0}' file.txt # NR = line number, prefix each line
awk '{print FNR, $0}' file1 file2 # FNR = line number PER FILE (resets for file2)
awk '{print NF}' file.txt # NF = number of fields in current line
# ── Field separator ──
awk -F',' '{print $1}' file.csv # comma-delimited
awk -F'\t' '{print $1}' file.tsv # tab-delimited
awk -F'|' '{print $1, $3}' file.txt # pipe-delimited
awk -F'[ ,]+' '{print $1}' file.txt # regex: one or more spaces or commas
awk 'BEGIN{FS=","} {print $1}' file.csv # same as -F,', but can set in BEGIN
awk 'BEGIN{FS=","; OFS=" | "} {print $1, $2}' file.csv # OFS = output field separator
# ── Conditions (pattern matching) ──
awk '$3 > 100' file.txt # print lines where field 3 > 100 (default action: print)
awk 'NR >= 5 && NR <= 10' file.txt # print lines 5-10
awk 'NR > 1' file.txt # skip header (first line)
awk '/error/' file.txt # print lines matching "error" (like grep)
awk '!/debug/' file.txt # print lines NOT matching "debug" (like grep -v)
awk '$1 == "GET"' access.log # lines where field 1 is exactly "GET"
awk '$9 >= 400 && $9 < 500' access.log # lines where field 9 (HTTP status) is 4xx
# ── Accumulation (sum, count, average) ──
awk '{sum += $1} END {print sum}' nums.txt # sum of first column
awk '{sum += $1; count++} END {print sum/count}' nums.txt # average
awk '{count[$1]++} END {for (k in count) print k, count[k]}' file.txt # frequency count by field 1
awk '{sum[$1] += $2} END {for (k in sum) print k, sum[k]}' file.txt # group sum
# ── String functions ──
awk '{print length($0)}' file.txt # length of each line
awk '{print tolower($1)}' file.txt # lowercase field 1
awk '{print toupper($1)}' file.txt # uppercase field 1
awk '{print substr($1, 1, 3)}' file.txt # first 3 chars of field 1
awk '{print split($0, arr, ",")}' file.txt # split line on comma, return count
awk '{gsub(/[0-9]+/, "N", $0); print}' file.txt # replace all numbers with "N"
awk '{print index($0, "error")}' file.txt # position of "error" in line (0 if not found)
awk '{printf "%-20s %5d\n", $1, $2}' file.txt # formatted output (like C printf)
Production Pattern: Log Analysis Pipeline
bash
#!/usr/bin/env bash
# ── Access log analyzer: top IPs, URLs, status codes, busiest hour ──
log="${1:-access.log}" # default to access.log
# ── Top 10 IPs by request count ──
# $1 = client IP; sort | uniq -c | sort -rn | head = frequency-sorted
awk '{print $1}' "$log" | sort | uniq -c | sort -rn | head -10
# ── Top 10 requested URLs ──
# $7 = request path (field 7 in Common Log Format: "GET /path HTTP/1.1")
awk '{print $7}' "$log" | sort | uniq -c | sort -rn | head -10
# ── HTTP status code distribution ──
awk '{print $9}' "$log" | sort | uniq -c | sort -rn
# ── Busiest hour (hour with most requests) ──
# $4 = timestamp "[10/Jan/2024:14:32:01" → cut -d: -f2 = "14"
awk '{print $4}' "$log" | cut -d: -f2 | sort | uniq -c | sort -rn | head -1
# ── 4xx/5xx errors by IP ──
awk '$9 >= 400 {print $1, $9}' "$log" | sort | uniq -c | sort -rn | head -20
# ── Bandwidth by IP (sum of $10 = bytes sent) ──
awk '{bandwidth[$1] += $10} END {for (ip in bandwidth) printf "%s %d\n", ip, bandwidth[ip]}' "$log" | sort -k2 -rn | head -10
# ── Requests per second (find traffic spikes) ──
# $4 = timestamp, extract to second precision: "10/Jan/2024:14:32:01"
awk '{print $4}' "$log" | cut -d: -f1-4 | sort | uniq -c | sort -rn | head -5
sort and uniq: Frequency Analysis
bash
# ── sort flags ──
sort file.txt # alphabetical (locale-dependent!)
sort -r file.txt # reverse
sort -n file.txt # numeric (2 > 10 — correct)
sort -rn file.txt # numeric reverse (highest first)
sort -u file.txt # unique (sort + dedup in one)
sort -k2 file.txt # by 2nd field
sort -k2n file.txt # by 2nd field, numeric
sort -k2 -k3n file.txt # by 2nd field (alpha), then 3rd field (numeric) — composite
sort -t',' -k2n file.csv # by 2nd field, numeric, comma-delimited
sort -h file.txt # human-numeric (2K, 1M, 1G — sorts sizes)
sort -V file.txt # version sort (1.2.3 < 1.2.10 — natural ordering)
sort -f file.txt # fold case (case-insensitive)
sort -R file.txt # random shuffle
sort -s -k2 file.txt # stable sort (preserve order of equal-key lines)
sort -S 50% file.txt # use 50% of RAM for sorting (large files)
sort --parallel=4 file.txt # use 4 threads (GNU sort)
# ── ⚠️ LC_ALL=C for deterministic sorting ──
# Locale affects sort order: en_US.UTF-8 may sort case-insensitively or accent-awarely
sort file.txt # locale-dependent: might be case-insensitive
LC_ALL=C sort file.txt # C locale: ASCII byte order — deterministic, fast, portable
# Always use LC_ALL=C for sort in scripts (deterministic output).
# ── uniq ──
sort file.txt | uniq # remove adjacent duplicates (MUST sort first!)
sort file.txt | uniq -c # count occurrences (prefix with count)
sort file.txt | uniq -d # print only DUPLICATE lines (appears more than once)
sort file.txt | uniq -u # print only UNIQUE lines (appears exactly once)
sort file.txt | uniq -i # case-insensitive comparison
sort file.txt | uniq -w5 # compare only first 5 chars
sort file.txt | uniq -f2 # skip first 2 fields, compare the rest
Anti-Pattern: uniq Without sort
bash
# ❌ NAIVE — uniq only removes ADJACENT duplicates
uniq file.txt > deduped.txt # non-adjacent duplicates remain!
# ✅ CORRECT — sort first (groups duplicates adjacently)
sort file.txt | uniq > deduped.txt
# Or: sort -u file.txt > deduped.txt (sort + dedup in one)
# ── Order-preserving dedup (keep first occurrence order) ──
awk '!seen[$0]++' file.txt
# Mechanism:
# $0 = current line (the whole line)
# seen[$0]++ = increment count for this line; returns OLD value (0 if first time, 1+ if seen before)
# !seen[$0]++ = negation: true if first time (old value 0), false if seen before
# awk default action for true pattern: print the line
# Result: prints each line only the first time it's seen, preserving original order.
# ── Case-insensitive order-preserving dedup ──
awk '!seen[tolower($0)]++' file.txt
tr: Character-Level Translation
bash
# ── tr translates/deletes/squeezes CHARACTERS (not strings, not patterns) ──
# ── Translate (map chars) ──
echo "hello" | tr 'a-z' 'A-Z' # HELLO — lowercase to uppercase
echo "hello" | tr 'el' 'ip' # hippo — e→i, l→p
echo "hello world" | tr ' ' '_' # hello_world — spaces to underscores
# ── Delete (-d) ──
echo "hello 123 world" | tr -d '0-9' # hello world — delete digits
echo "hello" | tr -d 'l' # heo — delete l's
echo "hello world" | tr -d '[:space:]' # helloworld — delete all whitespace
# ── Squeeze (-s) — collapse repeats ──
echo "hello world" | tr -s ' ' # hello world — collapse multiple spaces to one
echo "aaabbbccc" | tr -s 'abc' # abc — squeeze each repeated char
# ── Delete complement (-dc) — keep ONLY matching ──
echo "hello123world" | tr -dc '0-9' # 123 — delete everything EXCEPT digits
echo "hello world" | tr -dc 'a-z\n' # helloworld — keep only lowercase + newlines
# ── Rot13 ──
echo "hello" | tr 'a-zA-Z' 'n-za-mN-ZA-M' # uryyb — rot13 encoding
# ── ⚠️ tr is CHARACTER-based, not pattern-based ──
echo "abc" | tr 'abc' '123' # 123 — each char: a→1, b→2, c→3
echo "abc" | tr 'abc' '12' # 122 — c maps to 2 (last char of set2 repeats for extra chars in set1)
Pipeline Composition Patterns
bash
# ── Top 5 most frequent IPs in access log ──
grep "GET" access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -5
# ── Unique error messages (strip timestamps, dedup) ──
grep "ERROR" log.txt | sed 's/^\[.*\] //' | sort -u
# ── Lines longer than 80 chars ──
awk 'length > 80' file.txt
# ── Largest files (top 10) ──
du -s * | sort -rn | head -10
# ── Word frequency in a text file ──
tr '[:space:][:punct:]' '\n' < file.txt | grep -v '^$' | sort | uniq -c | sort -rn | head -20
# ── Find all unique file extensions in a directory ──
find . -type f -name '*.*' | sed 's/.*\.//' | sort | uniq -c | sort -rn
# ── Count lines of code by language ──
find . -name '*.sh' -exec wc -l {} + | tail -1
find . -name '*.py' -exec wc -l {} + | tail -1
# ── Extract all URLs from a file ──
grep -oE 'https?://[^ ]+' file.txt | sort -u
# ── CSV: sum of column 2, grouped by column 1 ──
awk -F',' '{sum[$1] += $2} END {for (k in sum) printf "%s,%d\n", k, sum[k]}' data.csv | sort
# ── Process substitution: diff sorted versions of a file ──
diff <(sort file1.txt) <(sort file2.txt) # no temp files needed
💡 Tips & Tricks
bash
# ── Prefer parameter expansion over sed for simple ops (no subprocess!) ──
str="hello world"
# ❌ SLOW (forks sed)
upper=$(echo "$str" | sed 's/.*/\U&/') # GNU sed only
# ✅ FAST (in-process)
upper="${str^^}" # Bash 4+ parameter expansion
# ── Use `awk` instead of `grep + sed + cut` for complex field extraction ──
# ❌ CHAINED (3 subprocesses)
grep "error" log.txt | sed 's/.*error: //' | cut -d' ' -f1
# ✅ SINGLE AWK (1 subprocess)
awk '/error/ {sub(/.*error: /, ""); print $1}' log.txt
# ── `sort -V` for version numbers ──
printf '%s\n' '1.2.3' '1.2.10' '1.2.2' | sort -V
# 1.2.2 1.2.3 1.2.10 — correct (numeric version ordering)
# Without -V: 1.2.10 1.2.2 1.2.3 — wrong (string sort: "10" < "2")
# ── `tac` (reverse) for processing a file bottom-to-top ──
tac log.txt | head -100 # last 100 lines, in order (tac reverses, head takes first 100)
tail -100 log.txt # same result (faster for large files)
# ── `paste` for side-by-side file comparison ──
paste file1.txt file2.txt # tab-separated, side by side
paste -d',' file1.txt file2.txt # comma-separated
⚠️ Edge Cases & Gotchas
bash
# ── grep regex is LINE-based — `.` doesn't match newlines ──
# For multi-line patterns: use grep -z (null-delimited) or pcregrep / ripgrep
grep -Pzo '"key"\s*:\s*"value"' file.json # -z: null-delimited (GNU only)
# ── macOS grep vs GNU grep ──
# macOS BSD grep doesn't support -P (PCRE). Use -E (ERE) or install ripgrep (rg).
# macOS BSD grep -o behaves slightly differently with capture groups.
# ── `sed` regex differs between GNU and BSD ──
# GNU sed: supports \s, \d, \b, etc. (GNU extensions)
# BSD sed: POSIX BRE only — no \s, \d. Use [[:space:]], [0-9], etc.
# Portable: use POSIX character classes [[:space:]], [[:digit:]], [[:alpha:]]
# ── `cut` can't handle quoted CSV ──
echo '"Smith, John",30' | cut -d',' -f1 # "Smith — broken (comma inside quotes)
# Use a proper CSV parser: csvkit, mlr (Miller), or awk with a CSV module.
# ── `sort` locale: LC_ALL=C for determinism ──
# en_US.UTF-8: sort might be case-insensitive, accent-aware, or locale-version-dependent
# C: pure ASCII byte order — always the same. Use LC_ALL=C in scripts.
# ── `uniq -c` output has leading spaces ──
sort file | uniq -c | sort -rn # uniq -c adds leading spaces: " 5 hello"
# To strip: | sed 's/^ *//' or | awk '{print $1, $2}'
# ── `xargs` breaks on spaces without -0 ──
find . -name "*.txt" | xargs grep "pattern" # breaks on filenames with spaces
find . -name "*.txt" -print0 | xargs -0 grep "pattern" # safe (null-delimited)
# ── `tail -f` blocks forever ──
tail -f log.txt # streams forever — in a script, use with timeout or grep -m1
tail -f log.txt | grep -m1 "ready" # stop at first match (grep -m1 exits after 1 match)
🧠 Quick Quiz
Why does this awk one-liner produce different output on Linux vs macOS?
bash
echo "hello" | awk '{print toupper($0)}'
Answer
It doesn't (in this case) — toupper is POSIX and works on both. But locale affects it:
- With
LC_ALL=C:toupperconverts ASCII a-z only."héllo"→"HéLLO"(é stays, because C locale is ASCII-only). - With
LC_ALL=en_US.UTF-8:toupperconverts Unicode."héllo"→"HÉLLO"(é → É, locale-aware).
The gotcha: awk string functions are locale-dependent. For consistent output across systems, set LC_ALL=C for ASCII-only behavior, or LC_ALL=en_US.UTF-8 for Unicode. Don't rely on default locale — it varies by system.