Back to Blog

Working with JSON in Zsh — jq Basics, Filters & Practical #22

Sandy LaneSandy Lane

Video: Working with JSON in Zsh — jq Basics, Filters & Practical #22 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Zsh Lesson 22: jq — JSON in the Terminal

brew install jq. jq '.' pretty-prints. jq '.field' extracts. jq '.[]' iterates arrays. jq -r for 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.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.