A webhook is failing in staging and the payload is a 4KB blob of nested JSON in your terminal scrollback. You need the id of every event where status is "failed". Copying it into an editor to eyeball it is the slow path. This is the exact shape of problem jq exists for:
curl -s https://api.example.com/events \
| jq -r '.events[] | select(.status == "failed") | .id'
That prints one bare ID per line, ready to pipe into xargs or a while read loop. No script file, no json.load, no leaving the shell.
The filters that cover 90% of the work
| Filter | What it does |
|---|---|
.foo | Read a field |
.["content-type"] | Read a field whose name has a dash or dot |
.items[] | Stream each array element |
select(.x == "y") | Keep only matching elements |
map(.price) | Transform every element, return an array |
to_entries[] | Iterate an object as key/value pairs |
-r | Raw output — strip the surrounding quotes |
-c | Compact — one JSON object per line (JSONL) |
Mistakes everyone makes at least once
Iterating an object as if it were an array
.[] works on both arrays and objects, but people reach for .data[] expecting a list and get Cannot iterate over null (null) because .data is an object keyed by ID. Use .data | to_entries[] (or .data[] if you only want the values), and remember to_entries gives you .key and .value.
Forgetting -r and shipping quotes downstream
Without -r, jq '.token' prints "eyJhbGci..." — quotes included. Feed that into an Authorization: Bearer header and the request fails with a confusing 401, because the literal " characters are now part of the token. Any time the output feeds another command, you almost certainly want -r.
Assuming a wrong-type field behaves like a missing one
A missing field is harmless — .user.address.city on a record with no address returns null silently. The error comes from a field that's present but the wrong type: when address is a string instead of an object, jq throws Cannot index string with string "city". Guard it by putting ? after the risky access and supplying a default: .user.address.city? // "unknown" yields "unknown" whether the field is absent, null, or the wrong type.
Not quoting the jq program
jq .items[0] sometimes works and sometimes doesn't, because the shell may glob [0] against filenames or split on spaces. Always single-quote the whole program: jq '.items[0]'. Single quotes also stop the shell from expanding $ before jq sees it.
select vs map: the confusing pair
Both filter, but they return different shapes, and picking the wrong one breaks the next stage of your pipeline.
.items[] | select(.active) | map(select(.active)) | |
|---|---|---|
| Input | an array | an array |
| Output | a stream of separate results | a single array |
| Count downstream | many values | one value |
| Use when | piping to -r, xargs, a loop | you need valid JSON to store or POST back |
If you select a stream and then try to POST it as a request body, you send several concatenated JSON objects, not a valid array — a common cause of "invalid JSON" from the receiving API. Wrap it: [.items[] | select(.active)], or reach for map.
jq earns its place as an inspection and glue tool. The moment a one-liner grows conditionals and reused sub-expressions, that logic wants a real, tested program — jq is where you find the transform, not where it should live forever.
Cover photo by Stanislav Kondratiev on Pexels.
