jq
Query and reshape JSON from the command line
jq reads JSON, applies a filter to it, and writes JSON back out. It exists because the usual
text tools cannot help you here: grep, sed and awk all work on lines, and JSON has no
opinion about where its lines go. The same object can arrive pretty-printed over twenty lines or
squashed onto one, and a filter written against the first shape silently returns nothing against
the second.
It is not part of a base Debian install, so start with sudo apt install jq.
The whole language is built out of ., which means "the input, unchanged". Everything else is
that with a path bolted on: .users reaches into a key, .users[] unpacks the array behind it
into one output per element, and .users[].name reaches into each of those in turn. Filters
chain with |, exactly like the shell pipe you already know, and you build a working filter by
adding one stage at a time and looking at what falls out.
Two things trip up nearly everyone. The first is that jq prints JSON, including when the
result is a bare string: you get "Alice", with the quotes, because that is what the JSON
value is. -r (--raw-output) strips them, and it is what you want any time the answer is
heading into a shell variable or another command rather than into more JSON.
The second is that the shape of your input decides where your filter starts. A top-level array
starts .[]; an object with the array buried inside it starts .users[]. Run jq . file.json
and look before writing anything else, because guessing wrong produces Cannot iterate over null, which does not say which of the two mistakes you made.
Beyond that, the useful vocabulary is small: select to filter, map to transform every element
of an array, {} to build a new object out of an old one, and length, add, sort_by and
group_by to answer questions about a whole collection at once.
Sample files used on this page
Every example below was run against these files. Recreate them to follow along.
services.json a top-level array, and deliberately not pretty-printed - this is the shape an API hands you, and the reason the first example exists
[{"name":"nginx","state":"running","restarts":0,"memory_mb":42},{"name":"postgres","state":"running","restarts":2,"memory_mb":310},{"name":"redis","state":"stopped","restarts":11,"memory_mb":0},{"name":"worker","state":"running","restarts":1,"memory_mb":128}]
api-response.json an object with the array buried inside it - the other shape, and the one that decides whether a filter starts .users[] or .[]
{
"page": 1,
"per_page": 3,
"total": 7,
"users": [
{
"id": 1,
"name": "Alice",
"email": "alice@example.com",
"active": true,
"roles": ["admin", "dev"]
},
{
"id": 2,
"name": "Bob",
"email": "bob@example.com",
"active": false,
"roles": ["dev"]
},
{
"id": 3,
"name": "Carol",
"email": "carol@example.com",
"active": true,
"roles": []
}
]
}
config.json nested objects, for the path and default-value examples
{
"server": {
"host": "deb1",
"port": 8080,
"tls": {
"enabled": false
}
},
"logging": {
"level": "info",
"file": "/var/log/app.log"
},
"workers": 4
}
events.jsonl four separate JSON objects, one per line - not an array, which is what -s is for
{"ts":"2026-08-19T09:00:00Z","level":"info","msg":"started"}
{"ts":"2026-08-19T09:01:12Z","level":"warn","msg":"slow query","ms":420}
{"ts":"2026-08-19T09:02:03Z","level":"error","msg":"connection refused"}
{"ts":"2026-08-19T09:04:44Z","level":"info","msg":"recovered"}
broken.json has a trailing comma, which JSON does not allow - for the example about what jq says when the input is not valid
{"name": "nginx", "state": "running",}
Look at the JSON before writing a filter
jq . on its own is the single most useful thing here: it validates the input and prints it in a shape you can read, which is what tells you where your real filter has to start.
Pretty-print a JSON file
jq . services.json
. means 'the input, unchanged'. jq indents and colourises the result on the way out, which is enough to turn an unreadable one-line API response into something you can navigate.
Show output
[
{
"name": "nginx",
"state": "running",
"restarts": 0,
"memory_mb": 42
},
{
"name": "postgres",
"state": "running",
"restarts": 2,
"memory_mb": 310
},
{
"name": "redis",
"state": "stopped",
"restarts": 11,
"memory_mb": 0
},
{
"name": "worker",
"state": "running",
"restarts": 1,
"memory_mb": 128
}
]
Squash JSON back onto one line
jq -c . api-response.json
-c (--compact-output) is the reverse of pretty-printing. Useful when the result is going into a file one record per line, or into a tool that wants a single line.
Show output
{"page":1,"per_page":3,"total":7,"users":[{"id":1,"name":"Alice","email":"alice@example.com","active":true,"roles":["admin","dev"]},{"id":2,"name":"Bob","email":"bob@example.com","active":false,"roles":["dev"]},{"id":3,"name":"Carol","email":"carol@example.com","active":true,"roles":[]}]}
List the top-level keys
jq -r 'keys[]' config.json
keys returns the object's keys as an array, sorted; [] unpacks that array into one output per key. The fastest way to find out what you are actually holding.
Show output
logging
server
workers
Check whether a file is valid JSON
jq empty config.json
empty produces no output at all, so this parses the input and prints nothing. Exit status 0 means the file is valid JSON, which is the shape of a precondition check in a script.
See what jq says when the input is not valid JSON
jq empty broken.json
The message names the line and column, which is the fastest route to a stray trailing comma. jq exits 5 on a parse error, distinct from the 1 it uses for 'the filter produced nothing useful'.
Show output
jq: parse error: Expected another key-value pair at line 1, column 38
Reaching into objects
A path is a chain of keys: .server.tls.enabled walks down three levels. Missing keys give null rather than an error, which is a feature until it is a surprise.
Read a top-level value
jq '.workers' config.json
.workers reaches into the key of that name. Numbers come out as numbers, ready to use in arithmetic.
Show output
4
Read a nested value
jq '.server.host' config.json
Chain keys with dots to walk down. The result is a JSON string, quotes and all, because that is what the value is.
Show output
"deb1"
Get the string without its quotes
jq -r '.server.host' config.json
-r (--raw-output) prints strings unquoted and unescaped. This is the flag you want any time the answer is going into a shell variable or another command rather than into more JSON.
Show output
deb1
Read a boolean three levels down
jq '.server.tls.enabled' config.json
true and false come out as bare JSON booleans, not strings, which is worth remembering when comparing them: .enabled == "false" is never true.
Show output
false
A key that does not exist gives null, not an error
jq '.nothing' config.json
jq treats a missing key as null rather than failing. Convenient when a field is genuinely optional, and the reason a typo in a key name reads as 'the data is empty' instead of 'you spelled it wrong'.
Show output
null
Supply a default when a key is missing
jq '.logging.retention // 7' config.json
// is the alternative operator: it yields the left side unless that is null or false, in which case it yields the right. The idiomatic way to give an optional setting a fallback.
Show output
7
List the keys of a nested object
jq '.server | keys' config.json
| chains filters exactly like a shell pipe: reach into .server first, then ask that object for its keys.
Show output
[
"host",
"port",
"tls"
]
Arrays: unpacking, indexing, and the mistake everyone makes
.[] turns one array into many separate outputs, which is what lets everything after it work on a single element at a time.
Unpack an array into one object per line
jq -c '.[]' services.json
.[] on a top-level array emits each element as its own output. Note this is four results, not one array of four, and the difference matters as soon as you pipe it somewhere.
Show output
{"name":"nginx","state":"running","restarts":0,"memory_mb":42}
{"name":"postgres","state":"running","restarts":2,"memory_mb":310}
{"name":"redis","state":"stopped","restarts":11,"memory_mb":0}
{"name":"worker","state":"running","restarts":1,"memory_mb":128}
Take a single element by index
jq '.[0]' services.json
Array indexing is zero-based. .[-1] counts from the end, which is the usual way to get the most recent entry out of an append-only list.
Show output
{
"name": "nginx",
"state": "running",
"restarts": 0,
"memory_mb": 42
}
Pull one field out of every element
jq -r '.users[].name' api-response.json
.users[] unpacks the array behind the users key, then .name applies to each element in turn. The most common jq you will ever write.
Show output
Alice
Bob
Carol
Pull two fields out, in order
jq -r '.users[] | .name, .email' api-response.json
A comma runs both filters against the same input and emits both results, so this interleaves name and email per user rather than listing all the names first.
Show output
Alice
alice@example.com
Bob
bob@example.com
Carol
carol@example.com
The mistake: starting at the wrong level
jq -c '.users[]' config.json
config.json has no users key, so .users is null and [] cannot iterate over it. This error nearly always means the input is a different shape from the one the filter was written for. Run jq . on it and look before changing anything else.
Show output
jq: error (at config.json:14): Cannot iterate over null (null)
Ask for a key that might not be there
jq -c '.users[]?' config.json
The ? suffix suppresses the error and emits nothing instead. Right when filtering a stream where only some records carry the field; wrong when you would rather be told the field is missing.
Join an array of strings into one
jq -r '.users[0].roles | join(", ")' api-response.json
join collapses an array of strings into a single string with the given separator, which together with -r is how you get a comma-separated list out into the shell.
Show output
admin, dev
Filtering with select
select(condition) passes its input through when the condition holds and emits nothing when it does not. Put it after a .[] and you have a filter over the whole collection.
Keep only the elements matching a condition
jq -c '.[] | select(.state == "running")' services.json
Unpack with .[], then keep the ones where the test holds. Three of the four survive here.
Show output
{"name":"nginx","state":"running","restarts":0,"memory_mb":42}
{"name":"postgres","state":"running","restarts":2,"memory_mb":310}
{"name":"worker","state":"running","restarts":1,"memory_mb":128}
Filter, then take one field
jq -r '.users[] | select(.active) | .name' api-response.json
The pipeline unpacks, filters and then projects. A bare .active is enough as a condition when the value is already a boolean.
Show output
Alice
Carol
Filter on a numeric comparison
jq -r '.[] | select(.memory_mb > 100) | .name' services.json
>, <, >= and <= compare numbers directly, with no quoting or conversion needed, because jq already knows the value is a number.
Show output
postgres
worker
Find anything that has restarted at all
jq -c '.[] | select(.restarts > 0)' services.json
The shape of most monitoring one-liners: unpack a list of things, keep the ones whose counter is non-zero.
Show output
{"name":"postgres","state":"running","restarts":2,"memory_mb":310}
{"name":"redis","state":"stopped","restarts":11,"memory_mb":0}
{"name":"worker","state":"running","restarts":1,"memory_mb":128}
Filter on membership in a nested array
jq -r '.users[] | select(.roles | contains(["admin"])) | .name' api-response.json
contains asks whether one value is contained in another. On arrays it takes an array, so the argument is ["admin"] rather than a bare string.
Show output
Alice
Filter on a regular expression
jq -r '.users[] | select(.email | test("^a")) | .email' api-response.json
test applies a regex and returns a boolean. The syntax is the same one grep uses with -E, so nothing new to learn.
Show output
alice@example.com
Ask whether any element matches
jq 'any(.[]; .state == "stopped")' services.json
any(stream; condition) reduces the whole collection to a single true or false, which is the right shape when a script only needs to know whether a problem exists rather than which one.
Show output
true
Ask whether every element matches
jq 'all(.[]; .restarts == 0)' services.json
all is the counterpart to any, and answers 'is everything healthy' in one call. False here, because three of the four have restarted.
Show output
false
Reshaping: building new objects and arrays
{} builds an object, [] collects a stream back into an array, and map applies a filter to every element of one. Between them they turn somebody else's JSON into yours.
Keep only the fields you care about
jq -c '.users[] | {name, email}' api-response.json
{name, email} is shorthand for {name: .name, email: .email}, the most common reshaping there is, and the reason a forty-field API record becomes readable.
Show output
{"name":"Alice","email":"alice@example.com"}
{"name":"Bob","email":"bob@example.com"}
{"name":"Carol","email":"carol@example.com"}
Rename fields and compute new ones
jq -c '.users[] | {user: .name, admin: (.roles | contains(["admin"]))}' api-response.json
The long form takes any filter on the right, so a field can be derived rather than copied. Parentheses are needed around anything containing a |.
Show output
{"user":"Alice","admin":true}
{"user":"Bob","admin":false}
{"user":"Carol","admin":false}
Collect a stream back into a single array
jq -c '[.users[].id]' api-response.json
Wrapping a filter in [...] gathers everything it emits into one array. Without the brackets this prints three separate numbers, which is a different thing entirely.
Show output
[1,2,3]
Transform every element of an array
jq -c 'map(.name)' services.json
map(f) is [.[] | f] written more clearly: array in, array out. Use it when the result should stay an array, and .[] | when you want separate outputs.
Show output
["nginx","postgres","redis","worker"]
Filter and transform in one chain
jq -c 'map(select(.state == "running")) | map(.name)' services.json
map(select(...)) filters an array while keeping it an array, the array-preserving counterpart to .[] | select(...).
Show output
["nginx","postgres","worker"]
Turn an object into key/value pairs
jq -r '.logging | to_entries[] | "\(.key)=\(.value)"' config.json
to_entries converts an object into an array of {key, value} objects, which is how you iterate over an object whose keys you do not know in advance. \(...) interpolates a value into a string.
Show output
level=info
file=/var/log/app.log
Rewrite every key of an object
jq -c '.logging | with_entries(.key |= ascii_upcase)' config.json
with_entries(f) is to_entries | map(f) | from_entries, so f sees each {key, value} pair. |= updates one field of that pair rather than replacing the whole thing.
Show output
{"LEVEL":"info","FILE":"/var/log/app.log"}
Drop a field from every record
jq -c 'del(.users[].email)' api-response.json
del takes a path and removes it, keeping the surrounding structure intact. Handy for stripping secrets or noise out of a response before saving or sharing it.
Show output
{"page":1,"per_page":3,"total":7,"users":[{"id":1,"name":"Alice","active":true,"roles":["admin","dev"]},{"id":2,"name":"Bob","active":false,"roles":["dev"]},{"id":3,"name":"Carol","active":true,"roles":[]}]}
Format each record as a sentence
jq -r '.[] | "\(.name) uses \(.memory_mb)MB"' services.json
String interpolation plus -r turns JSON into human-readable lines. This is usually the last stage of a pipeline, not something to feed back into another jq.
Show output
nginx uses 42MB
postgres uses 310MB
redis uses 0MB
worker uses 128MB
Counting and aggregating
The questions you want answered about a collection: how many, how much, which is biggest, how do they group.
Count the elements of an array
jq '.users | length' api-response.json
length counts elements of an array, keys of an object, and characters of a string, one word covering what three different tools would do on text.
Show output
3
Count the records in a top-level array
jq 'length' services.json
No path needed when the array is the whole document. The wc -l of JSON, and correct where wc would not be, since this file is a single line.
Show output
4
Total a numeric field across every record
jq '[.[].memory_mb] | add' services.json
Collect the field into an array with [...], then add sums it. add needs an array rather than a stream, which is why the brackets are not optional here.
Show output
480
Average a numeric field
jq 'map(.memory_mb) | add / length' services.json
There is no average builtin; add / length is the idiom. Both operate on the same array, so map has to come first.
Show output
120
Get min, max and total in one pass
jq -c 'map(.memory_mb) | {min: min, max: max, total: add}' services.json
Building an object out of several aggregates reads better than running jq three times, and parses the input only once.
Show output
{"min":0,"max":310,"total":480}
Find the record with the largest value
jq -c 'max_by(.memory_mb)' services.json
max_by returns the whole element rather than just the field, which is what you wanted: 'which service' is the question and 310 is only the evidence. min_by is the counterpart.
Show output
{"name":"postgres","state":"running","restarts":2,"memory_mb":310}
Sort records by a field
jq -c 'sort_by(.memory_mb) | map(.name)' services.json
sort_by(f) orders an array by whatever f returns. For descending order, negate a numeric key with sort_by(-.memory_mb) rather than adding reverse.
Show output
["redis","nginx","worker","postgres"]
Group records and count each group
jq -c 'group_by(.state) | map({state: .[0].state, count: length})' services.json
group_by returns an array of arrays, one per distinct value, so the map afterwards is what turns each group back into a summary. The JSON equivalent of sort | uniq -c.
Show output
[{"state":"running","count":3},{"state":"stopped","count":1}]
jq in shell scripts and pipelines
-r gets values out to the shell, --arg gets shell values safely in, and -e turns a query into an exit status.
Loop over values in the shell
jq -r '.[] | .name' services.json | while read -r svc; do echo "checking $svc"; done
-r is what makes this work: without it every value arrives with its quotes still attached, and $svc would be "nginx" rather than nginx.
Show output
checking nginx
checking postgres
checking redis
checking worker
Build a command line from the results
jq -r '.[] | select(.state == "stopped") | .name' services.json | xargs -r -I{} echo systemctl start {}
xargs -r does nothing at all when the input is empty, which is exactly right when the filter found no problems. Drop the echo to actually run it, and see systemctl for what it would do.
Show output
systemctl start redis
Pass a shell variable into a filter safely
jq --arg name Bob -c '.users[] | select(.name == $name)' api-response.json
--arg defines a jq variable from a shell value. Always use it rather than interpolating into the filter string: a value containing a quote would otherwise change what the filter means.
Show output
{"id":2,"name":"Bob","email":"bob@example.com","active":false,"roles":["dev"]}
Build JSON from shell values, with no input file
jq -n --arg host deb1 --argjson port 8080 -c '{host: $host, port: $port}'
-n (--null-input) runs the filter with no input at all, so jq becomes a JSON writer rather than a reader. --arg always produces a string; --argjson parses its value, which is how port stays a number.
Show output
{"host":"deb1","port":8080}
Use a query as a script's exit status
jq -e '.workers' config.json; echo "exit $?"
-e (--exit-status) exits 0 when the last result was neither null nor false, and 1 otherwise, which is what lets if jq -e ...; then test the contents of a JSON file directly.
Show output
4
exit 0
A missing key exits 1 under -e
jq -e '.missing' config.json; echo "exit $?"
The output is still null, but the exit status now says so. This pairing is what makes jq usable in a conditional at all. See exit codes and error handling for the rest of that story.
Show output
null
exit 1
The -e trap: false and missing look identical
jq -e '.server.tls.enabled' config.json; echo "exit $?"
.enabled is present and set to false, and -e reports exactly the same 1 it would for a key that does not exist. When the difference matters, test with has("enabled") and ignore the exit status.
Show output
false
exit 1
Emit CSV, with quoting handled correctly
jq -r '.[] | [.name, .state, .memory_mb] | @csv' services.json
@csv takes an array and formats it as one CSV row, quoting strings and escaping embedded quotes properly. @tsv does the same with tabs, which is friendlier to cut and awk.
Show output
"nginx","running",42
"postgres","running",310
"redis","stopped",0
"worker","running",128
Query JSON that another command produced
ip -j -4 addr show lo | jq -r '.[0].addr_info[0].local'
A growing number of system tools speak JSON on request: ip -j, journalctl -o json, systemctl --output=json. That is the point where jq stops being a convenience and becomes the only sane way to read the output.
Show output
127.0.0.1
Streams of JSON, and writing the result back
A file of one object per line is not an array, and jq treats the two differently. Editing operators change values in place, but getting the result back onto disk needs care.
Filter a file of one JSON object per line
jq -c 'select(.level == "error")' events.jsonl
jq reads consecutive JSON values as a stream, so an NDJSON log needs no .[] at all: the filter already runs once per record. This is grep for structured logs.
Show output
{"ts":"2026-08-19T09:02:03Z","level":"error","msg":"connection refused"}
Format matching log records as text
jq -r 'select(.level != "info") | "\(.level): \(.msg)"' events.jsonl
Select, then interpolate: the standard shape for turning a structured log into something readable. See journalctl for where JSON logs come from on a Debian system.
Show output
warn: slow query
error: connection refused
Treat a stream as one array instead
jq -s 'length' events.jsonl
-s (--slurp) reads every input value into a single array before running the filter. Needed for anything that has to see the whole set at once: counting, sorting, totalling.
Show output
4
Aggregate across a whole NDJSON file
jq -s -c 'group_by(.level) | map({level: .[0].level, n: length})' events.jsonl
The same group_by as before, made possible by -s. Without the slurp, group_by runs once per record and dutifully groups each one on its own.
Show output
[{"level":"error","n":1},{"level":"info","n":2},{"level":"warn","n":1}]
Convert an array file into one object per line
jq -c '.[]' services.json > services.jsonl && cat services.jsonl
.[] plus -c is the array-to-NDJSON conversion, and -s is the way back. Between them they cover most of the format wrangling JSON tooling asks for.
Show output
{"name":"nginx","state":"running","restarts":0,"memory_mb":42}
{"name":"postgres","state":"running","restarts":2,"memory_mb":310}
{"name":"redis","state":"stopped","restarts":11,"memory_mb":0}
{"name":"worker","state":"running","restarts":1,"memory_mb":128}
Set a value
jq -c '.server.tls.enabled = true | .server' config.json
= assigns to a path and emits the whole modified document, so the | .server afterwards is only there to keep the output short. jq never edits the file itself.
Show output
{"host":"deb1","port":8080,"tls":{"enabled":true}}
Update a value based on what it already was
jq -c '.workers |= . * 2 | {workers}' config.json
|= runs a filter against the current value and stores the result, where = needs the new value spelled out. The difference matters as soon as the new value depends on the old one.
Show output
{"workers":8}
Write the edited JSON back to its file
jq '.logging.level = "debug"' config.json > config.tmp && mv config.tmp config.json && jq -c .logging config.json
Write to a temporary file and rename it over the original. mv within one filesystem is atomic, so a reader sees either the old file or the new one and never a half-written one.
Show output
{"level":"debug","file":"/var/log/app.log"}
Never redirect jq onto its own input file
jq '.workers = 8' config.json > config.json
The shell truncates config.json to open the redirect, before jq has read a byte of it. Verified in the sandbox: this leaves the file zero bytes long and the data gone. Use the temporary-file form above instead, and see pipes and redirection for why the shell does this.
Indent with tabs instead of spaces
jq --tab . config.json | head -4
--tab indents with tab characters; --indent N sets a different number of spaces, up to 7. Useful when the result has to match a project's existing formatting.
Show output
{
"server": {
"host": "deb1",
"port": 8080,