Working with JSON in Zsh — jq Basics, Filters & Practical #22
Video: Working with JSON in Zsh — jq Basics, Filters & Practical #22 by Taught by Celeste AI - AI Coding Coach
Zsh Lesson 22: jq — JSON in the Terminal
brew install jq.jq '.'pretty-prints.jq '.field'extracts.jq '.[]'iterates arrays.jq -rfor raw (unquoted) output.jq 'select(.active)'filters.jq '{name, email}'constructs. The de facto JSON tool for shell.
When your data is JSON — APIs, configs, logs — jq is the right tool. Tiny syntax, huge expressiveness.
Pretty-print
echo '{"name":"Alice","age":30}' | jq '.'
# {
# "name": "Alice",
# "age": 30
# }
jq '.' is the identity filter — outputs the input unchanged, but formatted (indented + colored when stdout is a terminal).
Extract a field
echo '{"database":{"host":"localhost","port":5432}}' | jq '.database.host'
# "localhost"
.field accesses a key. Chain with .field.subfield for nested paths.
Array elements
echo '[{"name":"Alice"},{"name":"Bob"}]' | jq '.[0].name'
# "Alice"
# Iterate
echo '[{"name":"Alice"},{"name":"Bob"}]' | jq '.[].name'
# "Alice"
# "Bob"
.[N] for index N. .[] iterates all elements.
Raw output: -r
jq '.[].email' users.json
# "alice@example.com"
# "bob@example.com"
jq -r '.[].email' users.json
# alice@example.com
# bob@example.com
-r removes the JSON quotes. Essential when feeding the output to other shell commands.
Multiple fields
jq '.[] | .name, .role' users.json
# "Alice"
# "admin"
# "Bob"
# "user"
Pipe inside jq selects multiple fields. Each becomes a separate output.
Constructing objects
jq '.[] | {user: .name, is_admin: (.role == "admin")}' users.json
# {
# "user": "Alice",
# "is_admin": true
# }
{key: expr} builds a new object. Shorthand {name} is {name: .name}.
select(): filter
jq '.[] | select(.active == true)' users.json
# (only objects where .active is true)
jq '.[] | select(.age >= 30)' users.json
jq '.[] | select(.role | contains("admin"))' users.json
select(condition) keeps elements matching. Filter expression can use anything.
map(): transform each
jq 'map(.name)' users.json
# ["Alice", "Bob", "Charlie"]
map(expr) applies expr to every element of an array. Returns a new array.
Length, keys, values
jq 'length' users.json # array length
jq 'keys' config.json # top-level keys
jq '.users | length' state.json # nested length
length works on arrays, objects (number of keys), and strings.
Slicing
jq '.[:2]' users.json # first 2
jq '.[2:]' users.json # from index 2 to end
jq '.[1:3]' users.json # 1 to 3 (exclusive end)
Like Python slicing.
type
jq '.[] | .name, (.age | type)' users.json
# "Alice"
# "number"
type returns the JSON type as a string: "string", "number", "boolean", "null", "array", "object".
String interpolation
jq -r '.[] | "\(.name) <\(.email)>"' users.json
# Alice <alice@example.com>
# Bob <bob@example.com>
"\(.expr)" interpolates inside a string. The -r flag prints raw (no surrounding quotes).
Aggregate: sum, count
jq '[.[] | select(.active)] | length' users.json
# count of active users
jq '[.[].age] | add' users.json
# total age
jq '[.[].age] | length as $n | (add / $n)' users.json
# average age
[expr] collects results into an array. add sums.
Sort
jq 'sort_by(.age)' users.json
jq 'sort_by(.age) | reverse' users.json
jq 'sort_by(-.age)' users.json # negative for descending numbers
Group
jq 'group_by(.role)' users.json
# (array of arrays — one per role)
jq 'group_by(.role) | map({role: .[0].role, count: length})' users.json
# [{"role": "admin", "count": 1}, {"role": "user", "count": 2}]
Standard "group by, count" pipeline.
CSV / TSV output
jq -r '.[] | [.name, .email, .age] | @csv' users.json
# "Alice","alice@example.com",30
# "Bob","bob@example.com",25
jq -r '.[] | [.name, .age] | @tsv' users.json
@csv and @tsv format an array as a CSV/TSV row. Useful for piping to spreadsheets.
API workflows
# Fetch then filter
curl -s "https://api.github.com/repos/torvalds/linux" | jq '.stargazers_count'
# Map across array
curl -s "https://jsonplaceholder.typicode.com/users" \
| jq '.[] | {name, email, city: .address.city}'
# Top 5 by views
jq -r '.results | sort_by(.views) | reverse | .[:5] | .[] | .title' api_response.json
The most common use: pipe API responses through jq for the fields you need.
In a script
#!/usr/bin/env zsh
set -euo pipefail
CONFIG=config.json
# Read with defaults
HOST=$(jq -r '.database.host // "localhost"' "$CONFIG")
PORT=$(jq -r '.database.port // 5432' "$CONFIG")
echo "Connecting to $HOST:$PORT"
// is the alternative operator — provides a default if the path is null or missing.
Modifying JSON
# Add a field
jq '. + {timestamp: now}' input.json
# Update a field
jq '.users[0].active = true' state.json
# Delete a field
jq 'del(.password)' user.json
Outputs the modified JSON. To overwrite the file:
tmp=$(mktemp)
jq '.users[0].active = true' state.json > "$tmp" && mv "$tmp" state.json
(Don't > state.json while reading from it — would truncate first.)
Saving as variables
read -r name email <<< "$(jq -r '.[0] | "\(.name) \(.email)"' users.json)"
echo "$name $email"
Or using arrays:
mapfile -t names < <(jq -r '.[].name' users.json)
echo "$names[1]"
Common stumbles
Quoting jq filter. Always wrap in single quotes: jq '.field'. Double quotes let the shell try to expand $.
Missing -r. Without it, strings come back with surrounding "...". For shell consumption, almost always -r.
Filter on missing key. jq '.missing.field' returns null. Check with // "default" or select(...).
jq is whitespace-sensitive in filters? No — generally permissive. But pipe-separated stages work like Unix pipes: jq '.[] | .name'.
Chained dots vs piping. .a.b.c and .a | .b | .c are equivalent for nested access. Pipes are useful when the intermediate value is an array or you need to apply a function.
-r and arrays. jq -r '.list' on an array prints ["a","b"] (still an array). Use .list[] to iterate, then -r strips quotes per element.
No null safety. jq '.user.email' errors if .user is missing. Use ? for optional access:
jq '.user.email?' # null if any step missing
Update + redirect. jq '...' file.json > file.json truncates! Use a temp file: jq ... file.json > tmp && mv tmp file.json.
What's next
Lesson 23: curl, SSH, rsync. Network tools.
Recap
jq '.' pretty-print, .field.subfield access, .[] iterate array, .[N] index. -r for raw (unquoted) strings. select(cond) filter, map(expr) transform each. [expr] collect into array; length, add, sort_by, group_by for aggregates. {name, email} shortened object construction. @csv / @tsv output formats. // for default-on-null. For modifying files, write to temp + mv.
Next lesson: curl, SSH, rsync.