jq

Query and reshape JSON from the command line

Updated 2026-08-19

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",}
60 outputs, collapsed by default

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

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,