# grep, sed and awk

> Filter, edit and summarise text from the shell with grep, sed, awk, sort, uniq, cut, tr, paste, column and xargs, and build log-analysis pipelines that hold up.

Canonical: https://www.wiki.jodisand.me/text-processing/
Reviewed: 2026-09-24
Related: [Regular expressions](https://www.wiki.jodisand.me/regex/index.md), [Bash](https://www.wiki.jodisand.me/bash/index.md), [Files and directories](https://www.wiki.jodisand.me/files/index.md), [Shell one-liners](https://www.wiki.jodisand.me/oneliners/index.md), [jq](https://www.wiki.jodisand.me/jq/index.md)


## Cheatsheet

| Task | Command |
| --- | --- |
| Lines matching, case-insensitive, with line numbers | `grep -in 'error' app.log` |
| Recursive, only filenames, skip `.git` | `grep -rl --exclude-dir=.git 'TODO' .` |
| Fixed string, not a regex | `grep -F '[main]' config.ini` |
| Invert match, count | `grep -vc '^#' file` |
| Only the matched part | `grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' access.log` |
| Context around a match | `grep -B2 -A5 'panic' app.log` |
| Replace on every line, in place, with backup | `sed -i.bak 's/old/new/g' file` |
| Delete lines matching | `sed '/^\s*#/d' file` |
| Print lines 10 to 20 | `sed -n '10,20p' file` |
| Print between two patterns | `sed -n '/BEGIN/,/END/p' file` |
| Sum a column | `awk '{s += $3} END {print s}' file` |
| Rows where a column exceeds a value | `awk -F, '$4 > 500' data.csv` |
| Count by field | `awk '{c[$1]++} END {for (k in c) print c[k], k}' file \| sort -rn` |
| Print the last field | `awk '{print $NF}' file` |
| Frequency table | `sort file \| uniq -c \| sort -rn \| head` |
| Sort by the second column numerically | `sort -k2,2n file` |
| Columns 1 and 3 of a CSV | `cut -d, -f1,3 data.csv` |
| Uppercase, squeeze repeated spaces | `tr 'a-z' 'A-Z' < file`, `tr -s ' '` |
| Join two files side by side | `paste -d, a.txt b.txt` |
| Align output into columns | `column -t -s,` |
| Run a command per line, in parallel | `xargs -P8 -I{} cmd {}` |
| NUL-safe filename handling | `find . -name '*.log' -print0 \| xargs -0 gzip` |
| Characters, words, lines | `wc -c`, `wc -w`, `wc -l` |

Behaviour below is GNU grep 3.11, GNU sed 4.9, GNU awk (gawk) 5.3 and GNU coreutils 9.x as shipped on Fedora and RHEL 9. macOS and Alpine ship BSD or BusyBox variants with fewer options; the differences that bite are called out. References: the [GNU grep](https://www.gnu.org/software/grep/manual/grep.html), [GNU sed](https://www.gnu.org/software/sed/manual/sed.html) and [gawk](https://www.gnu.org/software/gawk/manual/gawk.html) manuals.

## Which tool

`grep` selects lines. `sed` edits lines in a single pass with a tiny state machine. `awk` splits lines into fields and gives you variables, arrays, arithmetic and formatted output. Reach for the simplest one that does the job: a `grep | awk` pipeline is usually clearer than one awk program that does both, and an awk program with three arrays is usually clearer than the equivalent Python once you know awk. All three read stdin when given no file, so they compose.

All three use regular expressions but not the same flavour. `grep` defaults to POSIX BRE, `-E` gives ERE and `-P` gives PCRE. `sed` also defaults to BRE with `-E` for ERE. awk always uses ERE. In BRE the characters `+ ? | ( ) { }` are literal unless escaped, so `grep 'a+'` matches a literal plus and `grep -E 'a+'` matches one or more `a`. See [Regular expressions](https://www.wiki.jodisand.me/regex/#flavours) for the full comparison.

## grep

grep prints each line that matches a pattern. With several files it prefixes the filename; `-h` suppresses that and `-H` forces it.

```sh
grep -n 'timeout' app.log                 # -n: line numbers
grep -i 'warn' app.log                    # -i: case-insensitive
grep -w 'error' app.log                   # -w: whole word; error but not errors
grep -x 'ok' status.txt                   # -x: whole line
grep -v '^$' file                         # -v: invert; drops empty lines
grep -c 'GET' access.log                  # -c: count of matching lines (not matches)
grep -l 'password' -r /etc 2>/dev/null    # -l: filenames with a match; -L: filenames without
grep -o '[0-9]\{3\}' file                 # -o: print only the matched text, one per line
grep -m 5 'ERROR' app.log                 # -m: stop after 5 matches
grep -q 'ready' status && echo up         # -q: no output; exit status only
grep -e '-v' -e 'foo' file                # -e: patterns that start with a dash, or several patterns
grep -f patterns.txt file                 # -f: one pattern per line from a file
grep -F 'a.b[0]' file                     # -F: fixed strings, no regex; fastest
grep -E 'error|fatal' app.log             # -E: extended regex; alternation without backslashes
grep -P '(?<=user=)\w+' -o app.log        # -P: PCRE; lookbehind, \d, non-greedy
grep -z -o 'BEGIN.*END' file              # -z: NUL-separated records, so . spans newlines
grep -A3 -B1 'Exception' app.log          # context after and before; -C3 for both
grep --color=always 'x' file | less -R    # keep highlighting through a pager
```

Recursive search options:

```sh
grep -r 'pattern' src/                    # recurse; follows symlinks given on the command line only (-R follows all)
grep -rn --include='*.go' 'context.TODO' .
grep -rn --exclude='*.min.js' --exclude-dir={.git,node_modules,vendor} 'fetch(' .
grep -rI 'pattern' .                      # -I: skip binary files
grep -ra 'pattern' .                      # -a: treat binary as text (prints matched binary lines)
```

Exit status is 0 for a match, 1 for none, 2 for an error. `-q` exits on the first match, so it is also the fastest way to test for presence. `-s` hides errors about missing or unreadable files.

The `-P` engine is the only one with lazy quantifiers, lookaround and `\d`. It refuses to run on files with invalid UTF-8 in a UTF-8 locale unless you add `-a` or set `LC_ALL=C`. `LC_ALL=C grep` is also several times faster on large ASCII logs because byte comparison replaces multibyte character handling.

## sed

sed reads a line into the pattern space, runs every command whose address matches, prints the pattern space unless `-n` is set, and moves to the next line. That model explains almost everything: commands are cheap, the file is streamed, and anything that needs to look at two lines at once takes extra work.

### Substitution

```sh
sed 's/old/new/' file           # first occurrence per line
sed 's/old/new/g' file          # every occurrence
sed 's/old/new/2' file          # second occurrence only
sed 's/old/new/gi' file         # I or i: case-insensitive (GNU)
sed 's|/usr/local|/opt|g' file  # any delimiter; pick one not in the pattern
sed -E 's/([0-9]+)-([0-9]+)/\2-\1/' file    # -E: ERE with unescaped groups; \1 \2 backreferences
sed 's/.*/"&"/' file            # &: the whole match
sed -E 's/\w+/\u&/g' file       # \u: uppercase next character; \U...\E uppercase a span (GNU)
sed 's/x/y/w changed.txt' file  # w: write changed lines to a file
sed 's/[[:space:]]+$//' file    # POSIX class; in BRE + is literal, so this needs -E
sed -E 's/[[:space:]]+$//' file # trailing whitespace removed
```

### Addresses

An address before a command restricts it to matching lines. Two addresses separated by a comma select a range from the first match to the next match of the second.

```sh
sed -n '5p' file                # line 5
sed -n '5,10p' file             # lines 5 to 10
sed -n '$p' file                # last line
sed -n '/start/,/end/p' file    # from a line matching start to the next matching end, inclusive
sed '/^#/d' file                # delete comment lines
sed '1d' file                   # drop the header
sed '/^$/d' file                # drop blank lines
sed -n '/error/!p' file         # !: negate; lines not matching
sed '0~2d' file                 # GNU: first~step; every second line starting at line 0 (even lines)
sed '10,$d' file                # from line 10 to end
sed '/pattern/,+3d' file        # GNU: the match and the three lines after it
sed -n '/BEGIN/{n;p}' file      # n: load the next line; print the line after each BEGIN
```

### Other commands

```sh
sed '3i\inserted before line 3' file
sed '3a\appended after line 3' file
sed '/marker/c\replacement line' file     # c: replace the whole line
sed 'y/abc/xyz/' file                     # transliterate, like tr
sed '=' file | sed 'N;s/\n/\t/'           # =: print line number; N appends the next line; joins them
sed -n 'l' file                           # l: print unambiguously, escapes and $ at line end
sed '$!N;s/\n/ /' file                    # join pairs of lines (N on every line but the last)
sed ':a;N;$!ba;s/\n/,/g' file             # slurp the file into one line, replacing newlines
sed -e 's/a/b/' -e 's/c/d/' file          # several scripts; or separate with ;
sed -f edits.sed file                     # commands from a file
sed -s -n '1p' *.log                      # -s: treat files separately so 1 means line 1 of each
sed 's/a/b/;t;s/c/d/' file                # t: branch to end if the last s succeeded
```

The pattern space and hold space (`h`, `H`, `g`, `G`, `x`) let sed carry state across lines, which is how `sed -n '1!G;h;$p'` reverses a file. Once a script needs the hold space, awk is usually clearer.

### In-place editing: GNU versus BSD

GNU sed takes an optional suffix attached to `-i`; BSD sed (macOS, FreeBSD) requires a suffix argument, which may be an empty string.

```sh
sed -i 's/old/new/g' file        # GNU: edit in place, no backup
sed -i.bak 's/old/new/g' file    # GNU: backup to file.bak (no space before the suffix)
sed -i '' 's/old/new/g' file     # BSD: empty suffix, no backup (GNU would treat '' as the script)
sed -i.bak 's/old/new/g' file    # BSD: also works, and is the one form portable to both
```

`-i` writes a new file and renames it over the original, so it breaks hard links, follows symlinks only with `--follow-symlinks` (GNU) and resets ownership if you run as a different user than the owner. It does not honour `-n` in the way you might expect: `sed -n -i 'p' file` keeps the file, but `sed -n -i '/x/p' file` deletes every line that does not match. Test without `-i` first.

BSD sed also lacks `\+`, `\|`, `\u`, `\U`, `I`, `0~2`, `addr,+N` and interprets `a`, `i` and `c` more strictly (they need a backslash and newline). For scripts that must run on both, stick to POSIX BRE with `-E` for ERE and avoid GNU extensions.

## awk

awk splits each input record (a line by default) into fields `$1` to `$NF` on the field separator `FS` (runs of whitespace by default), then runs every `pattern { action }` pair whose pattern is true for that record. `BEGIN` runs before input and `END` after. A pattern without an action prints the record; an action without a pattern runs on every record.

```sh
awk '{print $1, $3}' file                 # comma inserts OFS (a space); print $1 $3 concatenates
awk -F: '{print $1}' /etc/passwd          # -F: field separator; also a regex: -F'[:,]'
awk -F'\t' -v OFS=',' '{$1 = $1; print}' file   # TSV to CSV; assigning a field rebuilds $0 with OFS
awk 'NR == 1' file                        # header only; NR: record number so far
awk 'NR > 1' file                         # skip header
awk 'NF' file                             # NF: number of fields; drops blank lines
awk 'NF > 5' file                         # lines with more than five fields
awk '$3 > 100' file                       # numeric comparison on a field
awk '$1 == "GET" && $9 >= 500' access.log
awk '/error/ && !/timeout/' file          # regex patterns, combined
awk '/start/,/end/' file                  # range pattern, like sed
awk 'length($0) > 120' file               # long lines
awk '$2 ~ /^10\./' file                   # field matches regex; !~ negates
awk 'NR % 100 == 0' file                  # every hundredth line
awk 'END {print NR}' file                 # line count
awk '{print NR": "$0}' file               # number the lines
awk '{print $NF}' file                    # last field; $(NF-1) second last
awk '{$NF = ""; print}' file              # drop the last field (leaves a trailing OFS)
awk 'FNR == 1 && NR != 1 {next} 1' *.csv  # concatenate CSVs keeping only the first header; FNR resets per file
awk -v n=3 '{print $n}' file              # -v: shell value into an awk variable, set before BEGIN
awk '{print $1 > ($2 ".txt")}' file       # split into files named by the second column
```

Uninitialised variables are `""` and `0` at once, so `c[$1]++` needs no setup. Strings compare as strings and numbers as numbers; a field that looks numeric compares numerically (`"10" > "9"` is false as strings, true as strnums). Force a string comparison with `$1 "" == "10"` or a numeric one with `$1 + 0 == 10`.

### Arrays

awk arrays are associative with string keys. Iteration order is unspecified; pipe to `sort` or use gawk's `PROCINFO["sorted_in"]`.

```sh
awk '{c[$1]++} END {for (k in c) print c[k], k}' access.log | sort -rn | head    # count by first field
awk '{s[$1] += $10} END {for (k in s) printf "%s %.1f MiB\n", k, s[k]/1048576}' access.log   # bytes per client
awk '!seen[$0]++' file                    # deduplicate keeping first occurrence and order
awk '!seen[$2]++' file                    # unique by column 2
awk 'NR == FNR {a[$1]; next} $1 in a' keys.txt data.txt    # lines of data.txt whose key is in keys.txt
awk 'NR == FNR {m[$1] = $2; next} {print $0, m[$1]}' lookup.txt data.txt   # join: append a looked-up value
awk '{if ($3 > max[$1]) max[$1] = $3} END {for (k in max) print k, max[k]}' file   # max per group
awk '{n = split($0, parts, ","); print parts[n]}' file     # split returns the count; last element
awk 'BEGIN {PROCINFO["sorted_in"] = "@val_num_desc"} {c[$1]++} END {for (k in c) print c[k], k}' file   # gawk: sorted iteration
awk '{delete c; ...}'                     # delete the whole array (gawk and POSIX 2024)
```

`NR == FNR` is true only while reading the first file, which is the idiom for loading a lookup table before processing the second file.

### printf and formatting

`printf` does not append a newline. Width and precision work as in C.

```sh
awk '{printf "%-20s %8d %6.2f%%\n", $1, $2, $3 * 100}' file   # left-justify 20, right-justify 8, two decimals
awk '{printf "%s\t%s\n", toupper($1), substr($2, 1, 8)}' file
awk 'BEGIN {printf "%5.1f\n", 1234567 / 1048576}'             # 1.2
awk '{printf "%08.3f\n", $1}' file                             # zero-padded
awk 'BEGIN {OFMT = "%.2f"} {print $1 / 3}' file                # OFMT: format for print of non-integer numbers
awk '{print strftime("%F %T", $1)}' epochs.txt                 # gawk: epoch to date
awk 'BEGIN {print mktime("2026 09 24 00 00 00")}'              # gawk: date to epoch
awk '{gsub(/"/, ""); print}' file                              # gsub: replace all in $0; sub: first only
awk '{sub(/\r$/, ""); print}' file                             # strip CR
awk 'BEGIN {IGNORECASE = 1} /error/' file                      # gawk: case-insensitive matching
awk -F, 'BEGIN {OFS = ","} {print $2, $1}' file                # swap columns
awk 'BEGIN {FPAT = "([^,]+)|(\"[^\"]+\")"} {print $2}' quoted.csv   # gawk: CSV fields with quoted commas
awk --csv '{print $2}' quoted.csv                              # gawk 5.3+: proper CSV parsing including embedded newlines
```

String functions: `length(s)`, `substr(s, start, len)`, `index(s, t)`, `split(s, arr, sep)`, `sub(re, repl, target)`, `gsub`, `match(s, re)` (sets `RSTART` and `RLENGTH`), `tolower`, `toupper`, `sprintf`. gawk adds `gensub(re, repl, how, target)` with `\\1` backreferences, `patsplit`, `strftime`, `systime`, `mktime` and `asort`/`asorti`.

Multi-line records: set `RS` to a blank line (`RS=""`) to treat paragraphs as records, with each line a field when `FS="\n"`. `RS` may be a regex in gawk.

```sh
awk 'BEGIN {RS = ""; FS = "\n"} {print $1 " -> " NF " lines"}' blocks.txt
```

## sort, uniq and friends

`sort` is locale-aware; `LC_ALL=C sort` is byte order and much faster. `uniq` only collapses adjacent duplicates, so sort first.

```sh
sort file                       # lexical, locale collation
sort -n file                    # numeric
sort -h file                    # human-readable sizes: 1K, 2M, 3G (GNU)
sort -V versions.txt            # version sort: 1.2.10 after 1.2.9 (GNU)
sort -r file                    # reverse
sort -u file                    # unique lines (on the whole line, or the key with -k)
sort -k2,2n -k1,1 file          # by column 2 numerically, then column 1; always give the end field
sort -t, -k3,3nr data.csv       # CSV, column 3, numeric, descending
sort -t$'\t' -k2 file           # tab-separated
sort -s -k1,1 file              # stable: equal keys keep input order
sort -R file                    # random order (shuf is faster and does not group equal lines)
sort -c file                    # check whether sorted; exit 1 and the first offending line if not
sort -m a.sorted b.sorted       # merge already-sorted inputs
sort --parallel=8 -S 2G -T /var/tmp bigfile   # threads, memory buffer, temp directory
sort -z                         # NUL-terminated records for filenames
```

`-k2` without an end means "from field 2 to the end of the line", which is almost never intended. `-k2,2` is one field. `-n` on a key applies only to that key: `-k2,2n`.

```sh
sort file | uniq                # duplicates removed
sort file | uniq -c             # count each; count first, then the line
sort file | uniq -d             # only lines that appear more than once
sort file | uniq -u             # only lines that appear once
sort file | uniq -c | sort -rn | head -20    # top 20 most frequent
uniq -f1 file                   # ignore the first field when comparing
uniq -i file                    # case-insensitive
uniq -w 10 file                 # compare the first 10 characters only
```

`cut` extracts by delimiter or byte or character position. It cannot reorder fields or handle multi-character delimiters; use awk for those.

```sh
cut -d: -f1,7 /etc/passwd       # fields 1 and 7
cut -d, -f2- data.csv           # field 2 to end
cut -d, -f-3 data.csv           # fields 1 to 3
cut -c1-10 file                 # characters 1 to 10
cut -b1-4 file                  # bytes
cut -d, -f2 --complement data.csv   # everything except field 2 (GNU)
cut -d' ' -f1 --output-delimiter=, file
cut -f1 file                    # default delimiter is TAB
```

Lines without the delimiter are printed whole unless `-s` is given. `cut` treats each delimiter as a field boundary, so runs of spaces produce empty fields; `tr -s ' '` first, or use awk.

`tr` maps, squeezes or deletes characters. It works on characters, never strings.

```sh
tr 'a-z' 'A-Z' < file           # uppercase
tr '[:lower:]' '[:upper:]'      # locale-safe form
tr -d '\r' < dos.txt > unix.txt # delete CR
tr -d '[:punct:]'               # delete punctuation
tr -s ' ' < file                # squeeze repeated spaces to one
tr -s '[:space:]' '\n'          # one word per line
tr ':' '\n' <<< "$PATH"         # split PATH
tr -cd '[:alnum:]\n' < file     # -c: complement; keep only alphanumerics and newlines
tr '\0' '\n' < /proc/1/environ  # NUL-separated to lines
```

`paste` joins files line by line, or serialises one file's lines.

```sh
paste a.txt b.txt               # side by side, tab-separated
paste -d, a.txt b.txt           # comma
paste -sd, file                 # -s: serial; join all lines of one file with commas
paste - - < file                # two lines into one, tab-separated (each - is a stdin read)
paste -d'\n' a.txt b.txt        # interleave lines
```

`join` merges two sorted files on a common field like a SQL inner join; `comm` compares two sorted files line by line.

```sh
join -t, -1 1 -2 2 <(sort -t, -k1,1 a.csv) <(sort -t, -k2,2 b.csv)   # a.csv col 1 with b.csv col 2
join -a1 -e MISSING -o 0,1.2,2.2 a.txt b.txt   # left join with a placeholder for unmatched
comm -12 <(sort a) <(sort b)    # lines in both
comm -23 <(sort a) <(sort b)    # lines only in a
comm -13 <(sort a) <(sort b)    # lines only in b
```

`column` formats into aligned columns. On Fedora and RHEL it is the util-linux version, which also emits JSON.

```sh
column -t file                  # align on whitespace
column -t -s, data.csv          # CSV input
column -t -s, -N NAME,SIZE,DATE data.csv   # header names (util-linux)
column -t -s, -J -N name,size data.csv     # JSON output (util-linux)
column -c 80 list.txt           # fill columns to 80 characters wide
mount | column -t
```

Other coreutils that turn up in pipelines: `head -n -5` (all but the last 5), `tail -n +2` (from line 2), `tail -F` (follow across rotation), `tac` (reverse lines), `rev` (reverse characters), `nl` (number lines), `fold -w 80 -s` (wrap at spaces), `fmt`, `expand`/`unexpand` (tabs), `shuf -n 10` (sample), `split -l 100000 big.log part-` (split into files), `numfmt --to=iec` (human sizes), `seq 1 10`, `wc -l`.

## xargs

`xargs` reads items from stdin and runs a command with them as arguments, batching as many as fit. Without options it splits on whitespace and interprets quotes, which is wrong for filenames; use `-0` with `find -print0` or `-d '\n'` for line-oriented input.

```sh
find . -name '*.log' -print0 | xargs -0 gzip              # batches; NUL-safe
xargs -d '\n' -I{} cp {} /backup/ < list.txt              # one line per item; {} placeholder
xargs -n1 echo < list.txt                                 # one item per command
xargs -n 50 rm -f < files.txt                             # 50 per command
xargs -P 8 -I{} curl -fsS -o /dev/null -w '%{http_code} {}\n' {} < urls.txt   # 8 in parallel
xargs -r rm < maybe-empty.txt                             # -r: do nothing if input is empty (GNU; BSD default)
xargs -t cmd < list.txt                                   # -t: print each command before running it
xargs -p rm < list.txt                                    # -p: prompt before each command
echo a b c | xargs -n1 -I{} sh -c 'echo "item: $1"' _ {}  # shell logic per item; $1, not {}, inside the script
cat urls.txt | xargs -P4 -n1 -- wget -q                   # -- ends xargs options
```

`-I` implies `-L 1` (one item per command) and disables `-n`. Output of parallel jobs interleaves; add `--process-slot-var` or write to per-item files when order matters. `xargs` exits 123 if any invocation returned 1 to 125, 124 if one exited 255, and 125 to 127 for its own failures.

`find -exec cmd {} +` does the same batching without a pipe and is the portable choice when the input is a `find` result. GNU `parallel` adds job logs, retries and output grouping when `xargs -P` is not enough; it is a separate package.

## Log-analysis pipelines

Start narrow with `grep`, then aggregate with `awk`, then rank with `sort | uniq -c | sort -rn`. Use `LC_ALL=C` on large files and avoid `cat file | grep` when `grep pattern file` does the same thing.

Requests per status code from an nginx or Apache combined log (status is field 9, bytes field 10):

```sh
awk '{c[$9]++} END {for (s in c) printf "%s %d\n", s, c[s]}' access.log | sort -k2,2rn
```

Top 20 client IPs, then the same restricted to 5xx responses:

```sh
awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -20
awk '$9 ~ /^5/ {print $1}' access.log | sort | uniq -c | sort -rn | head -20
```

Requests per minute, to find a spike:

```sh
awk -F'[][]' '{print substr($2, 1, 17)}' access.log | uniq -c | awk '{printf "%s %6d %s\n", $2, $1, substr("##################################################", 1, $1 / 50)}'
```

The `-F'[][]'` splits on either bracket so the timestamp is field 2; the timestamp's first 17 characters are `24/Sep/2026:14:03`, and because the log is already in time order, `uniq -c` counts without a sort.

Bytes served per URL path, in MiB, top 10, ignoring the query string:

```sh
awk '{split($7, u, "?"); b[u[1]] += $10} END {for (p in b) printf "%10.1f %s\n", b[p] / 1048576, p}' access.log | sort -rn | head
```

Slowest requests when the log ends with a request time in seconds (a custom `$request_time` field, here field 11), with the 95th percentile:

```sh
awk '{print $11, $7}' access.log | sort -rn | head
awk '{t[NR] = $11} END {n = asort(t); printf "p50 %.3f p95 %.3f p99 %.3f max %.3f\n", t[int(n * .5)], t[int(n * .95)], t[int(n * .99)], t[n]}' access.log   # gawk asort
```

Errors per hour from the journal or a syslog-format file, and the distinct error messages with numbers and hex stripped so they group:

```sh
journalctl -u my-app --since today -o short-iso | grep -i error | cut -c1-13 | uniq -c
grep -i error app.log | sed -E 's/[0-9]+/N/g; s/0x[0-9a-f]+/0xH/g' | sort | uniq -c | sort -rn | head
```

Pull the block between a start and end marker for the request ID you care about, from a multi-line log:

```sh
sed -n '/request_id=abc123/,/^--- end/p' app.log
awk '/BEGIN TRANSACTION/ {buf = ""; keep = 0} {buf = buf $0 "\n"} /ERROR/ {keep = 1} /COMMIT|ROLLBACK/ {if (keep) printf "%s", buf}' db.log   # only blocks containing an error
```

Unique users who logged in today from `auth.log` or `journalctl -t sshd`, and failed password sources ranked:

```sh
journalctl -t sshd --since today | grep -oP 'Accepted \S+ for \K\S+' | sort -u
journalctl -t sshd --since today | grep -oP 'Failed password for (invalid user )?\S+ from \K\S+' | sort | uniq -c | sort -rn | head
```

`\K` in a PCRE pattern discards everything matched before it from the `-o` output, which avoids a `cut` or `awk` stage.

Follow a log and highlight without losing the rest of the lines:

```sh
tail -F app.log | grep --line-buffered -E 'ERROR|WARN|$'     # $ matches every line; only the keywords colour
tail -F app.log | awk '/ERROR/ {print "\033[31m" $0 "\033[0m"; next} 1'
```

`grep` block-buffers when writing to a pipe, so `tail -F | grep | awk` shows nothing for a long time without `--line-buffered`. awk and sed have `fflush()` and `-u` respectively; `stdbuf -oL cmd` fixes most other tools.

## Oneliners

```sh
# Count matching lines across many files and total them
grep -c 'ERROR' *.log | awk -F: '{s += $2; print} END {print "total:", s}'

# Files containing both patterns
grep -lZ 'foo' -r . | xargs -0 grep -l 'bar'

# Lines in file1 not in file2, regardless of order
grep -vxFf file2 file1

# Print the line after each match, without the match
grep -A1 'pattern' file | grep -v -e 'pattern' -e '^--$'

# Extract every email address
grep -oE '[[:alnum:]._%+-]+@[[:alnum:].-]+\.[[:alpha:]]{2,}' file | sort -u

# Extract IPv4 addresses and rank them
grep -oE '\b([0-9]{1,3}\.){3}[0-9]{1,3}\b' file | sort | uniq -c | sort -rn

# Search a compressed log without decompressing to disk
zgrep -c 'ERROR' app.log.1.gz; zstdgrep 'ERROR' app.log.zst

# Replace across a tree, only in files that match (avoids rewriting unchanged files)
grep -rlZ 'old.example.com' src/ | xargs -0 sed -i 's/old\.example\.com/new.example.com/g'

# Replace a path containing slashes using a different delimiter
sed -i 's#/var/www/html#/srv/www#g' /etc/nginx/conf.d/*.conf

# Change a key=value in a config file, only if the key exists uncommented
sed -i -E 's/^(max_connections\s*=\s*).*/\1500/' postgresql.conf

# Append a line after a matching line, once
grep -q '^Include conf.d' sshd_config || sed -i '/^Port /a Include conf.d/*.conf' sshd_config

# Comment out lines matching
sed -i '/^PermitRootLogin/s/^/#/' sshd_config

# Delete from a pattern to end of file
sed -i '/^\[legacy\]/,$d' config.ini

# Print the Nth line quickly (q stops reading)
sed -n '1000{p;q}' bigfile

# Strip ANSI escape sequences
sed -E 's/\x1b\[[0-9;]*[A-Za-z]//g' coloured.log

# Trim leading and trailing whitespace
sed -E 's/^[[:space:]]+|[[:space:]]+$//g' file

# Convert CRLF to LF and back
sed -i 's/\r$//' file; sed -i 's/$/\r/' file

# Sum a column of a CSV, skipping the header
awk -F, 'NR > 1 {s += $3} END {printf "%.2f\n", s}' data.csv

# Average of a column
awk '{s += $1; n++} END {if (n) print s / n}' numbers.txt

# Min and max of a column
awk 'NR == 1 {min = max = $1} $1 < min {min = $1} $1 > max {max = $1} END {print min, max}' numbers.txt

# Group by column 1 and sum column 2
awk '{s[$1] += $2} END {for (k in s) print k, s[k]}' file | sort

# Print lines where a field is in a set
awk 'BEGIN {split("GET POST", a); for (i in a) ok[a[i]]} $6 in ok' access.log

# Transpose rows and columns
awk '{for (i = 1; i <= NF; i++) m[i, NR] = $i; if (NF > nf) nf = NF} END {for (i = 1; i <= nf; i++) {row = ""; for (j = 1; j <= NR; j++) row = row (j > 1 ? " " : "") m[i, j]; print row}}' file

# Print a column of a fixed-width file by character positions
awk '{print substr($0, 10, 8)}' fixed.txt

# Lines longer than 80 characters, with their numbers
awk 'length > 80 {print FILENAME ":" FNR ": " length}' *.md

# Sort a file by line length
awk '{print length, $0}' file | sort -n | cut -d' ' -f2-

# Deduplicate without sorting, keeping the first occurrence
awk '!seen[$0]++' file

# Random sample of 1% of lines
awk 'BEGIN {srand()} rand() < 0.01' bigfile

# Convert epoch timestamps in column 1 to ISO 8601 (gawk)
awk '{$1 = strftime("%FT%T%z", $1)} 1' events.log

# Human-readable sizes from du without -h (for sorting first)
du -sk * | sort -rn | head | numfmt --field=1 --from-unit=1024 --to=iec

# Top 10 processes by RSS, formatted
ps -eo rss,comm --sort=-rss | head -11 | awk 'NR == 1 {print; next} {printf "%8.1f MiB %s\n", $1 / 1024, $2}'

# Parse key=value logs into one field
grep -oP 'duration=\K[0-9.]+' app.log | sort -n | tail -1

# Find the header column index by name, then print that column
awk -F, -v col=status 'NR == 1 {for (i = 1; i <= NF; i++) if ($i == col) c = i; next} {print $c}' data.csv

# Reverse fields on each line
awk '{for (i = NF; i > 0; i--) printf "%s%s", $i, (i > 1 ? OFS : ORS)}' file

# Join lines matching a prefix with the previous line (continuation lines)
awk '/^[[:space:]]/ {printf "%s", $0; next} NR > 1 {print ""} {printf "%s", $0} END {print ""}' file

# Words by frequency in a document
tr -cs '[:alpha:]' '\n' < book.txt | tr 'A-Z' 'a-z' | sort | uniq -c | sort -rn | head -20

# Every second line, odd then even
sed -n 'p;n' file; sed -n 'n;p' file

# Rename files by regex: replace spaces with underscores
find . -maxdepth 1 -name '* *' -print0 | xargs -0 -I{} sh -c 'mv -- "$1" "$(printf %s "$1" | tr " " _)"' _ {}

# Check that every line has the same number of fields
awk -F, '{c[NF]++} END {for (n in c) print n " fields: " c[n] " lines"}' data.csv

# Diff two command outputs side by side
diff -y --suppress-common-lines <(sort a.txt) <(sort b.txt)
```

## Scripts

Summarise an nginx combined-format access log: request count, error rate, top status codes, top paths and top clients, for one file or a glob including gzipped rotations.

```sh
#!/usr/bin/env bash
# usage: access-report.sh access.log [access.log.1 access.log.2.gz ...]
set -euo pipefail
(( $# )) || { echo 'usage: access-report.sh LOG...' >&2; exit 2; }

read_logs() { for f in "$@"; do case $f in *.gz) zcat -- "$f" ;; *.zst) zstdcat -- "$f" ;; *) cat -- "$f" ;; esac; done; }

export LC_ALL=C
read_logs "$@" | awk '
  { total++; status[$9]++; split($7, u, "?"); path[u[1]]++; client[$1]++ }
  $9 ~ /^5/ { err++ }
  END {
    printf "requests: %d   5xx: %d (%.2f%%)\n\n", total, err, total ? 100 * err / total : 0
    print "status:"; for (s in status) printf "  %s %d\n", s, status[s] | "sort -k2,2rn"; close("sort -k2,2rn")
    print "\ntop paths:"; for (p in path) printf "  %7d %s\n", path[p], p | "sort -rn | head -15"; close("sort -rn | head -15")
    print "\ntop clients:"; for (c in client) printf "  %7d %s\n", client[c], c | "sort -rn | head -15"
  }'
```

Bulk search-and-replace across a tree with a dry-run diff, so the change is reviewed before files are rewritten. Rewrites files only when run with `--apply`.

```sh
#!/usr/bin/env bash
# usage: bulk-replace.sh [--apply] 'regex' 'replacement' DIR [glob]
set -euo pipefail
apply=0; [[ ${1:-} == --apply ]] && { apply=1; shift; }
(( $# >= 3 )) || { echo "usage: bulk-replace.sh [--apply] REGEX REPL DIR [GLOB]" >&2; exit 2; }
re=$1 repl=$2 dir=$3 glob=${4:-*}

mapfile -d '' files < <(grep -rlZ --include="$glob" --exclude-dir=.git -E -- "$re" "$dir")
(( ${#files[@]} )) || { echo 'no files match' >&2; exit 0; }
printf '%d file(s) match\n' "${#files[@]}" >&2

for f in "${files[@]}"; do
  if (( apply )); then
    sed -i -E -- "s/$re/$repl/g" "$f"; printf 'rewrote %s\n' "$f"
  else
    diff -u --label "$f" --label "$f (proposed)" "$f" <(sed -E -- "s/$re/$repl/g" "$f") || true
  fi
done
(( apply )) || echo 'dry run; re-run with --apply to write' >&2
```

Watch a log and alert when the error rate over a sliding one-minute window exceeds a threshold, for a quick check during a deploy when nothing better is wired up.

```sh
#!/usr/bin/env bash
# usage: error-rate.sh /var/log/my-app/app.log [threshold-per-minute]
set -euo pipefail
log=${1:?log file required} threshold=${2:-20}

tail -Fn0 -- "$log" | awk -v thr="$threshold" '
  /ERROR/ {
    now = systime(); t[++n] = now
    while (n && t[1] < now - 60) { for (i = 2; i <= n; i++) t[i - 1] = t[i]; delete t[n--] }   # drop entries older than 60 s
    if (n > thr && now - last_alert > 60) { printf "%s ALERT: %d errors in the last minute\n", strftime("%T"), n; fflush(); last_alert = now }
  }'
```

## Troubleshooting

| Symptom | Cause | Fix |
| --- | --- | --- |
| `grep: pattern+` matches a literal plus | BRE treats `+`, `?`, `\|`, `()` and `{}` as literals | `grep -E`, or escape as `\+` in GNU BRE |
| `sed -i '' 's/a/b/' file` fails with `unknown command` on Linux | GNU `-i` takes the suffix attached; `''` became the script | `sed -i 's/a/b/' file`; portable form is `sed -i.bak` |
| `sed -i 's/a/b/' file` on macOS complains `undefined label` or eats the script | BSD `-i` requires a suffix argument | `sed -i '' 's/a/b/' file` on BSD |
| `sed: -e expression #1, char N: unknown option to s` | The replacement or pattern contains the delimiter (usually `/` in a path) | Use another delimiter: `s#/old#/new#` |
| `\d`, `\w` do nothing in `sed` or `grep` | Not part of BRE or ERE; `\w` is a GNU extension, `\d` is not | `[0-9]`, `[[:digit:]]`, or `grep -P` |
| Replacement inserts `$1` literally | sed uses `\1`, not `$1`; `-E` still needs `\1` | `sed -E 's/(x)/\1/'` |
| `awk` prints the whole line when I asked for a field | `print $1 $3` concatenates; `$` missing on a variable name | `print $1, $3`; `print $n` not `print n` |
| `awk -F,` splits quoted CSV fields containing commas | `-F` is a plain separator | `gawk --csv` (5.3+), `FPAT`, or a CSV-aware tool such as `mlr` or Python `csv` |
| `sort -k2` sorts wrongly | Key runs from field 2 to end of line | `-k2,2`; add `n` for numeric |
| `uniq -c` misses duplicates | Input not sorted; `uniq` compares adjacent lines only | `sort \| uniq -c` |
| `sort` output order differs between hosts | Locale collation (`en_AU.UTF-8` versus `C`) | `LC_ALL=C sort` for byte order, and for speed |
| `xargs: unterminated quote` or files with spaces split | Default whitespace and quote parsing | `find -print0 \| xargs -0`, or `xargs -d '\n'` |
| Pipeline through `grep` or `awk` shows nothing until it ends | Block buffering when stdout is a pipe | `grep --line-buffered`, `awk '{...; fflush()}'`, `sed -u`, `stdbuf -oL` |
| `grep -P` says `invalid UTF-8 byte sequence in input` | Binary or Latin-1 bytes in a UTF-8 locale | `LC_ALL=C grep -P` or `grep -aP` |
| `grep` reports `Binary file matches` | A NUL byte in the file | `grep -a` to print lines, `-I` to skip binary files |
| `tr 'a-z' 'A-Z'` mangles non-ASCII text | `tr` works on bytes, not multibyte characters | `sed 's/.*/\U&/'` (GNU) or `awk '{print toupper($0)}'` |
| `awk` numeric comparison treats `10` less than `9` | Values compared as strings (one operand is a string constant) | Force numeric: `$1 + 0 > 9` |
| `Argument list too long` from `grep` or `sed` with a glob | Too many filenames for one command | `find ... -exec grep ... {} +` or `xargs` |

## Further reading

- [GNU grep manual](https://www.gnu.org/software/grep/manual/grep.html): options, regex syntax per engine, exit status and performance notes.
- [GNU sed manual](https://www.gnu.org/software/sed/manual/sed.html): commands, addresses, hold space and the full list of GNU extensions.
- [The GNU Awk User's Guide](https://www.gnu.org/software/gawk/manual/gawk.html): the language, built-in variables and functions, and gawk-only features such as `--csv` and `PROCINFO["sorted_in"]`.
- [GNU coreutils manual](https://www.gnu.org/software/coreutils/manual/coreutils.html): `sort`, `uniq`, `cut`, `tr`, `paste`, `join`, `comm`, `numfmt` and the rest.
- [GNU findutils: xargs](https://www.gnu.org/software/findutils/manual/html_node/find_html/Invoking-xargs.html): options, exit codes and interaction with `find -print0`.
- [POSIX Shell and Utilities](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/contents.html): what portable scripts may rely on for `grep`, `sed`, `awk` and the text utilities.


