Skip to content

Text Processing: grep, sed, awk, and jq

What You'll Learn

  • How pipelines and standard streams let small tools combine into powerful one-liners
  • When to reach for grep, sed, awk, or jq
  • Real operations examples: top error sources, request rates, config edits, and API output
  • How to avoid the quoting and locale traps that make one-liners fragile

Streams and Pipelines

Every process has three standard streams: stdin (0), stdout (1), and stderr (2). A pipe connects one command's stdout to the next command's stdin.

command 2>/dev/null            # discard errors
command > out.log 2>&1         # stdout and stderr to one file
command 2>&1 | tee run.log     # see output and save it
command | head -5              # the pipeline stops early; later lines are never read

Pipelines and exit codes

By default a pipeline's exit status is the last command's. grep ERROR app.log | wc -l succeeds even if app.log doesn't exist. With set -o pipefail (part of strict mode), any failing command fails the pipeline.

Choosing the Tool

Tool Best at Example
grep Selecting lines that match a pattern Find error lines
sed Editing streams: substitute, delete, insert Change a config value
awk Fields and columns, counting, summing, reports Requests per status code
sort, uniq, cut, wc Ordering, de-duplicating, counting Top 10 client IPs
jq Parsing and transforming JSON Extract fields from API or kubectl output

grep

grep -i 'error' app.log                     # case-insensitive
grep -n -C 3 'Traceback' app.log            # line numbers with 3 lines of context
grep -v 'healthcheck' access.log            # invert: exclude matches
grep -c ' 500 ' access.log                  # count matching lines
grep -E 'timeout|refused|reset' app.log     # extended regex: alternation
grep -o 'user_id=[0-9]\+' app.log           # print only the match
grep -rIl 'password' /etc/myapp/            # recursive, skip binaries, filenames only
grep -F '[ERROR]' app.log                   # fixed string: no regex, no escaping brackets
zgrep 'ERROR' app.log.2.gz                  # search compressed rotated logs

sed

sed -n '100,120p' app.log                           # print a line range
sed 's/http:/https:/g' urls.txt                     # substitute every match on each line
sed -i.bak 's/^max_connections = .*/max_connections = 500/' postgresql.conf   # in place, keep a backup
sed '/^\s*#/d; /^\s*$/d' nginx.conf                 # strip comments and blank lines
sed -n '/2026-09-14T10:00/,/2026-09-14T10:15/p' app.log   # lines between two timestamps

sed -i on macOS

GNU sed (Linux) accepts sed -i 's/a/b/' file. BSD sed (macOS) requires an explicit backup suffix argument: sed -i '' 's/a/b/' file. Scripts that run on both should use sed -i.bak ... and remove the backup, or use perl -pi -e.

For anything more structured than a single key, prefer a real parser — yq for YAML, jq for JSON, or an Ansible module — over regex edits.

awk

awk splits each line into fields ($1, $2, … and $0 for the whole line) and runs a program against it.

Given an Nginx access log:

203.0.113.9 - - [14/Sep/2026:10:02:11 +0000] "GET /api/orders HTTP/1.1" 500 512 "-" "curl/8.9" 0.842
# Requests per status code
awk '{print $9}' access.log | sort | uniq -c | sort -rn

# Top 10 client IPs
awk '{print $1}' access.log | sort | uniq -c | sort -rn | head

# Only 5xx responses, printing time, path, and status
awk '$9 ~ /^5/ {print $4, $7, $9}' access.log

# Average response time (last field) per path
awk '{sum[$7] += $NF; n[$7]++} END {for (p in sum) printf "%.3f %s\n", sum[p]/n[p], p}' access.log | sort -rn | head

# Custom separator: users with a login shell from /etc/passwd
awk -F: '$7 !~ /nologin|false/ {print $1, $7}' /etc/passwd

sort, uniq, cut, and friends

cut -d, -f1,3 inventory.csv            # columns 1 and 3 of a CSV (no quoted commas)
sort -t, -k3,3n inventory.csv          # sort numerically by the third column
sort -u hosts.txt                      # sort and de-duplicate
uniq -c                                # count adjacent duplicates — always sort first
comm -13 <(sort old.txt) <(sort new.txt)   # lines only in new.txt
tr '[:upper:]' '[:lower:]' < names.txt
xargs -n 1 -P 4 ./check-host.sh < hosts.txt   # run 4 checks in parallel

jq

sudo apt install -y jq
# Pretty-print and pick fields
curl -s https://api.github.com/repos/prometheus/prometheus/releases/latest | jq '{tag: .tag_name, published: .published_at}'

# Raw strings for use in shell variables
TAG=$(curl -s https://api.github.com/repos/prometheus/prometheus/releases/latest | jq -r .tag_name)

# Pods that are not Running, with their node
kubectl get pods -A -o json \
  | jq -r '.items[] | select(.status.phase != "Running") | [.metadata.namespace, .metadata.name, .status.phase, .spec.nodeName] | @tsv'

# Container images across all pods, counted
kubectl get pods -A -o json | jq -r '.items[].spec.containers[].image' | sort | uniq -c | sort -rn

# Count structured log events by level
jq -r '.level' app.json.log | sort | uniq -c

# Filter JSON log lines by a field
jq -c 'select(.level == "ERROR" and .service == "orders-api")' app.json.log

# Build JSON safely from shell variables — never by string concatenation
jq -n --arg host "$HOSTNAME" --argjson disk "$(df --output=pcent / | tail -1 | tr -dc 0-9)" \
  '{host: $host, disk_used_percent: $disk}'

Worked Example: An Incident One-Liner

"Which endpoints started returning 5xx in the last 15 minutes, and from which upstream?"

since=$(date -u -d '15 minutes ago' '+%d/%b/%Y:%H:%M')
awk -v since="$since" '
  { ts = substr($4, 2, 17) }            # 14/Sep/2026:10:02
  ts >= since && $9 ~ /^5/ { count[$7" "$9]++ }
  END { for (k in count) print count[k], k }
' /var/log/nginx/access.log | sort -rn | head

String comparison of timestamps works here only because every line is within the same day. For anything longer, use structured JSON logs and query them in Loki or jq.

What Most Engineers Miss

  • Sorting depends on the locale. In en_US.UTF-8, sort ignores punctuation and case in ways that differ from byte order, and uniq and comm then disagree with your expectations. Scripts that compare sorted output should run LC_ALL=C sort, which is also much faster.
  • uniq only collapses adjacent lines. Always sort | uniq -c, or use sort -u.
  • sed -i replaces the file. It writes a new file and renames it over the old one, so hard links break and the file gets a new inode; a process holding the old file open keeps writing to the deleted copy. On a symlink, GNU sed -i replaces the link with a regular file unless you pass --follow-symlinks.
  • grep -c counts lines, not matches. A line with three matches counts once; use grep -o pattern | wc -l for occurrences.
  • grep -q can hide errors. It exits 0 as soon as one line matches, even if another file couldn't be read, and exits 1 for "no match", which stops a set -e script. Exit status 2 is the real error.
  • Some regex engines can take exponential time. GNU grep -E, sed, and awk use automata and stay fast, but grep -P, Perl, PCRE, and Python's re backtrack. Patterns with nested or adjacent quantifiers over the same characters, such as (a+)+ or .*.*=, can run for minutes on one crafted line.

Real incident: Cloudflare's regular expression (2019)

On 2 July 2019 a single new firewall rule containing .*(?:.*=.*) drove CPU to 100% on every Cloudflare machine serving HTTP traffic, taking the service down globally for 27 minutes (postmortem). The regex engine backtracked: for each = in a request it retried every way of splitting the text between the two .*. Cloudflare moved to engines that guarantee linear time. Before putting a pattern in a hot path, test it against long inputs that almost match.

Common Mistakes

  • Parsing ls output instead of using globs or find -print0 | xargs -0, and breaking on filenames with spaces.
  • Using uniq without sort, so non-adjacent duplicates aren't counted.
  • Writing cat file | grep everywhere — harmless, but grep pattern file is clearer and gives grep the filename.
  • Editing YAML or JSON with sed regexes, then corrupting the file when formatting changes.
  • Building JSON with string interpolation instead of jq --arg, producing invalid JSON when a value contains a quote.
  • Forgetting pipefail, so a missing input file produces an empty result instead of an error.

Check Yourself

Counting unique IPs

awk '{print $1}' access.log | uniq | wc -l reports far more unique IPs than exist. Why?

  • awk splits on tabs, not spaces
  • uniq only removes adjacent duplicates, so the input must be sorted first
  • wc -l counts the header line

Use sort -u | wc -l, or sort | uniq -c | sort -rn to count per IP.

Interview Questions

  • How would you find the top 10 IP addresses hitting a web server from its access log?
  • What's the difference between grep, sed, and awk, and when would you use each?
  • How do you extract the names of all failing pods across namespaces with kubectl and jq?
  • Why does set -o pipefail matter for scripts that use pipelines?

Next

Continue to Testing, Linting, and Debugging.