jq: Parsing JSON on the Command Line Without Reaching for Python
Every API you will ever call answers in JSON, and approximately none of them format it for human beings. What arrives is four thousand characters on a single line, wrapped by your terminal into a grey paragraph of punctuation, containing somewhere — in there, definitely — the one field you actually wanted.
The traditional responses are to paste it into a browser tab, or to write six lines of Python. Both work. Both are also a detour around a tool that has existed since 2012, does the job in one pipe, and is available on the server you’ll be SSHed into at 2am when the browser tab is not.
jq is a small language for filtering and reshaping JSON, written in C by Stephen Dolan and now maintained by the jqlang project. It has a reputation for being cryptic, which it earns somewhere around its third page of documentation — but the first page covers most of what anyone needs, and that page is short.
The short version
Section titled “The short version”If you’re here for one command, it’s the second one:
| You want | Command |
|---|---|
| Readable output | curl -s $URL | jq . |
| One field | curl -s $URL | jq -r '.name' |
| One field from every item | curl -s $URL | jq -r '.items[].name' |
| Only the items that match | jq '.items[] | select(.state == "open")' |
| Something a shell can consume | jq -r '.items[] | [.id, .name] | @tsv' |
The rest of this guide explains why those work, so you can write the sixth one yourself.
Installing it
Section titled “Installing it”brew install jq # macOSsudo apt install jq # Debian, Ubuntusudo dnf install jq # Fedora, RHELCheck what you’ve got, because it matters more than usual here:
jq --versionjq 1.6 sat as the current release for five years, so it is still what macOS ships in
/usr/bin/jq and what several stable distributions install. 1.7 arrived in 2023 and 1.8 in
2025, adding builtins such as pick and abs. Everything in this guide works on 1.6 and
later; if a filter you’ve copied from elsewhere fails with is not defined, you have found a
newer builtin than your jq.
A file to practise on
Section titled “A file to practise on”Save this as servers.json and every command below will produce exactly the output shown:
{ "region": "eu-west-1", "updated": "2026-08-30", "servers": [ { "name": "web-01", "role": "web", "cpu": 0.82, "memory_mb": 4096, "tags": ["prod", "public"] }, { "name": "web-02", "role": "web", "cpu": 0.34, "memory_mb": 4096, "tags": ["prod", "public"] }, { "name": "db-01", "role": "db", "cpu": 0.91, "memory_mb": 16384, "tags": ["prod"] }, { "name": "cache-01", "role": "cache","cpu": 0.12, "memory_mb": 2048, "tags": ["prod"] }, { "name": "web-03", "role": "web", "cpu": 0.05, "memory_mb": 4096, "tags": ["staging"] } ]}Filters are just paths, then pipes
Section titled “Filters are just paths, then pipes”The first thing everyone learns is jq ., the identity filter, which takes the JSON in and
gives the same JSON back — indented, coloured, and legible. An enormous number of people stop
here and use jq as a pretty-printer for the rest of their careers. It is a perfectly
respectable life, and you can do better in about four minutes.
Reach into the document by writing the path you’d write in JavaScript:
jq '.region' servers.json # "eu-west-1"jq '.servers[0].name' servers.json # "web-01"Then chain filters with |, exactly as you would in the shell — the value on the left becomes
the input on the right:
jq '.servers | length' servers.json # 5That is the whole mental model. jq is a pipeline of filters, inside one argument of a pipeline of commands, which is either elegant or a bit much depending on how your day is going.
.[]: the bit that unlocks everything
Section titled “.[]: the bit that unlocks everything”.[] takes an array and emits each element as a separate result. It is the difference
between jq as a viewer and jq as a tool:
jq '.servers[]' servers.json # five objects, one after anotherjq -r '.servers[].name' servers.jsonweb-01web-02db-01cache-01web-03Note what happened: the output is no longer one JSON document but five, streamed. Everything
after .[] in the pipeline runs once per element — a loop, without any of the ceremony of
writing one.
Wrap an expression in [ ] to collect that stream back into a single array, which you’ll need
whenever a later filter expects one thing rather than many:
jq '[.servers[].memory_mb] | add' servers.json # 30720Array slices work as you’d hope: .servers[0] is the first, .servers[-1] the last, and
.servers[1:3] the two in between.
-r, for output other programs can read
Section titled “-r, for output other programs can read”By default jq emits JSON, so strings come out wearing quotation marks. That’s correct — it is
a JSON processor — and completely useless when you want to pass the result to another command.
-r (or --raw-output) drops the quotes:
jq '.region' servers.json # "eu-west-1"jq -r '.region' servers.json # eu-west-1If you are piping jq’s output anywhere at all, you almost certainly want -r. Its siblings:
-c puts each result on one compact line (ideal for logs and for feeding jq again), -j
suppresses the trailing newline, and -S sorts object keys so that two versions of the same
document can be diffed sensibly.
select(): the workhorse
Section titled “select(): the workhorse”select(condition) passes its input through if the condition is true and emits nothing at all
if it isn’t. Combined with .[], that’s a WHERE clause:
jq -r '.servers[] | select(.role == "web") | .name' servers.jsonweb-01web-02web-03The condition is any jq expression, so this is where the real filtering lives:
jq -r '.servers[] | select(.cpu > 0.8) | .name' servers.jsonjq -r '.servers[] | select(.name | test("^web")) | .name' servers.jsonjq -r '.servers[] | select(any(.tags[]; . == "prod")) | .name' servers.jsontest() is a regex match; any(.tags[]; . == "prod") asks whether any tag equals prod.
There is a shorter way to write that last one, and it is a trap — see the gotchas below.
Reshaping as you go
Section titled “Reshaping as you go”jq builds JSON as readily as it reads it. Object construction uses the shorthand you’d expect,
and string interpolation with \(...) turns records into sentences:
jq -c '.servers | map({name, cpu})' servers.json[{"name":"web-01","cpu":0.82},{"name":"web-02","cpu":0.34},...]jq -r '.servers[] | select(.cpu > 0.8) | "\(.name) is at \(.cpu * 100 | floor)%"' servers.jsonweb-01 is at 82%db-01 is at 91%map(f) is simply [.[] | f] with a friendlier face: it applies a filter to every element of
an array and hands back an array. Use map when you’re staying inside a single document, and
.[] when you want a stream.
Counting things
Section titled “Counting things”The aggregate builtins are the reason jq quietly replaces a lot of small scripts:
jq '.servers | length' servers.json # 5jq '[.servers[].memory_mb] | add' servers.json # 30720jq '.servers | map(.memory_mb) | add / length' servers.json # 6144jq -r '.servers | max_by(.cpu) | .name' servers.json # db-01jq -r '[.servers[].tags[]] | unique | join(", ")' servers.jsonprod, public, staginggroup_by does what SQL taught you it does, though it returns an array of arrays and expects
you to tidy up after it:
jq -r '.servers | group_by(.role) | map({role: .[0].role, count: length}) | .[] | "\(.role): \(.count)"' servers.jsoncache: 1db: 1web: 3And because jq is perfectly willing to multiply a string by a number, you can commit minor atrocities such as rendering a bar chart:
jq -r '.servers[] | "\(.name)\t\("#" * (.cpu * 20 | round))"' servers.jsonweb-01 ################web-02 #######db-01 ##################cache-01 ##web-03 #Nobody needs this. Everybody does it once.
Handing the results to other commands
Section titled “Handing the results to other commands”The clean way out of JSON and into the rest of the shell is @tsv, which takes an array and
emits tab-separated fields with the escaping handled properly:
jq -r '.servers[] | [.name, .role, .memory_mb] | @tsv' servers.jsonWhich drops straight into a loop, tabs being the one separator that names and roles will never contain:
jq -r '.servers[] | [.name, .role] | @tsv' servers.json |while IFS=$'\t' read -r name role; do echo "$name has role $role"done@csv does the same with quoting for spreadsheets, and @sh quotes values for safe insertion
into a shell command — which is the correct way to build commands out of JSON, rather than
hoping nobody’s filename contains a space.
Values go the other way with --arg (a string) and --argjson (any JSON), and you should use
them rather than splicing shell variables into the filter text:
role=webjq --arg role "$role" -r '.servers[] | select(.role == $role) | .name' servers.jsonSix gotchas that will cost you an afternoon
Section titled “Six gotchas that will cost you an afternoon”1. // fires on false, not just on missing. The alternative operator looks like a
default value and behaves like one right up until the real value is false:
echo '{"a": false}' | jq '.a // "fallback"' # "fallback"// means “if the left side is null, false, or an error”. For genuinely optional booleans,
test with has("a") instead.
2. contains does substring matching inside arrays. It looks like the obvious way to check
a tag, and it is quietly wrong:
jq -r '.servers[0].tags | contains(["pro"])' servers.json # true (!)jq -r '.servers[0].tags | index("prod")' servers.json # 0contains recurses into strings, so "pro" matches "prod". Use index("prod") for an exact
element, or any(.tags[]; . == "prod") when you want to be unambiguous about it.
3. -r prints null as the word “null”. A missing field is null, and raw output renders
it literally, so a bad path yields a cheerful four-character string rather than an error:
echo '{"a": null}' | jq -r '.a' # nullIf that value ends up in a filename, you’ll find out eventually. // empty suppresses the
result entirely, which is usually what you meant.
4. Keys with dashes or spaces need quoting. .content-type parses as .content minus
.type, and the error message is not going to mention that:
jq -r '."content-type"' response.jsonjq -r '.["x-rate limit"]' response.json5. NDJSON is not an array. Logs and streaming APIs emit one JSON object per line, and jq
handles that natively by processing each document in turn — which means anything expecting a
collection will fail or quietly lie. Given an events.ndjson of one object per line:
jq -c 'select(.ok)' events.ndjson # fine: filters each line in turnjq 'map(.id)' events.ndjson # error: map runs inside each object, not across themjq -s 'length' events.ndjson # 3: -s slurps every document into one array-s (--slurp) is the fix whenever you need all the records at once, to sort, count or
aggregate them.
6. Indexing a string is an error, but indexing null isn’t. .nope.deeper on a missing key
returns null quite happily; .region.foo on a string exits with
Cannot index string with string "foo". Append ? — as in .region.foo? — to swallow the
error where you’d rather have nothing.
Exit codes, for scripts
Section titled “Exit codes, for scripts”Plain jq exits 0 whenever it parsed the input, regardless of what it found. -e makes the exit
code reflect the output instead: 1 if the last result was null or false, and 4 if there was
no output at all. That makes jq usable as a test:
if jq -e '.servers[] | select(.cpu > 0.9)' servers.json > /dev/null; then echo "Something is on fire"fiRecipes worth stealing
Section titled “Recipes worth stealing”What is even in this file? Print every leaf path with its value — invaluable for an unfamiliar payload, and much faster than scrolling:
jq -r 'paths(scalars) as $p | "\($p | join(".")) = \(getpath($p) | tostring)"' response.jsonThe latest non-prerelease version of anything on GitHub:
curl -s https://api.github.com/repos/jqlang/jq/releases | jq -r 'map(select(.prerelease | not)) | .[0].tag_name'A project’s scripts and dependencies, without opening the file:
jq -r '.scripts | to_entries[] | "\(.key)\t\(.value)"' package.json | column -tjq -r '.dependencies | keys[]' package.jsonMerge JSON files, with keys from the later files winning:
jq -s 'add' defaults.json overrides.jsonDrop a noisy key from a document:
jq 'del(.embedding)' record.jsonReformat a file in place, since jq won’t do it for you and the naive redirect truncates the file before it reads it:
jq . config.json > config.tmp && mv config.tmp config.jsonTwo of these are worth a shell function rather than a re-typing. If you keep your configuration in a dotfiles repo — and you should — that’s where they belong.
When jq is the wrong tool
Section titled “When jq is the wrong tool”- For YAML, use
yq. It speaks broadly the same filter language against YAML, TOML and XML, which saves learning a second syntax for the same job. - When jq’s error messages defeat you, try
gojq— a Go reimplementation that is stricter about edge cases and far more forthcoming about what went wrong.jaq, in Rust, chases raw speed instead. Both are drop-in for everyday filters, and neither is a reason to skip learning jq itself. - For anything with real logic in it, stop. When the filter no longer fits on a line and
you’ve started defining functions, you have written a program in a language with no debugger
and no tests, and Python’s
jsonmodule is right there. jq is superb glue and a poor application platform.
Where next
Section titled “Where next”Our modern CLI starter pack files jq under honourable mentions, on the grounds that it replaces nothing — there was never a good way to do this before. The rest of that page covers the tools that find things, rather than take them apart. For reaching the machines whose APIs you’re now interrogating, there’s the SSH config file; for keeping the session alive while a long query runs, tmux. And if you’re about to alias half of this, the minimal Zsh setup is the sane place to put it.