Skip to content

06 · Advanced Text Processing Pipelines

grep, awk, sed, and jq are each capable on their own, but production data-processing scripts get their power from chaining them: filter with grep, extract/transform with awk, reshape with sed, and query/reshape JSON with jq. This module builds several real multi-stage pipelines, including converting between plain text, CSV, and JSON.

Thinking in pipeline stages

Most text pipelines follow the same shape: filter → extract → transform → aggregate. Naming the stage you're on makes a long pipeline easy to debug one | at a time.

# filter (grep) -> extract (awk) -> aggregate (sort | uniq -c) -> rank (sort -rn)
grep 'ERROR' app.log | awk '{print $1, $2}' | sort | uniq -c | sort -rn | head -5

Build pipelines incrementally: run the first stage alone, confirm the output looks right, then pipe in the next stage — trying to write a five- stage pipeline in one shot is how silent mistakes creep in.

grep to filter, awk to extract

# pull just the client IP and status code for 5xx responses in an access log
grep ' 5[0-9][0-9] ' access.log | awk '{print $1, $9}'
# keep only lines mentioning a given user, then extract the action column
grep 'user=alice' audit.log | awk -F'action=' '{print $2}' | awk '{print $1}'

awk to extract, sed to reshape

# turn "name=alice age=30 city=nyc" lines into "alice,30,nyc"
echo "name=alice age=30 city=nyc" \
    | awk '{print $1, $2, $3}' \
    | sed -E 's/[a-z]+=//g; s/ /,/g'
# alice,30,nyc

Introducing jq for JSON

echo '{"name":"alice","age":30}' | jq '.name'
# "alice"

echo '{"name":"alice","age":30}' | jq -r '.name'    # -r strips the quotes
# alice

jq filters chain with | just like shell pipes, but inside a single jq argument — .foo.bar, .[0], and select(...) are the building blocks for everything below.

jq: navigating and filtering arrays

cat users.json
# [{"name":"alice","active":true},{"name":"bob","active":false}]

jq '.[] | select(.active == true) | .name' users.json
# "alice"

jq -r '.[] | select(.active) | .name' users.json
# alice

jq: reshaping objects with map

jq '[.[] | {user: .name, status: (if .active then "active" else "inactive" end)}]' users.json
# [{"user":"alice","status":"active"},{"user":"bob","status":"inactive"}]

Combining jq with curl, awk, and grep

# fetch a JSON API, keep only active users, uppercase their names
curl -s https://api.example.com/users \
    | jq -r '.[] | select(.active) | .name' \
    | awk '{ print toupper($0) }'

A full pipeline: log analysis to a ranked JSON report

# access.log fields: $1=IP ... $9=status code
awk '{ print $1, $9 }' access.log \
    | sort \
    | uniq -c \
    | awk '{ printf "{\"ip\":\"%s\",\"status\":\"%s\",\"count\":%d}\n", $3, $2, $1 }' \
    | jq -s 'sort_by(-.count)'

Reading it stage by stage: awk extracts IP + status, sort | uniq -c counts identical pairs, the second awk emits one JSON object per line, and jq -s ("slurp") reads all of those separate JSON lines into a single sorted array.

Converting CSV to JSON and back with jq

# CSV -> JSON (first row is the header)
awk -F, 'NR==1 { split($0, h, ","); next }
         { printf "{"; for (i=1; i<=NF; i++)
               printf "\"%s\":\"%s\"%s", h[i], $i, (i<NF ? "," : "");
           print "}" }' data.csv | jq -s '.'
# JSON -> CSV (array of flat objects, using the first object's keys as the header)
jq -r '(.[0] | keys_unsorted) as $keys | $keys, (.[] | [.[$keys[]]]) | @csv' data.json

How It Actually Works

Converting between CSV and JSON with jq highlights a fundamental difference from the line-oriented tools (sed/awk/grep) used elsewhere in this course: jq must parse its entire input into an in-memory abstract syntax tree representing the full JSON document structure before it can evaluate a filter against it, because JSON values can nest arbitrarily and a filter like .[] | .name needs the complete structure to know where object boundaries are — this is why jq isn't naturally streaming the way grep is, and why jq's --stream mode (which does support incremental processing) has to represent the input as a flat sequence of path/leaf-value events instead of the friendlier nested filter syntax.

Chaining jq in a pipeline with sed/awk still uses the same kernel pipe mechanism as any other pipeline stage — the difference is purely in each tool's internal buffering model (line-oriented and streaming for sed/awk, whole-document for plain jq), which is why a jq stage in an otherwise-streaming pipeline can introduce a full stop-and-wait point: nothing downstream sees any output until jq has consumed its entire input and finished evaluating.

Multi-stage text pipelines that mix formats (CSV → TSV → JSON) are, at the OS level, indistinguishable from any other pipeline — the kernel just moves undifferentiated bytes through pipe buffers; all the "format" awareness lives entirely in each tool's own parsing logic, which is why a subtly malformed delimiter (an unescaped comma inside a CSV field) breaks downstream tools with no signal from the kernel that anything went wrong.

Cheat sheet

Tool Best for
grep filtering lines by pattern
awk field extraction, math, reports
sed line-based text substitution/reshaping
jq querying/transforming JSON
jq -r raw (unquoted) string output
jq -s slurp multiple JSON lines/values into one array
sort \| uniq -c count duplicate lines
@csv (in jq) render an array as a CSV row

Exercise

Given an access.log in Common Log Format, write a single pipeline that extracts the requested path and status code, keeps only 4xx/5xx responses, counts occurrences per path, and emits the result as a JSON array of {"path": ..., "count": ...} objects sorted by count descending. Verify the output is valid JSON by piping it through jq length.