Skip to content

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.

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.

Terminal window
brew install jq # macOS
sudo apt install jq # Debian, Ubuntu
sudo dnf install jq # Fedora, RHEL

Check what you’ve got, because it matters more than usual here:

Terminal window
jq --version

jq 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.

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"] }
]
}

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:

Terminal window
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:

Terminal window
jq '.servers | length' servers.json # 5

That 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.

.[] 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:

Terminal window
jq '.servers[]' servers.json # five objects, one after another
jq -r '.servers[].name' servers.json
web-01
web-02
db-01
cache-01
web-03

Note 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:

Terminal window
jq '[.servers[].memory_mb] | add' servers.json # 30720

Array slices work as you’d hope: .servers[0] is the first, .servers[-1] the last, and .servers[1:3] the two in between.

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:

Terminal window
jq '.region' servers.json # "eu-west-1"
jq -r '.region' servers.json # eu-west-1

If 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(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:

Terminal window
jq -r '.servers[] | select(.role == "web") | .name' servers.json
web-01
web-02
web-03

The condition is any jq expression, so this is where the real filtering lives:

Terminal window
jq -r '.servers[] | select(.cpu > 0.8) | .name' servers.json
jq -r '.servers[] | select(.name | test("^web")) | .name' servers.json
jq -r '.servers[] | select(any(.tags[]; . == "prod")) | .name' servers.json

test() 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.

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:

Terminal window
jq -c '.servers | map({name, cpu})' servers.json
[{"name":"web-01","cpu":0.82},{"name":"web-02","cpu":0.34},...]
Terminal window
jq -r '.servers[] | select(.cpu > 0.8) | "\(.name) is at \(.cpu * 100 | floor)%"' servers.json
web-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.

The aggregate builtins are the reason jq quietly replaces a lot of small scripts:

Terminal window
jq '.servers | length' servers.json # 5
jq '[.servers[].memory_mb] | add' servers.json # 30720
jq '.servers | map(.memory_mb) | add / length' servers.json # 6144
jq -r '.servers | max_by(.cpu) | .name' servers.json # db-01
jq -r '[.servers[].tags[]] | unique | join(", ")' servers.json
prod, public, staging

group_by does what SQL taught you it does, though it returns an array of arrays and expects you to tidy up after it:

Terminal window
jq -r '.servers | group_by(.role) | map({role: .[0].role, count: length})
| .[] | "\(.role): \(.count)"' servers.json
cache: 1
db: 1
web: 3

And because jq is perfectly willing to multiply a string by a number, you can commit minor atrocities such as rendering a bar chart:

Terminal window
jq -r '.servers[] | "\(.name)\t\("#" * (.cpu * 20 | round))"' servers.json
web-01 ################
web-02 #######
db-01 ##################
cache-01 ##
web-03 #

Nobody needs this. Everybody does it once.

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:

Terminal window
jq -r '.servers[] | [.name, .role, .memory_mb] | @tsv' servers.json

Which drops straight into a loop, tabs being the one separator that names and roles will never contain:

Terminal window
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:

Terminal window
role=web
jq --arg role "$role" -r '.servers[] | select(.role == $role) | .name' servers.json

Six 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:

Terminal window
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:

Terminal window
jq -r '.servers[0].tags | contains(["pro"])' servers.json # true (!)
jq -r '.servers[0].tags | index("prod")' servers.json # 0

contains 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:

Terminal window
echo '{"a": null}' | jq -r '.a' # null

If 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:

Terminal window
jq -r '."content-type"' response.json
jq -r '.["x-rate limit"]' response.json

5. 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:

Terminal window
jq -c 'select(.ok)' events.ndjson # fine: filters each line in turn
jq 'map(.id)' events.ndjson # error: map runs inside each object, not across them
jq -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.

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:

Terminal window
if jq -e '.servers[] | select(.cpu > 0.9)' servers.json > /dev/null; then
echo "Something is on fire"
fi

What is even in this file? Print every leaf path with its value — invaluable for an unfamiliar payload, and much faster than scrolling:

Terminal window
jq -r 'paths(scalars) as $p | "\($p | join(".")) = \(getpath($p) | tostring)"' response.json

The latest non-prerelease version of anything on GitHub:

Terminal window
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:

Terminal window
jq -r '.scripts | to_entries[] | "\(.key)\t\(.value)"' package.json | column -t
jq -r '.dependencies | keys[]' package.json

Merge JSON files, with keys from the later files winning:

Terminal window
jq -s 'add' defaults.json overrides.json

Drop a noisy key from a document:

Terminal window
jq 'del(.embedding)' record.json

Reformat a file in place, since jq won’t do it for you and the naive redirect truncates the file before it reads it:

Terminal window
jq . config.json > config.tmp && mv config.tmp config.json

Two 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.

  • 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 json module is right there. jq is superb glue and a poor application platform.

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.