Learn jq by example
9 min read
jq is the fastest way to pull one number out of a 40,000-line API response. It is also, for most people, the tool they give up on twice before it sticks.
The reason is not that jq is complicated. It is that the official manual is a reference: an alphabetical list of what every function does, written for someone who already has the mental model. It never tells you the one thing that makes the rest make sense.
This post tells you that thing first, then works through the queries you will actually use, on one realistic document.
Every query below was run through Prettify's jq engine and checked against jq 1.7.1 before publishing. The outputs are what the engines returned, not what I expected them to return.
The document
{
"company": "Acme Corp",
"founded": 2019,
"headquarters": {
"city": "San Francisco",
"state": "CA",
"coordinates": { "lat": 37.7749, "lng": -122.4194 }
},
"employees": [
{ "id": 1, "name": "Alice Johnson", "role": "CEO", "email": "alice@acme.com", "salary": 185000 },
{ "id": 2, "name": "Bob Smith", "role": "CTO", "email": "bob@acme.com", "salary": 170000 },
{ "id": 3, "name": "Carol Williams", "role": "Designer", "email": "carol@acme.com", "salary": 125000 },
{ "id": 4, "name": "David Brown", "role": "Engineer", "email": "david@acme.com", "salary": 145000 }
]
}
The one idea: everything is a stream
Here is the thing the manual will not tell you.
A jq expression does not return a value. It returns a stream of values: zero, one, or many.
Not "an array sometimes". A stream. .company produces exactly one value. .employees[] produces four. empty produces none.
And the pipe does not mean "pass the result along". It means "for each value on the left, run the right side".
Once that clicks, most of jq stops needing to be memorized. If you want the longer version of why, I wrote about building a jq engine, where this idea turns out to be the whole design.
Reaching into the document
Start with ., which means "the whole thing". Then walk down with field names:
.company → "Acme Corp"
.headquarters.city → "San Francisco"
.headquarters.coordinates.lat → 37.7749
Missing fields give you null rather than an error:
.nope → null
That is deliberate, and it is why jq is pleasant against messy data. A missing key is not a crash.
.[] is the one that unlocks everything
.employees gives you the array, as one value:
.employees | length → 4
.employees[] is different. It opens the array and emits each element as its own value in the stream:
.employees[] | .name
→ "Alice Johnson"
"Bob Smith"
"Carol Williams"
"David Brown"
Four separate outputs, not an array of four. That distinction is the whole game.
Read it as: for each employee, take .name.
You can also index directly:
.employees[0] | .name → "Alice Johnson"
.employees[-1] | .name → "David Brown"
Negative indices count from the end.
A colon takes a range instead of a single element, and gives you back an array:
.employees[0:2] | map(.name) → ["Alice Johnson","Bob Smith"]
.employees[2:] | map(.name) → ["Carol Williams","David Brown"]
.employees[:1] | map(.name) → ["Alice Johnson"]
.employees[-2:] | map(.name) → ["Carol Williams","David Brown"]
Either bound can be left out, and out-of-range bounds are clamped rather than treated as an error. Strings slice the same way, so "hello world"[0:5] is "hello".
select() filters the stream
select(condition) emits its input if the condition is true, and emits nothing if it is false. It is not a filter function in the JavaScript sense. It is a gate.
.employees[] | select(.salary > 150000) | .name
→ "Alice Johnson"
"Bob Smith"
Four values went in, two came out. The other two hit the gate and produced no output at all.
Exact matches work the same way:
.employees[] | select(.role == "Engineer") | .name
→ "David Brown"
You can also filter on the result of an expression rather than a bare field. test() takes a regular expression:
.employees[] | select(.role | test("^C")) | .name
→ "Alice Johnson"
"Bob Smith"
map() is .[] with the array put back
This is where people get confused, so here it is directly.
.employees[] | .nameopens the array and gives you four separate values.employees | map(.name)keeps the array and gives you one value, an array of four
.employees | map(.name)
→ ["Alice Johnson","Bob Smith","Carol Williams","David Brown"]
Both are correct. Which you want depends on what comes next. If the next step needs an array, use map. If the next step should run once per item, use .[].
The other way to put a stream back into an array is to wrap the whole thing in brackets:
[.employees[] | .name]
→ ["Alice Johnson","Bob Smith","Carol Williams","David Brown"]
map(f) is exactly [.[] | f]. Same thing, shorter.
You can combine map and select to filter and keep an array:
.employees | map(select(.salary > 150000)) | length
→ 2
Picking fields
Curly braces build a new object. The shorthand copies a field by name:
.employees | map({name, salary})
→ [{"name":"Alice Johnson","salary":185000},
{"name":"Bob Smith","salary":170000},
{"name":"Carol Williams","salary":125000},
{"name":"David Brown","salary":145000}]
Give the keys new names by writing them out:
.employees | map({label: .name, pay: .salary})
→ [{"label":"Alice Johnson","pay":185000}, …]
This is how you reshape an API response into whatever your code actually wants.
Sorting, grouping, aggregating
.employees | sort_by(.salary) | map(.name)
→ ["Carol Williams","David Brown","Bob Smith","Alice Johnson"]
reverse flips it, so the highest paid is:
.employees | sort_by(.salary) | reverse | .[0].name
→ "Alice Johnson"
Though max_by says it more clearly:
.employees | max_by(.salary) | .name → "Alice Johnson"
.employees | min_by(.salary) | .name → "Carol Williams"
Totals and averages:
.employees | map(.salary) | add → 625000
.employees | map(.salary) | add / length → 156250
add sums the numbers in an array. In add / length, both sides get the same input, the array of salaries, so you get the sum divided by the count. Operands share an input the same way the two sides of , do.
Unique values and grouping:
.employees | map(.role) | unique → ["CEO","CTO","Designer","Engineer"]
.employees | group_by(.role) | length → 4
Joining into a string:
.employees | map(.name) | join(", ")
→ "Alice Johnson, Bob Smith, Carol Williams, David Brown"
Keys and entries
.headquarters | keys
→ ["city","coordinates","state"]
Note that keys sorts alphabetically. If you need the original document order, use keys_unsorted.
to_entries turns an object into an array of {key, value} pairs, which is how you iterate over an object whose keys you do not know in advance:
.headquarters | to_entries
→ [{"key":"city","value":"San Francisco"},
{"key":"state","value":"CA"},
{"key":"coordinates","value":{"lat":37.7749,"lng":-122.4194}}]
The order is the document's, not alphabetical: unlike keys, to_entries leaves the pairs where it found them.
Once the object is an array, map and select work on it again, and from_entries puts it back:
.headquarters | to_entries | map(select(.value | type == "string")) | from_entries
→ {"city":"San Francisco","state":"CA"}
A real one
Extracting the unique email domains from a list of people, in one line:
.employees | map(.email | split("@") | .[1]) | unique
→ ["acme.com"]
Read it left to right: for each employee, take the email, split it on @, take the second piece, then dedupe the resulting array. Every step is one of the pieces above.
The part that makes this stick
Reading examples is not how you learn jq. You learn it by writing a query, being wrong, and seeing why.
Prettify has a Transform Wizard for exactly that loop. Pick a field, choose an operator, add a sort, and it writes the jq into the query box where you can read and edit it. It samples your document to discover the available fields, works out whether you have a root array or an array nested in an object, and offers only the operators that make sense for each field's type.
The point is not that the wizard writes queries for you. It is that you can watch jq being written, on your own data, and check your mental model against something that is definitely right. That feedback loop is what the manual cannot give you.
Once the generated query stops surprising you, you do not need the wizard.
One thing to know up front: the jq panel in Prettify is a Pro feature. Everything in this post is standard jq, so it works identically in the command-line tool, which is free. Learn it wherever you like.
Three things that will trip you up
.[] inside a viewer looks like an array. Command-line jq prints each value in a stream on its own line. A GUI has to render one result, so a stream of four values is usually shown as an array of four. The semantics did not change, the display did. If you are following along in a browser tool and .employees[] looks identical to .employees, that is why.
Errors are not null. A missing key gives you null. A type error, such as indexing a number with a string, throws:
.founded.x → error: cannot index number with string "x"
Append ? and the expression swallows its own error and produces nothing:
.founded.x? → (no output)
On the command line that prints nothing at all. In a viewer, a stream of zero values usually renders as null, for the same reason a stream of four renders as an array.
That is the difference between "this field is absent" and "this query does not make sense here", and jq keeps them separate on purpose.
Strings come back quoted. The output of jq is JSON, so on the command line jq '.company' prints "Acme Corp" with the quotes still attached. Pass -r for the raw string:
$ jq -r '.company' company.json
Acme Corp
That matters the moment you pipe jq into another command.
Cheat sheet
| Goal | Query |
|---|---|
| Whole document | . |
| A field | .company |
| Nested field | .headquarters.city |
| Array as one value | .employees |
| Each element separately | .employees[] |
| First / last element | .employees[0] / .employees[-1] |
| A range of elements | .employees[0:2], .employees[2:], .employees[:2] |
| Count | .employees | length |
| One field from each | .employees | map(.name) |
| Filter | .employees[] | select(.salary > 150000) |
| Filter, keep array | .employees | map(select(.salary > 150000)) |
| Regex filter | select(.role | test("^C")) |
| Pick fields | .employees | map({name, salary}) |
| Rename fields | .employees | map({label: .name}) |
| Sort | .employees | sort_by(.salary) |
| Highest / lowest | max_by(.salary) / min_by(.salary) |
| Sum | .employees | map(.salary) | add |
| Deduplicate | .employees | map(.role) | unique |
| Join to a string | map(.name) | join(", ") |
| Object keys | .headquarters | keys |
| Key/value pairs | .headquarters | to_entries |
| Collect a stream | [.employees[] | .name] |
| Ignore errors | .founded.x? |
| Raw string output (CLI) | jq -r '.company' |
Try these against your own JSON in Prettify, or start from the JSON viewer. Parsing runs in your browser, so nothing you paste leaves your device.