🔎 jq Guide

A how-to manual: the filter model, the options, selecting, reshaping, updating, counting, text and dates, real data, shell scripts without surprises, errors, large files and versions, with every example run for real.

Video: jq (Command Line Tips video)
Prefer to watch? This short video runs this page's main commands on a real Ubuntu 24.04 machine, with on-screen captions and no voiceover. Click to play, or watch it on YouTube.
jq: a command-line processor for JSON, like sed and awk are for text
jq '.servers[] | select(.online) | .name' servers.json
curl -s https://api.example.com/items | jq -r '.[] | [.id, .name] | @tsv'

JSON is what almost every tool and web service speaks now: cloud CLIs, REST APIs, ip -j, lsblk -J, log shippers, configuration files. A text tool like grep cannot tell a key from a value or a number from a string. jq can. You give it a filter (a small program), it reads JSON from a file or a pipe, and it prints the result as JSON, or as plain text with -r.

A filter is a pipeline, like a shell pipeline: each stage takes the JSON that comes out of the one before it. That is the whole idea. The rest of the language (select, map, group_by, reduce, string and date functions) is a toolbox of pipeline stages.

Every example below ran for real, on Ubuntu 24.04 with the package from Ubuntu (jq 1.7.1-3ubuntu0.24.04.2, which prints itself as "jq-1.7"), against the two sample files shown in the next section. Where a command depends on the jq version, the newer jq 1.8.2 was run as well and the difference is noted in the version section.

Install, and the sample data

jq is in Ubuntu's repositories. It is one small program with no dependencies:

$ sudo apt install jq
$ jq --version
jq-1.7
$ dpkg -s jq | grep ^Version
Version: 1.7.1-3ubuntu0.24.04.2

The package is jq 1.7.1 with Ubuntu's patches, and the program reports "jq-1.7". That is the version this guide describes. The current upstream release when this was written is 1.8.2, which has a few new functions and two changes in behaviour; the version section covers them and shows how to install it from the project's static binary.

All the examples use two files. servers.json is an inventory (nested objects, arrays, a null, an empty list):

$ cat servers.json
{
  "datacenter": "fra1",
  "updated": "2026-10-17T02:00:00Z",
  "servers": [
    {"name": "web1", "role": "web", "cpu": 4, "ram_gb": 16, "ip": "10.0.1.11", "tags": ["prod", "nginx"],
     "disks": [{"mount": "/", "size_gb": 50, "used_pct": 41}, {"mount": "/var", "size_gb": 200, "used_pct": 78}], "online": true},
    {"name": "web2", "role": "web", "cpu": 4, "ram_gb": 16, "ip": "10.0.1.12", "tags": ["prod", "nginx"],
     "disks": [{"mount": "/", "size_gb": 50, "used_pct": 38}, {"mount": "/var", "size_gb": 200, "used_pct": 91}], "online": true},
    {"name": "db1", "role": "db", "cpu": 8, "ram_gb": 64, "ip": "10.0.2.21", "tags": ["prod", "postgres"],
     "disks": [{"mount": "/", "size_gb": 100, "used_pct": 22}, {"mount": "/data", "size_gb": 2000, "used_pct": 67}], "online": true, "notes": null},
    {"name": "db2", "role": "db", "cpu": 8, "ram_gb": 64, "ip": "10.0.2.22", "tags": ["staging", "postgres"],
     "disks": [], "online": false},
    {"name": "cache1", "role": "cache", "cpu": 2, "ram_gb": 8, "ip": "10.0.3.31", "tags": ["prod", "redis"],
     "disks": [{"mount": "/", "size_gb": 30, "used_pct": 12}], "online": true}
  ]
}

logs.jsonl is a log with one JSON object per line (the format many applications and log shippers write, also called JSON Lines or NDJSON):

$ head -3 logs.jsonl
{"ts":"2026-10-17T02:00:01Z","level":"info","service":"api","msg":"request done","status":200,"ms":42,"user":"ann"}
{"ts":"2026-10-17T02:00:02Z","level":"info","service":"api","msg":"request done","status":200,"ms":38,"user":"ed"}
{"ts":"2026-10-17T02:00:03Z","level":"warn","service":"api","msg":"slow request","status":200,"ms":910,"user":"ann"}

It has ten lines: services api, pay and web, three of them errors.

First steps: reading values

The simplest filter is a dot. It means "the whole input", and jq prints it pretty-printed and coloured when the output is a terminal:

$ echo '{"a":1,"b":[1,2,3]}' | jq .
{
  "a": 1,
  "b": [
    1,
    2,
    3
  ]
}
$ echo '{"a":1,"b":[1,2,3]}' | jq -c .          (-c: compact, one line)
{"a":1,"b":[1,2,3]}
$ echo '{"a":1,"b":[1,2,3]}' | jq --tab .        (indent with tabs; --indent N uses N spaces)

Reading a key is a dot and its name. A number in square brackets picks an array element (negative counts from the end), a range slices, and empty brackets mean "every element":

$ jq .datacenter servers.json
"fra1"
$ jq -r .datacenter servers.json                  (-r: raw, strings without their quotes)
fra1
$ jq '.servers | length' servers.json
5
$ jq '.servers[0].name' servers.json
"web1"
$ jq '.servers[-1].ip' servers.json
"10.0.3.31"
$ jq '.servers[].name' servers.json               (one output per element)
"web1"
"web2"
"db1"
"db2"
"cache1"
$ jq '.servers[1:3] | map(.name)' servers.json    (a slice, then a function on each element)
["web2","db1"]
$ jq '.servers[0] | keys' servers.json
["cpu","disks","ip","name","online","ram_gb","role","tags"]       (shown compact here; jq prints one item per line)

The pipe | feeds the left side's output to the right side, and the comma gives two results from one input:

$ jq '.servers[0] | .name, .ip' servers.json
"web1"
"10.0.1.11"

Two rules that explain most surprises. First, a missing key is not an error: it is null, and nulls pass through a chain of keys:

$ jq '.servers[0].nosuch.deeper' servers.json
null
$ jq '.servers[2].notes' servers.json              (a key that exists and is null)
null

Second, asking for the wrong kind of thing is an error, and jq stops with exit status 5:

$ jq '.servers[0].name.first' servers.json
jq: error (at servers.json:16): Cannot index string with string "first"
$ jq '.datacenter[0]' servers.json
jq: error (at servers.json:16): Cannot index string with number

Add a question mark to ignore the error (the value then produces nothing), and use .. to walk every value at every depth:

$ jq '[.servers[] | .disks[]?.mount]' servers.json
["/","/var","/","/var","/","/data","/"]
$ jq '[..|numbers] | length' servers.json
24

The line number in the error is the last line jq had read (the whole file here); use it as a hint, not an exact location.

The options you will use

Most of the daily use of jq is a handful of options:

  -r, --raw-output   print strings without quotes (use it when a result goes to another command)
  -c, --compact-output   one result per line (essential for JSON Lines)
  -e, --exit-status   exit 1 if the last result is null or false, 4 if there was no result (for use in if and &&)
  -n, --null-input   do not read input; run the filter once on null (for building JSON and for inputs)
  -s, --slurp   read all inputs into one array
  -R, --raw-input   read lines as strings instead of JSON
  -j, --join-output   like -r, but no newline after each result
  --raw-output0   like -r, but end each result with a NUL (safe for xargs -0; jq 1.7 and later)
  -S, --sort-keys   print object keys sorted (for comparing files)
  -f FILE   read the filter from a file
  -L DIR   where to find modules (see the programs section)
  -C / -M   force colour / force monochrome
  --tab, --indent N   pretty-print with tabs or N spaces
  --unbuffered   flush output after each result (needed in pipes, see the scripts section)
  --stream   read huge input as a stream of events (see the big files section)
  --arg NAME VALUE   a string variable $NAME
  --argjson NAME JSON   a JSON variable (numbers, booleans, objects)
  --args, --jsonargs   the rest of the command line becomes $ARGS.positional (strings, or JSON)
  --slurpfile NAME FILE   $NAME is an array of all the JSON values in FILE
  --rawfile NAME FILE   $NAME is the contents of FILE as one string

Quick demonstrations of the ones that are not obvious:

$ jq -r --arg r db '.servers[] | select(.role == $r) | .name' servers.json
db1
db2
$ jq -c --argjson min 8 '[.servers[] | select(.cpu >= $min) | .name]' servers.json
["db1","db2"]
$ jq -c -n '$ARGS.named' --arg x 1 --argjson y 2
{"x":"1","y":2}
$ jq -c -n '$ARGS.positional' --args one two three
["one","two","three"]
$ jq -c -n '$ARGS.positional' --jsonargs 1 '"two"' null
[1,"two",null]
$ jq -c -n --slurpfile s servers.json '$s[0].servers | length'
5
$ jq -s 'length' logs.jsonl                        (-s: all ten lines become one array)
10
$ printf 'line one\nline two\n' | jq -R .          (-R: each line is a string)
"line one"
"line two"
$ printf 'line one\nline two\n' | jq -R -s .       (-R -s: the whole input as one string)
"line one\nline two\n"

Input can hold several JSON values in a row (separated by newlines, or by nothing at all); jq handles each in turn, which is why JSON Lines files work with no special option:

$ echo '{"a":1} {"b":2} [3]' | jq -c .
{"a":1}
{"b":2}
[3]
$ echo '{"a":1}{"b":2}' | jq -s -c .
[{"a":1},{"b":2}]

Colours: jq colours output on a terminal and not in a pipe. -C forces colour (for | less -R) and -M forbids it. The colours can be changed with the JQ_COLORS environment variable (a colon-separated list of terminal codes for null, false, true, numbers, strings, arrays, objects and object keys). NO_COLOR is not enough on its own once -C is given: the lab's -C output was still coloured.

Selecting: select, comparisons and defaults

select(condition) passes its input through when the condition is true and drops it otherwise. It is the filter you will use most. Put it after .[] to keep some elements:

$ jq '.servers[] | select(.role=="db") | .name' servers.json
"db1"
"db2"
$ jq -r '.servers[] | select(.online and .cpu >= 8) | .name' servers.json
db1
$ jq -r '.servers[] | select(.online | not) | .name' servers.json        (not is a function you pipe into)
db2

The comparisons are == != < <= > >=, and the logic is and, or, not. Tests on strings and arrays:

$ jq -r '.servers[] | select(.tags | index("prod")) | .name' servers.json
web1
web2
db1
cache1
$ jq -r '.servers[] | select(.tags | contains(["prod","postgres"])) | .name' servers.json
db1
$ jq -r '.servers[] | select(.name | test("^web")) | .ip' servers.json     (test: a regular expression)
10.0.1.11
10.0.1.12
$ jq -r '.servers[] | select(.name | startswith("db")) | .name' servers.json
db1
db2
$ jq '[.servers[] | select(.disks | length == 0) | .name]' servers.json
["db2"]
$ jq -c '.servers[] | select(any(.disks[]; .used_pct > 75)) | {name, worst: ([.disks[].used_pct] | max)}' servers.json
{"name":"web1","worst":78}
{"name":"web2","worst":91}

any(generator; condition) and all(generator; condition) answer "is it true for some / for all". first(...) and limit(n; ...) stop early (useful on infinite or huge inputs).

Defaults. The // operator ("alternative") gives its right side when the left is null, false or empty:

$ jq -c '.[] | [.name, (.notes // "none")]' servers.json         (a missing or null note gets a default)
["web1","none"]
$ jq -c '.a // "default", (.b // "default"), (.c // empty)' <<< '{"a":null,"b":false,"c":0}'
"default"
"default"
0

Note that false also triggers the default: the b key was false and still produced "default". When false is a real value, test for null explicitly: if .b == null then ... else .b end.

Three comparison traps from the lab:

1. Values of different types compare by a fixed order, not by content: null < false < true < numbers < strings < arrays < objects. A number is therefore always smaller than any string. With --arg the variable is a string, so the comparison silently selects nothing:

$ jq -c --arg n 8 '[.servers[] | select(.cpu >= $n) | .name]' servers.json
[]
$ jq -c --argjson n 8 '[.servers[] | select(.cpu >= $n) | .name]' servers.json
["db1","db2"]

Use --argjson for numbers (or write $n | tonumber).

2. select(. != null) drops nulls; select(.key == null) also matches objects that do not have the key at all, because a missing key is null. Use has("key") to tell the two apart:

$ jq -c 'map(select(.k == null))' <<< '[{"k":1},{"j":2}]'
[{"j":2}]
$ jq -c '.[] | select(has("x"))' <<< '[{"x":1},{"y":2}]'
{"x":1}

3. sort puts mixed types in that same fixed order:

$ jq -c 'sort' <<< '[3,"b",null,true,false,[1],{"a":1},"a",1]'
[null,false,true,1,3,"a","b",[1],{"a":1}]

Building new JSON

A filter that makes objects and arrays builds new JSON from the old. {a, b} is shorthand for {a: .a, b: .b}, a computed key goes in parentheses, and a string with \( ) in it interpolates:

$ jq -c '.servers[] | {name, role, address: .ip}' servers.json
{"name":"web1","role":"web","address":"10.0.1.11"}
{"name":"web2","role":"web","address":"10.0.1.12"}
{"name":"db1","role":"db","address":"10.0.2.21"}
{"name":"db2","role":"db","address":"10.0.2.22"}
{"name":"cache1","role":"cache","address":"10.0.3.31"}
$ jq -r '.servers[] | "\(.name) has \(.cpu) CPUs and \(.ram_gb) GB of RAM"' servers.json
web1 has 4 CPUs and 16 GB of RAM
web2 has 4 CPUs and 16 GB of RAM
db1 has 8 CPUs and 64 GB of RAM
db2 has 8 CPUs and 64 GB of RAM
cache1 has 2 CPUs and 8 GB of RAM
$ jq -c '.servers[] | {name, mem: (.ram_gb | tostring + " GB")}' servers.json | head -2
{"name":"web1","mem":"16 GB"}
{"name":"web2","mem":"16 GB"}

Wrap a generator in [ ] to collect its results into an array, and add them up or join them:

$ jq '[.servers[] | .cpu] | add' servers.json
26
$ jq '[.servers[].ram_gb] | add / length' servers.json
33.6
$ jq -c '[.servers[] | .name] | join(", ")' servers.json
"web1, web2, db1, db2, cache1"
$ jq -c '.servers | map(.name) | to_entries | map("\(.key+1). \(.value)")' servers.json
["1. web1","2. web2","3. db1","4. db2","5. cache1"]

map(f) is [.[] | f]. map_values(f) does the same to the values of an object and keeps the keys. Turning arrays into objects and back is a common job, and there are three functions for it. to_entries makes {key, value} pairs, from_entries reverses it (it also accepts name/value and Key/Value spelling), and with_entries(f) applies a filter to the pairs:

$ jq -c '.servers | map({key: .name, value: .ip}) | from_entries' servers.json
{"web1":"10.0.1.11","web2":"10.0.1.12","db1":"10.0.2.21","db2":"10.0.2.22","cache1":"10.0.3.31"}
$ jq -c '[.servers[] | {(.name): .ip}] | add' servers.json      (same result; add merges objects)
{"web1":"10.0.1.11","web2":"10.0.1.12","db1":"10.0.2.21","db2":"10.0.2.22","cache1":"10.0.3.31"}
$ jq -c '.servers | INDEX(.name) | map_values(.role)' servers.json     (INDEX builds a lookup table)
{"web1":"web","web2":"web","db1":"db","db2":"db","cache1":"cache"}
$ jq -c '.servers[0] | with_entries(select(.key != "disks" and .key != "tags"))' servers.json
{"name":"web1","role":"web","cpu":4,"ram_gb":16,"ip":"10.0.1.11","online":true}
$ jq -c '.servers[0] | to_entries | map(select(.value | type == "number")) | from_entries' servers.json
{"cpu":4,"ram_gb":16}

Adding two objects with + replaces the whole value of a key that is in both. The * operator merges recursively:

$ cat m1.json m2.json
{"a":{"x":1,"y":2},"b":1}
{"a":{"y":3,"z":4},"c":2}
$ jq -c -s '.[0] + .[1]' m1.json m2.json
{"a":{"y":3,"z":4},"b":1,"c":2}
$ jq -c -s '.[0] * .[1]' m1.json m2.json
{"a":{"x":1,"y":3,"z":4},"b":1,"c":2}

Note add over an empty array is null, over strings it concatenates, and over arrays it joins them:

$ jq -c 'map(.n) | add' <<< '[]'
null
$ jq -c 'add' <<< '["a","b"]'
"ab"
$ jq -c 'add' <<< '[[1],[2]]'
[1,2]

Two precedence facts: the comma binds more tightly than the pipe (.servers | first, last | .name gives two names), and in an object an expression with a pipe needs parentheses: {mem: (.ram_gb | tostring)}. A generator inside an object makes several objects:

$ jq -c -n '{a: (1,2)}'
{"a":1}
{"a":2}

Updating and deleting

jq has assignment operators. They do not change the input (JSON in, JSON out); they print the changed copy. = sets, |= updates with a filter (the old value is the input), and += -= *= /= do arithmetic:

$ jq -c '.servers[0] | .cpu |= . * 2 | .tags += ["new"] | .online = false | {cpu, tags, online}' servers.json
{"cpu":8,"tags":["prod","nginx","new"],"online":false}
$ jq -c '(.servers[] | select(.name=="db2") | .online) = true | .servers[3] | {name, online}' servers.json
{"name":"db2","online":true}
$ jq -c '.servers |= map(.cpu_class = (if .cpu >= 8 then "big" elif .cpu >= 4 then "mid" else "small" end)) | [.servers[] | {name, cpu_class}]' servers.json
[{"name":"web1","cpu_class":"mid"},{"name":"web2","cpu_class":"mid"},{"name":"db1","cpu_class":"big"},{"name":"db2","cpu_class":"big"},{"name":"cache1","cpu_class":"small"}]

The left side of an assignment is a path, so you can select what to change inside it, as the second example does. Missing parts are created:

$ jq '.a.b.c = 1' <<< null
{"a":{"b":{"c":1}}}                    (shown compact; jq prints it pretty)
$ jq -c '.a[2] = 1' <<< null
{"a":[null,null,1]}
$ jq -c '.a += 1' <<< '{}'
{"a":1}

Deleting: del(path), or filter an array with map(select(...)):

$ jq -c '.servers[0] | del(.disks, .tags)' servers.json
{"name":"web1","role":"web","cpu":4,"ram_gb":16,"ip":"10.0.1.11","online":true}
$ jq -c 'del(.servers[] | select(.online | not)) | .servers | length' servers.json
4
$ jq -c '.servers[0] | del(.disks[0]) | .disks' servers.json
[{"mount":"/var","size_gb":200,"used_pct":78}]
$ jq -c 'del(..|nulls)' <<< '{"a":1,"b":null,"c":{"d":null,"e":2}}'          (remove every null, at any depth)
{"a":1,"c":{"e":2}}
$ jq -c 'walk(if type == "object" then del(.secret) else . end)' <<< '{"a":{"secret":1,"k":2},"secret":3}'
{"a":{"k":2}}

pick keeps only some paths (a new function in jq 1.7):

$ jq -c 'pick(.datacenter, .servers[0].name)' servers.json
{"datacenter":"fra1","servers":[{"name":"web1"}]}

Paths are first-class values: a path is an array of keys and indexes. The path functions let you work with them directly:

$ jq -c 'path(.servers[0].disks[1].mount)' servers.json
["servers",0,"disks",1,"mount"]
$ jq -c 'getpath(["servers",0,"ip"])' servers.json
"10.0.1.11"
$ jq -c 'setpath(["a","b"]; 9), delpaths([["a"]])' <<< '{"a":{"b":7}}'
{"a":{"b":9}}
{}
$ jq -c '[paths(scalars) | join(".")]' <<< '{"a":1,"b":{"c":2}}'
["a","b.c"]
$ jq -c '[paths(scalars) as $p | {key: ($p | join(".")), value: getpath($p)}] | from_entries' <<< '{"a":1,"b":{"c":2}}'
{"a":1,"b.c":2}                         (flatten nested JSON into dotted keys)

leaf_paths, which older guides use, is gone: jq 1.7 answers "jq: error: leaf_paths/0 is not defined". Use paths(scalars).

Two traps with |=. First, the right-hand side of .[] |= f uses only the first result of f, and in jq 1.7 .[] |= empty on [1,2,3,4] gave [] (everything removed); do not rely on empty inside |=, use del(...) or map(select(...)). Second, an update of a key that does not exist works on null, so .n |= . + 1 on {"n":null} gives 1 (the lab). That is convenient for counters and dangerous for typos in key names: .nmae += 1 quietly creates a new key.

Grouping, sorting, counting and summing

This is where jq replaces a small script. sort_by, group_by and unique_by take a filter that makes the key:

$ jq -c '.servers | sort_by(.ram_gb, .name) | map(.name)' servers.json        (sort by RAM, then by name)
["cache1","web1","web2","db1","db2"]
$ jq -c '.servers | sort_by(.ram_gb) | reverse | map(.name)' servers.json
["db2","db1","web2","web1","cache1"]
$ jq '.servers | max_by(.ram_gb) | .name' servers.json
"db2"
$ jq -c '.servers | group_by(.role) | map({role: .[0].role, count: length, names: map(.name)})' servers.json
[{"role":"cache","count":1,"names":["cache1"]},{"role":"db","count":2,"names":["db1","db2"]},{"role":"web","count":2,"names":["web1","web2"]}]
$ jq -c '.servers | map(.tags[]) | unique' servers.json
["nginx","postgres","prod","redis","staging"]
$ jq -c '.servers | map(.tags[]) | group_by(.) | map({tag: .[0], n: length}) | sort_by(-.n)' servers.json     (a count of each tag, most common first)
[{"tag":"prod","n":4},{"tag":"nginx","n":2},{"tag":"postgres","n":2},{"tag":"redis","n":1},{"tag":"staging","n":1}]
$ jq -c 'unique_by(.k) | map(.k)' <<< '[{"k":1},{"k":2},{"k":1}]'
[1,2]

Note that group_by sorts first, so its groups come out in key order. sort_by(-.n) is the way to sort numbers in descending order (or reverse).

A summary of the log file, per service (requests, errors, average time in ms):

$ jq -s -c 'group_by(.service) | map({service: .[0].service, requests: length, errors: map(select(.level=="error")) | length, avg_ms: (map(.ms) | add / length | floor)})' logs.jsonl
[{"service":"api","requests":6,"errors":1,"avg_ms":541},{"service":"pay","requests":2,"errors":2,"avg_ms":1560},{"service":"web","requests":2,"errors":0,"avg_ms":16}]
$ jq -s -c 'map(.ms) | sort | {min: .[0], median: .[(length/2|floor)], max: .[-1]}' logs.jsonl
{"min":5,"median":51,"max":3001}
$ jq -s -c 'group_by(.user) | map({user: .[0].user, n: length}) | sort_by(-.n)' logs.jsonl
[{"user":"ann","n":4},{"user":"ed","n":3},{"user":"ria","n":2},{"user":"gus","n":1}]
$ jq -c 'select(.ms > 1000) | {msg, ms}' logs.jsonl
{"msg":"upstream timeout","ms":3001}
{"msg":"db connection lost","ms":2200}

(The median here is the middle element of the sorted list; for an even count it takes the upper of the two middle values.)

reduce folds a stream into one value, and foreach shows the steps on the way. These are the general tools when add and group_by do not fit:

$ jq -c -n 'reduce range(1;6) as $i (0; . + $i)'
15
$ jq -c '[foreach range(1;6) as $i (0; . + $i)]' -n
[1,3,6,10,15]
$ jq -c 'reduce .servers[] as $s ({}; .[$s.role] += 1)' servers.json         (count per role; the key starts as null, so += works)
{"web":2,"db":2,"cache":1}
$ jq -c '.servers | map({(.name): (.disks | map({(.mount): .used_pct}) | add)}) | add' servers.json
{"web1":{"/":41,"/var":78},"web2":{"/":38,"/var":91},"db1":{"/":22,"/data":67},"db2":null,"cache1":{"/":12}}

(db2 has no disks, and add over an empty array is null, which is the trap from the building section.)

Other useful ones: min, max, min_by, max_by, any, all, flatten, transpose, combinations, range, limit, first, last, indices and index, contains, inside, and the slice syntax .[2:4]. Short demonstrations:

$ jq -c 'flatten, flatten(1)' <<< '[1,[2,[3,[4]]]]'
[1,2,3,4]
[1,2,[3,[4]]]
$ jq -c 'transpose' <<< '[[1,2],[3,4],[5,6]]'
[[1,3,5],[2,4,6]]
$ jq -c '[range(0; length; 2) as $i | .[$i:$i+2]]' <<< '[1,2,3,4,5]'        (split into pairs)
[[1,2],[3,4],[5]]
$ jq -c '[limit(2; .servers[] | .name)]' servers.json
["web1","web2"]
$ jq -c 'first(.[] | select(.n > 5))' <<< '[{"n":3}]'                        (no match: no output at all)
$ jq -c '[.[] | select(.n > 5)] | first' <<< '[{"n":3}]'                       (the array form: null)
null

Text: strings, regular expressions and formats

Strings have the usual functions. Most of them are filters, so they sit after a pipe:

$ jq -n '"Hello World" | ascii_downcase, ascii_upcase, length, (split(" ")|reverse|join(" ")), sub("World";"jq"), test("world";"i"), (.[0:5]), index("World")'
"hello world"
"HELLO WORLD"
11
"World Hello"
"Hello jq"
true
"Hello"
6
$ jq -c 'contains("oba"), startswith("foo"), endswith("bar"), ltrimstr("foo"), .[3:], indices("o")' <<< '"foobar"'
true
true
true
"bar"
"bar"
[1,2]
$ jq -c 'explode, (explode | implode)' <<< '"abc"'
[97,98,99]
"abc"

length counts characters (not bytes), and utf8bytelength counts bytes: "é€😀" has 3 characters and 9 bytes. ascii_downcase changes only ASCII letters (the lab's "ÀB" became "Àb").

Regular expressions use the Oniguruma library (Perl-like). The functions are test (true or false), match (details), capture (named groups to an object), scan (all matches), sub and gsub (replace). Flags: "g" global, "i" case-insensitive, "x" extended:

$ jq -nc '"a1b22c333" | [scan("[0-9]+")], gsub("[0-9]";"#"), [match("[0-9]+";"g").string], (capture("(?<letter>[a-z])(?<num>[0-9]+)"))'
["1","22","333"]
"a#b##c###"
["1","22","333"]
{"letter":"a","num":"1"}
$ jq -r 'capture("(?<ip>[0-9.]+)/(?<len>[0-9]+)") | "\(.ip) len \(.len)"' <<< '"10.0.1.11/24"'
10.0.1.11 len 24
$ jq -c '[scan("(\\w+)=(\\w+)")] | map({(.[0]): .[1]}) | add' <<< '"key=value; other=thing"'
{"key":"value","other":"thing"}

Backslashes inside a jq string need doubling, as in "\\w+" (the string is parsed once by the shell's quoting and once by jq).

Numbers and conversions:

$ jq -c 'map(tostring)' <<< '[1,"a",null,true,[1]]'
["1","a","null","true","[1]"]
$ jq -c '[.[] | tonumber?]' <<< '["1","x","3"]'          (the ? drops the ones that fail)
[1,3]
$ jq -c '[.[] | tonumber? // 0]' <<< '["1","x"]'
[1,0]
$ jq -c '[.[] | (. * 100 | round) / 100]' <<< '[1.234, 5.678]'          (round to 2 decimals)
[1.23,5.68]
$ jq -r 'tostring | ("0" * (5 - length)) + .' <<< 42                     (zero-pad)
00042

Output formats. @csv, @tsv, @sh, @uri, @base64, @base64d, @html, @json and @text turn values into the text of that format, with the right quoting. They work on one array or string per result, which is why a table is built with [ ... ] | @csv:

$ jq -r '.servers[] | [.name, .ip, (.tags | join("|"))] | @csv' servers.json
"web1","10.0.1.11","prod|nginx"
"web2","10.0.1.12","prod|nginx"
"db1","10.0.2.21","prod|postgres"
"db2","10.0.2.22","staging|postgres"
"cache1","10.0.3.31","prod|redis"
$ jq -r '["NAME","ROLE","CPU"], (.servers[] | [.name, .role, (.cpu|tostring)]) | @tsv' servers.json | column -t -s "$(printf '\t')"
NAME    ROLE   CPU
web1    web    4
web2    web    4
db1     db     8
db2     db     8
cache1  cache  2
$ jq -nr '"a b&c=d/é" | @uri, @base64, @html, @json'
a%20b%26c%3Dd%2F%C3%A9
YSBiJmM9ZC/DqQ==
a b&amp;c=d/é
"a b&c=d/é"
$ jq -nr '"aGVsbG8gd29ybGQ=" | @base64d'
hello world

(column comes from the bsdextrautils package on Ubuntu 24.04.) A header row plus data, from the keys of the first object, is a standard idiom:

$ jq -r '(.servers[0] | keys_unsorted) as $k | $k, (.servers[] | [.[$k[]] | if type == "array" or type == "object" then tojson else . end]) | @csv' servers.json | head -2
"name","role","cpu","ram_gb","ip","tags","disks","online"
"web1","web",4,16,"10.0.1.11","[""prod"",""nginx""]","[{""mount"":""/"",""size_gb"":50,""used_pct"":41},{""mount"":""/var"",""size_gb"":200,""used_pct"":78}]",true

Nested values become JSON text inside a quoted CSV cell (note the doubled quotes), which a spreadsheet reads back as text. Convert CSV into JSON with -R, splitting the lines yourself (jq has no CSV parser; this works for simple files with no quoted commas):

$ printf 'a,1\nb,2\n' | jq -R -c 'split(",") | {name: .[0], n: (.[1] | tonumber)}'
{"name":"a","n":1}
{"name":"b","n":2}

join needs strings, numbers or null; a nested array gives "jq: error: string ("a,") and array (["b"]) cannot be added". Also, -r of an array prints it as JSON (the brackets stay), so use .[] or join:

$ echo '{"a":[1,2]}' | jq -r '.a[]'
1
2
$ echo '[1,"a",null]' | jq -r 'join(",")'
1,a,

A null prints as the word null under -r (and as nothing inside join):

$ jq -r '.[]' <<< '[1,null,"a",true]'
1
null
a
true
$ jq -r '.a // empty' <<< '{"a":null}'                     (print nothing for a null, instead of the word)

Dates and numbers

jq has date functions for the common ISO form and for Unix time. fromdate and todate handle the form 2026-10-17T02:00:01Z; strftime and strptime use the C library's format strings:

$ jq -nc '"2026-10-17T02:00:01Z" | fromdate, (fromdate | todate), (fromdate | strftime("%A %d %B %Y %H:%M")), (strptime("%Y-%m-%dT%H:%M:%SZ") | mktime)'
1792202401
"2026-10-17T02:00:01Z"
"Saturday 17 October 2026 02:00"
1792202401
$ jq -c 'fromdate | . + 86400 | todate' <<< '"2026-10-17T02:00:01Z"'          (add a day: seconds arithmetic)
"2026-10-18T02:00:01Z"
$ jq -nc '"2026-10-17T02:00:01Z" | fromdate as $t | ($t - 1792202000)'          (seconds between two times)
401
$ jq -nc 'now | type'
"number"

Traps with dates:

  strptime returns a "broken down time" array and its month is zero-based. October is 9, not 10:
$ jq -c 'strptime("%Y-%m-%d") | {year: .[0], month: .[1], day: .[2]}' <<< '"2026-10-17"'
{"year":2026,"month":9,"day":17}
  todate, strftime and mktime work in UTC. For local time use strflocaltime and the TZ variable:
$ jq -r 'strflocaltime("%Y-%m-%d %H:%M %Z")' <<< 1792202401
2026-10-17 02:00 UTC
$ TZ=America/New_York jq -nr '1792202401 | strflocaltime("%Y-%m-%d %H:%M %Z")'
2026-10-16 22:00 EST
  fromdate accepts only the plain form with a Z. Fractions of a second and time zone offsets are refused:
$ jq -n '"2026-10-17T02:00:01.123Z" | fromdate'
jq: error (at <unknown>): date "2026-10-17T02:00:01.123Z" does not match format "%Y-%m-%dT%H:%M:%SZ"
$ jq -n '"2026-10-17T02:00:01+02:00" | fromdate'
jq: error (at <unknown>): date "2026-10-17T02:00:01+02:00" does not match format "%Y-%m-%dT%H:%M:%SZ"
$ jq -n '"2026-10-17T02:00:01.123Z" | sub("\\.[0-9]+";"") | fromdate'          (cut the fraction first)
1792202401
  For an offset like +02:00, convert to UTC before the data reaches jq (date -u -d, or have the source print UTC); the lab did not find a clean jq-only way.

Numbers deserve a short section. jq 1.7 keeps the exact text of a number that it only passes through, even when it is too big for a 64-bit float, and converts to a float only when you do arithmetic:

$ echo '{"id": 9007199254740993}' | jq -c '.id, (.id | tostring)'
9007199254740993
"9007199254740993"
$ echo '[12345678901234567890, 1.000, 0.10]' | jq -c '., map(. + 0)'
[12345678901234567890,1.000,0.10]
[12345678901234567000,1,0.1]
$ jq -n '12345678901234567890, 1.10, 0.1+0.2, 3.0, 1e3, 1e1000'
12345678901234567890
1.10
0.30000000000000004
3.0
1E+3
1E+1000

So an untouched id such as 9007199254740993 survives (jq 1.6 and older turned such numbers into floats on input; that was not run in the lab), but id + 0 does not. Compare ids as strings when you can. Special values: NaN prints as null and infinity as the largest float:

$ printf '{"a":NaN,"b":Infinity}' | jq -c .
{"a":null,"b":1.7976931348623157e+308}

And the integer division trap: jq has only one number type, so 10 / 4 is 2.5 and 1 + 2 * 3 is 7 as you would expect; there is no integer division (use floor). % works on integers (10 % 3 is 1).

Real data: ip, lsblk, a web API, and JSON logs

Many Linux tools can now print JSON, and jq turns it into exactly the text you want. These ran in the lab container:

$ ip -j addr | jq -r '.[] | select(.ifname != "lo") | .ifname as $n | .addr_info[] | select(.family == "inet") | "\($n) \(.local)/\(.prefixlen)"'
eth0 172.17.0.2/16
$ ip -j route | jq -c '.[] | {dst, gateway, dev}'
{"dst":"default","gateway":"172.17.0.1","dev":"eth0"}
{"dst":"172.17.0.0/16","gateway":null,"dev":"eth0"}
$ ip -j -s link show eth0 | jq -c '.[0] | {ifname, operstate, rx: .stats64.rx.bytes, tx: .stats64.tx.bytes}'
{"ifname":"eth0","operstate":"UP","rx":50475630,"tx":181390}
$ lsblk -J -o NAME,TYPE | jq -c '[.blockdevices[].type] | group_by(.) | map({(.[0]): length}) | add'
{"disk":1,"loop":31}

(The lab's host had one disk and 31 loop devices; filter with select(.type == "disk") to leave the loops out.) The pattern "X as $n | ... uses $n" is how you keep a value from an outer level while you dig into an inner one.

A web API. Fetch once to a file while you work out the filter (so you do not hit the API on every try), then query the file. This is the real GitHub API for jq's own releases:

$ curl -s https://api.github.com/repos/jqlang/jq/releases/latest -o rel.json
$ jq -r '.tag_name, .published_at, (.assets | length)' rel.json
jq-1.8.2
2026-06-20T14:11:27Z
29
$ jq -r '.assets[] | select(.name | test("linux-amd64$")) | "\(.name) \(.size) bytes"' rel.json
jq-linux-amd64 2267912 bytes

In a script, check for an error: curl -s hides HTTP failures, so use curl -sf (fail on HTTP errors) and let set -o pipefail carry the failure to the end of the pipe. An API that is rate limited (like this one) answers with a JSON error object, not the data you expect; select the field with -e or test for it.

JSON Lines. Because jq handles one value after another, a JSON log is filtered with no special option:

$ jq -c 'select(.level=="error") | {ts, service, msg}' logs.jsonl
{"ts":"2026-10-17T02:00:04Z","service":"pay","msg":"card declined"}
{"ts":"2026-10-17T02:00:06Z","service":"pay","msg":"upstream timeout"}
{"ts":"2026-10-17T02:00:09Z","service":"api","msg":"db connection lost"}
$ jq -r '[.ts, .level, .service, .msg] | @tsv' logs.jsonl | head -3
2026-10-17T02:00:01Z	info	api	request done
2026-10-17T02:00:02Z	info	api	request done
2026-10-17T02:00:03Z	warn	api	slow request

(See the Loki guide for a log system that runs this kind of query across many machines.)

Comparing and merging files. Key order is not part of JSON's meaning, so sort the keys before you diff:

$ diff <(jq -S . m1.json) <(jq -S . m2.json)
3,4c3,4
<     "x": 1,
<     "y": 2
---
>     "y": 3,
>     "z": 4
6c6
<   "b": 1
---
>   "c": 2

$ jq -c -n '[inputs | .a]' m1.json m2.json            (-n with inputs reads every file as a stream)
[{"x":1,"y":2},{"y":3,"z":4}]
$ jq -c --slurpfile o m2.json '. * $o[0]' m1.json         (merge a second file into the input)
{"a":{"x":1,"y":3,"z":4},"b":1,"c":2}

Duplicate keys in the input: the last value wins but the key keeps its first position ({"b":1,"a":2,"b":3} becomes {"b":3,"a":2}). jq keeps the key order of the input; only keys and -S sort.

Using jq safely in shell scripts

This is the section that prevents the incidents.

1. Never build a filter by pasting shell variables or data into it. Pass the data in with --arg and --argjson. The lab's test: a value that tries to break out of the string leaves the filter intact:

$ jq -r --arg r 'db" or true or "' '.servers[] | select(.role == $r) | .name' servers.json
$                                                       (no output: it is just a role name that matches nothing)

2. Never build shell commands from JSON text with string addition. A name containing a semicolon runs the second command. The lab used a name of "x; touch /tmp/pwned":

$ jq -r '.[] | "echo " + .name' evil.json | sh
x
$ ls /tmp/pwned
/tmp/pwned                                               (the file was created: the data became a command)
$ jq -r '.[] | @sh "echo \(.name)"' evil.json | sh
x; touch /tmp/pwned
$ ls /tmp/pwned
ls: cannot access '/tmp/pwned': No such file or directory

@sh quotes every interpolated value for the shell (it turned the name into 'x; touch /tmp/pwned'). Even so, piping generated commands into sh is a last resort; the safer form is to read values and use them as arguments:

$ jq -r '.servers[] | select(.online) | .ip' servers.json | xargs -n1 echo ping
ping 10.0.1.11
ping 10.0.1.12
ping 10.0.2.21
ping 10.0.3.31
$ jq -r '.servers[].name' servers.json | while read -r n; do echo "host: $n"; done
host: web1
host: web2
host: db1
host: db2
host: cache1

For values that could contain spaces or newlines, separate them with NUL bytes (--raw-output0) and read them with xargs -0:

$ jq --raw-output0 '.servers[].name' servers.json | xargs -0 -n1 echo name=
name= web1
name= web2
name= db1
name= db2
name= cache1

Exporting variables safely from JSON:

$ jq -r 'to_entries[] | "export \(.key)=\(.value | @sh)"' <<< '{"A":"one two","B":"it'"'"'s"}'
export A='one two'
export B='it'\''s'

3. Use the exit status. jq's own exit codes (lab-checked): 0 success, 1 and 4 only with -e (last result false or null = 1, no result = 4), 2 usage or a file that cannot be read, 3 a compile error in your filter, 5 any runtime error including invalid JSON input.

$ jq -e 'all(.servers[]; .online)' servers.json >/dev/null && echo "all online" || echo "something is offline (exit $?)"
something is offline (exit 1)
$ jq -n 'empty' -e >/dev/null; echo "exit=$?"
exit=4

Without -e, jq exits 0 even when the filter printed null. A script that needs the field must either use -e or test the output.

4. -r on a null prints the word "null". Make absent values empty: jq -r '.a // empty'. A script that reads a "null" string as a hostname is a classic bug.

5. Never write the output to the file you are reading. The shell empties the file before jq starts:

$ cp servers.json s.json; jq '.datacenter = "ams1"' s.json > s.json; wc -c < s.json
0
$ cp servers.json s.json; jq '.datacenter = "ams1"' s.json | sponge s.json; jq -r .datacenter s.json      (sponge is in moreutils)
ams1
$ jq '.datacenter = "ams2"' s.json > s.tmp && mv s.tmp s.json                                              (the plain way)
$ jq -r .datacenter s.json
ams2

jq has no -i like sed. Use a temporary file and mv (which also keeps the original if jq fails, because && stops), or sponge.

6. In a pipe, jq buffers its output unless it is writing to a terminal. A producer that emits a line a second, through jq into a loop, looks hung and then delivers everything at the end. The lab timed it (three lines, one second apart, through jq and a read loop):

$ (for i in 1 2 3; do echo "{\"n\":$i}"; sleep 1; done) | jq -c . | while read -r l; do echo "$l at +${SECONDS}s"; done
{"n":1} at +3s
{"n":2} at +3s
{"n":3} at +3s
$ ... | jq --unbuffered -c . | while read -r l; do echo "$l at +${SECONDS}s"; done
{"n":1} at +0s
{"n":2} at +1s
{"n":3} at +2s

Use --unbuffered whenever jq reads from tail -f, a log shipper or any slow producer and the result must appear live. The same applies to a grep after jq: the jq output is buffered even when grep is the next stage.

7. Quoting. Use single quotes around the filter so the shell does not touch $, backticks or double quotes. Inside the filter, "..." are jq strings. If you need a shell value, use --arg, not "$var" inside double quotes. An empty variable in an --arg expansion is an empty string, which silently matches nothing; check what you pass.

8. Input problems that look like jq problems: a UTF-8 byte-order mark at the start of a file is accepted (the lab's file parsed fine), CRLF line endings are fine in JSON but stay in -R lines ("x\r"; clean them with rtrimstr("\r")), and an empty input (echo '' | jq .) prints nothing and exits 0.

Programs in files, variables, functions and modules

A long filter belongs in a file. Comments start with # and blank lines are ignored. The lab's report of servers with a disk more than 75% full:

# report.jq: one line per server that has a disk over 75% full
def worst: [.disks[].used_pct] | max;
.servers[]
| select(.disks | length > 0)
| select(worst > 75)
| "\(.name): \(worst)% used"

$ jq -r -f report.jq servers.json
web1: 78% used
web2: 91% used

(-f reads the program from the file; the first non-option argument is then an input file.) The select(.disks | length > 0) guards the empty list of db2, because max of an empty array is null and null > 75 is false anyway, but the guard says what you mean.

Variables are bound with "as". They hold a value for the rest of the pipeline to the right, and they let you reach back to an outer value:

$ jq -c '. as $root | .servers | map(select(.online) | {name, dc: $root.datacenter})' servers.json
[{"name":"web1","dc":"fra1"},{"name":"web2","dc":"fra1"},{"name":"db1","dc":"fra1"},{"name":"cache1","dc":"fra1"}]
$ jq -c '.servers | length as $n | map(.cpu) | add | . / $n' servers.json
5.2

Destructuring pulls apart arrays and objects in one go:

$ jq '. as {a: $x, b: [$y, $z]} | [$x,$y,$z]' -c <<< '{"a":1,"b":[2,3]}'
[1,2,3]
$ jq -c '.[] as [$a, $b] | {a: $a, b: $b}' <<< '[[1,2],[3,4]]'
{"a":1,"b":2}
{"a":3,"b":4}

Functions with def. A function can take filter arguments and value arguments ($name). Recursive functions work:

$ jq -c 'def pct: tostring + "%"; [.servers[0].disks[] | .used_pct | pct]' servers.json
["41%","78%"]
$ jq -c 'def over($t): select(.used_pct > $t); [.servers[].disks[] | over(75)]' servers.json
[{"mount":"/var","size_gb":200,"used_pct":78},{"mount":"/var","size_gb":200,"used_pct":91}]
$ jq -c -n 'def fac: if . <= 1 then 1 else . * (. - 1 | fac) end; [range(1;7) | fac]'
[1,2,6,24,120,720]

Modules: put functions in a .jq file and import them. -L adds the folder to the module search path. The lab's module and a missing-module error:

$ printf 'def shout: ascii_upcase + "!";\n' > lib.jq
$ jq -c -L . 'import "lib" as l; .servers[0].name | l::shout' servers.json
"WEB1!"
$ jq -c 'import "lib" as l; [.servers[0].name | l::shout]' servers.json
jq: error: module not found: lib

Conditionals use if ... then ... elif ... else ... end (the else is optional in jq 1.7). Errors are caught with try and catch, or the ? shorthand:

$ jq -c '[.[] | try tonumber catch "bad"]' <<< '["1","x","3"]'
[1,"bad",3]
$ jq -c 'try error("boom") catch .' <<< null
"boom"

jq 1.7 also has $ENV and env (the environment), input and inputs (read further inputs from inside the filter), and $__loc__ (the position in your own program, for error messages):

$ FOO=bar jq -n 'env.FOO, $ENV.FOO'
"bar"
"bar"
$ jq -n '$__loc__'
{"file":"<top-level>","line":1}                         (shown compact)

Errors and debugging

jq's messages are short. The common ones and what they mean:

Invalid JSON input (parse error, exit 5). The line and column are where jq gave up, which is usually just after the real mistake:

$ echo '{"a":1' | jq .
jq: parse error: Unfinished JSON term at EOF at line 2, column 0
$ echo '{"a":1,}' | jq .
jq: parse error: Expected another key-value pair at line 1, column 8
$ echo "{'a':1}" | jq .
jq: parse error: Invalid numeric literal at line 1, column 5
$ echo 'hello' | jq .
jq: parse error: Invalid numeric literal at line 2, column 0
$ printf '{"a":1} // comment\n' | jq -c .
{"a":1}                                                (the value before the error is still printed)
jq: parse error: Invalid numeric literal at line 1, column 11

JSON allows no trailing commas, no single quotes, no comments and no bare words. "Invalid numeric literal" often means you fed jq something that is not JSON at all (an HTML error page, a log line, a message from the tool). Look at the first bytes of the input. Output printed before a parse error is already out: a script that stops on error must check the exit status.

Your filter is wrong (compile error, exit 3):

$ jq '.servers | lenght' servers.json
jq: error: lenght/0 is not defined at <top-level>, line 1:
.servers | lenght
jq: 1 compile error
$ jq '.servers[ | length' servers.json
jq: error: syntax error, unexpected '|' (Unix shell quoting issues?) at <top-level>, line 1:
$ jq '.a-b' <<< '{"a":5,"b":2}'
jq: error: b/0 is not defined at <top-level>, line 1:

The last one is a classic: a key with a dash in it cannot be written .a-b (that is a minus). Quote it: .["a-b"] or ."a-b".

The data is not what the filter expected (runtime error, exit 5):

$ jq '.a.b.c' <<< '{"a":1}'
jq: error (at <stdin>:1): Cannot index number with string "b"
$ jq 'has("a")' <<< '[1]'
jq: error (at <stdin>:1): Cannot check whether array has a string key

These say which operation and what type the value really was. Print the structure to see: jq -c '.. | type' or jq 'map(type)'. The fix is usually a ? (ignore) or a type test (select(type == "object")).

Looking inside a running filter: debug prints the value to stderr and passes it on, debug("text") adds a message, stderr prints without the DEBUG wrapper (and without a newline), and halt_error stops with a chosen exit code:

$ jq -c -n '[1,2,3] | debug | map(.+1)'
["DEBUG:",[1,2,3]]                                     (this line is on stderr)
[2,3,4]
$ jq -c -n '[1,2,3] | debug("got: \(.)") | length'
["DEBUG:","got: [1,2,3]"]
3
$ jq -n '"fatal problem" | halt_error(3)'; echo " exit=$?"
fatal problem exit=3
$ jq -c 'input_filename, input_line_number' m1.json
"m1.json"
1

For a filter that gives no output when you expected some, build it up one stage at a time: run .servers, then .servers[], then .servers[] | select(...), and look at each. Most "it prints nothing" bugs are a select whose condition is never true (the string-versus-number comparison, a misspelt key that reads as null) or a missing -e when you wanted a failing exit status.

Big files and streaming

jq reads the whole of each JSON value into memory before it filters it. For a few megabytes that is fine. For huge input, the way you call jq decides memory and speed. The lab generated 1,000,000 log lines (62 MB, one JSON object per line) and measured each way (wall time and peak memory, on the lab machine):

  select error lines, one at a time (jq -c 'select(.level=="error")')        2.7 s     3 MB
  slurp the whole file into an array (jq -s '... | length')                  3.4 s   729 MB
  -n with inputs and reduce (count errors)                                    2.5 s     3 MB
  -n with inputs and reduce (count per service)                               7.4 s     3 MB
  grep the text first, then jq on the survivors                               0.45 s    3 MB
  stop at the first match (first(inputs | select(...)))                       0.00 s    3 MB

The same data as one big array (85 MB, one JSON value):

  plain jq on the array ([.[] | .ms] | add)                                   4.6 s   755 MB
  jq --stream on the array (reduce over the stream events)                   10.8 s     3 MB

The lessons:
  Lines of JSON are cheap: jq handles one value at a time, so memory stays tiny. Do not use -s on a large log. It needs memory as big as the file (here, twelve times it).
  To aggregate without slurping, use -n with inputs and reduce: jq -n 'reduce inputs as $l ({}; .[$l.service] += 1)' big.jsonl.
  Prefilter with grep when the cheap text match is enough. Here it was six times faster than jq alone. Then let jq check the survivors properly.
  A single huge array or object has to be read as a whole unless you use --stream, which turns it into events (a path and a value) that jq reads one by one in constant memory, at some cost in speed:

$ echo '{"a":[1,2,{"b":3}]}' | jq -c --stream .
[["a",0],1]
[["a",1],2]
[["a",2,"b"],3]
[["a",2,"b"]]
[["a",2]]
[["a"]]
$ echo '[1,[2,3]]' | jq -c --stream 'select(length==2)'          (the leaf values only)
[[0],1]
[[1,0],2]
[[1,1],3]
$ jq -nc --stream 'reduce (inputs | select(length==2 and .[0][-1]=="ms") | .[1]) as $m (0; . + $m)' big.json
1500252623

fromstream and truncate_stream rebuild the pieces; the lab's fromstream(1|truncate_stream(inputs)) (one object at a time out of the big array) held memory flat at 3 MB but took 30 seconds, so keep --stream for files that do not fit in memory, and convert them to JSON Lines when you can (jq -c '.[]' file.json > file.jsonl, if the memory allows once).

The numbers above are from one machine and will differ on yours. The shape of the result is what to take away: memory is a question of how you read, and the fastest query is often one that stops early.

Versions: jq 1.7 and jq 1.8

The jq in Ubuntu 24.04 is 1.7.1 (printing "jq-1.7"). The project's current release is 1.8.2. You will meet both: scripts written on a newer machine and run on Ubuntu, or the other way round. The lab ran the same programs against both. The release's static binary installs in a few lines, with the checksum from the same release:

$ curl -sL https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-linux-amd64 -o jq-1.8.2
$ curl -sL https://github.com/jqlang/jq/releases/download/jq-1.8.2/sha256sum.txt -o jq-sha.txt
$ grep " jq-linux-amd64$" jq-sha.txt
b1c22172dd303f3be49e935aa56aa48a8b7a46e0bc838b4997d3bb451495870f  jq-linux-amd64
$ sha256sum jq-1.8.2
b1c22172dd303f3be49e935aa56aa48a8b7a46e0bc838b4997d3bb451495870f  jq-1.8.2
$ chmod +x jq-1.8.2 && ./jq-1.8.2 --version
jq-1.8.2

(The two lines must match. Put the binary in /usr/local/bin/jq to use it by default; it will then shadow the package's. The release also has signatures; the lab compared checksums only.)

What is only in 1.8.2 (each checked, 1.7 answers "is not defined" or "not a valid format"):

  trim, ltrim, rtrim, trimstr("x")     strip whitespace, or a given prefix and suffix, from a string
  abs                                    absolute value
  skip(n; f)                             like limit, but drops the first n results
  add(f)                                 add up the results of f (instead of [f] | add)
  @urid                                  decode a URI-encoded string
  have_decnum, have_literal_numbers     ask whether the build keeps number text exactly

$ jq-1.8.2 -nc '"  x  " | trim, ltrim, rtrim'
"x"
"x  "
"  x"
$ jq-1.8.2 -nc '[-1, 2.5, -0] | map(abs)'
[1,2.5,0]
$ jq-1.8.2 -nc '[1,2,3] | skip(1; .[])' | tr "\n" " "
2 3
$ jq-1.8.2 -c 'add(.[].a)' <<< '[{"a":1},{"a":2}]'
3
$ jq-1.8.2 -nc '"a%20b" | @urid'
"a b"

On 1.7, do the same with sub("^\\s+|\\s+$";"";"g") for trim, a map(abs) as if . < 0 then -. else . end, and [f] | add for add(f).

What behaves differently in 1.8.2 (the lab compared the two on the same input):

  ltrimstr and rtrimstr on a value that is not a string. 1.7 returns the input unchanged ("5 | ltrimstr("x")" prints 5). 1.8.2 stops with "jq: error (at <stdin>:1): startswith() requires string inputs". A pipeline that trimmed a number or null by accident now fails.
  limit with a negative count. 1.7: [limit(-1; 1,2)] gives [1,2] (everything). 1.8.2: "jq: error (at <stdin>:1): limit doesn't support negative count".
  Error text. The same mistake reads slightly different: 1.7 says Cannot index array with string "b", 1.8.2 says Cannot index array with string ("b").

What gave the same result on both in the lab: pick, debug(msg), @sh, strptime with mktime, tojson of 1.10 (it stays "1.10"), big integer literals passing through untouched (and becoming 1e+20 after arithmetic), and map(. * 1) on [0.1, 1.0, 100].

Write for the older jq if you cannot control where the script runs: avoid the new functions, and put an explicit type test (select(type == "string")) before ltrimstr, so the script behaves the same on both.

Other tools. gojq is a Go reimplementation with clearer error messages and some differences (it does not keep number text and does not support some functions); jaq is a faster Rust one; yq edits YAML (and the two common yq programs differ: check which one you have). None of these was run in the lab. Check each against your scripts before you swap one for another.

Cheat sheet

Pretty-print           jq . file.json                  compact: jq -c .         sorted keys: jq -S .
Raw strings            jq -r '.name'                   (no quotes; use for scripts)
A key / nested         .a    .a.b    .["a-b"]    .a?    (missing key is null, wrong type is an error)
Array                  .[0]   .[-1]   .[2:4]   .[]   (.[] gives each element)
Pipe and comma         .a | .b      .a, .b
Filter                 .[] | select(.x == "y")        select(.n > 5)   select(.tags | index("a"))
Test text              test("re")   startswith("a")   endswith("a")   contains("a")
Default                .a // "none"      (also for false)      .a // empty
Build                  {name, ip: .addr, n: (.x | length)}     [.[] | .name]     "\(.a)-\(.b)"
Update                 .a = 1   .a |= . + 1   .a += 1   del(.a)   map(select(.ok))   pick(.a)
Count and group        length   group_by(.k)   unique_by(.k)   sort_by(.k)   max_by(.k)   add
Object <-> array       to_entries   from_entries   with_entries(f)   map_values(f)   INDEX(.id)
Loops                  map(f)   reduce .[] as $x (0; . + $x)   foreach   limit(n; f)   first(f)   range(n)
Variables, functions   . as $v | ...      def f($x): ...;      -f program.jq      -L dir  import "m" as m;
Text                   split(",")   join(",")   ascii_downcase   sub("a";"b")   gsub("a";"b")   capture("(?<x>..)")
Numbers                tonumber   tostring   floor   round   . * 100 | round / 100
Formats                @csv   @tsv   @sh   @uri   @base64   @base64d   @html   @json
Dates (UTC)            fromdate   todate   strftime("%F")   strptime("%F")   mktime   now   (strflocaltime + TZ for local)
From the shell         jq -r --arg v "$x" '.[] | select(.k == $v)'     --argjson n 5     --slurpfile f file   --args a b
Many inputs            jq -s '.'   (array of all)      jq -n '[inputs]'      jq -n 'reduce inputs as $l (...)'
Lines                  jq -R .   (each line a string)      jq -R -s .   (all as one string)
Scripts                jq -e 'cond'  (exit 1 on false/null, 4 on no output)      jq --unbuffered     jq --raw-output0 | xargs -0
Edit a file            jq '...' f > f.tmp && mv f.tmp f        or   jq '...' f | sponge f
Exit codes             0 ok   1/4 only with -e   2 usage or file   3 compile error   5 runtime or parse error
Big files              jq -c on lines (flat memory)      -n inputs + reduce      --stream      grep first      never -s on a big log
Debug                  debug   debug("msg")   stderr   halt_error(3)   $__loc__   input_filename

Common problems

parse error: Invalid numeric literal                  the input is not JSON (HTML error page, log text, single quotes, a comment): look at the first bytes of it
parse error: Unfinished JSON term at EOF             the input is cut off: a failed download, a pipe closed early, or a missing bracket
parse error: Expected another key-value pair         a trailing comma, which JSON does not allow
jq: error: X/0 is not defined                        a misspelt function or a key with a dash (.a-b): quote it, .["a-b"]
Cannot index string with string "x"                  you are one level too deep or too shallow: print the type (jq 'type') and fix the path
Cannot iterate over null                             .[] on a missing key: use .[]? or // []
Cannot index array with string                       an array where you expected an object: use .[] first or select(type == "object")
Prints nothing at all                                a select that never matches (string vs number, misspelt key reading as null): build the filter up one stage at a time
Selecting a number gives nothing with --arg          --arg is always a string and a number is smaller than any string: use --argjson or tonumber
Output has quotes in a shell variable                forgot -r; use jq -r (and // empty for nulls, or a null is printed as the word null)
A value appears as the text null in a script          -r prints the word null: use // empty, or test with -e
jq says exit 0 but the field was null                 without -e jq exits 0 whatever it printed; use -e or test the output
File is empty after jq ... > same_file                the shell truncates the file before jq reads it: write to a temp file and mv, or use sponge
Output arrives late in a pipe                         jq buffers into a pipe: use --unbuffered
Big file eats memory                                  you used -s or one huge array: use lines of JSON, -n inputs with reduce, or --stream
Numbers lose digits                                   arithmetic turns a big integer into a float: leave ids untouched, compare as strings
Month is off by one                                   strptime months are zero-based: 9 is October
Dates are an hour off                                 todate and strftime are UTC: use strflocaltime with TZ for local time
Regex backslash does nothing                          double it inside the jq string: "\\d+"
Works in jq 1.7, fails in 1.8                         ltrimstr on a non-string (now an error) or limit with a negative count: guard with a type test
Works in jq 1.8, fails in 1.7                         a new function (trim, abs, skip, add(f), @urid, trimstr): use the older form
leaf_paths is not defined                             removed in jq 1.7: use paths(scalars)
Injection in a script                                 data pasted into the filter or into a command: use --arg and @sh, never string addition into sh

Tips

Use jq -r for anything that goes to another command, and -c for JSON Lines. Use plain jq (pretty output) when you are reading.
Build a filter in stages: type the first stage, look at the output, add the next. Save the API answer to a file while you work.
Pass data in with --arg and --argjson. Quote shell output with @sh. Never paste values into a filter or into a command you will run.
Make scripts fail loudly: set -o pipefail, check jq's exit status, use -e where a null or false means failure, and use // empty so a missing value is not the string null.
Use jq -S and diff to compare two JSON files: key order is not meaning.
Put a long filter in a .jq file with comments and a name, and keep it in version control next to the script that uses it.
Keep numbers you do not compute as they are (ids), and compare them as strings. Do arithmetic only on values that are really numbers.
Count and group with group_by, reduce and sort_by instead of piping through sort and uniq -c: the key stays attached to the data.
Know which jq you have (jq --version) and write for the oldest one that will run the script, or bundle the static binary with it.
For big logs, filter lines, not arrays: -c on JSON Lines, -n with inputs, grep first, and never -s on something that does not fit in memory.
Reach for a real language (Python, Go) when a filter becomes a page of nested functions. jq is for pipelines, and it is at its best when the filter fits on a screen.