CoreMesh

jq Cheat Sheet: Copy-Paste Filters for Select, Map and Logs

Copy-paste jq filters for select, map, group_by, to_entries and counting elements, plus the flags you need for JSON logs. Sample input included.

jq Cheat Sheet: Copy-Paste Filters for Select, Map and Logs

A jq cheat sheet is only useful if the filters paste straight into a terminal and run. The patterns that cover most daily work are select for filtering, map for transforming arrays, group_by for counting per key, and to_entries for treating object keys as data.

If you came for one thing: filter array elements with jq '.[] | select(.field == "value")', count matches with jq 'map(select(.field == "value")) | length', and pull plain strings out of JSON logs with jq -r. The rest of the page is variations on those three moves, plus the flags and the gotchas that cost the most time.

jq filter cheat sheet#

Create the sample files first. The first is a single JSON array. The second is newline-delimited JSON, one object per line, which is what most CLIs and services write as logs.

bash
cat > users.json <<'EOF'
[
  {"name": "Ana",   "team": "web",  "age": 31, "active": true,  "langs": ["js", "css"]},
  {"name": "Ben",   "team": "api",  "age": 45, "active": false, "langs": ["go"]},
  {"name": "Chloe", "team": "web",  "age": 28, "active": true,  "langs": ["js", "ts", "css"]},
  {"name": "Dev",   "team": "data", "age": 39, "active": true,  "langs": ["python", "sql"]}
]
EOF

cat > app.log <<'EOF'
{"ts":"2026-10-16T09:00:01Z","level":"info","msg":"server started","port":8080}
{"ts":"2026-10-16T09:00:05Z","level":"warn","msg":"slow query","ms":812,"route":"/api/orders"}
{"ts":"2026-10-16T09:00:07Z","level":"error","msg":"upstream timeout","ms":5003,"route":"/api/orders"}
{"ts":"2026-10-16T09:00:09Z","level":"info","msg":"request","ms":42,"route":"/health"}
EOF

The table lists the filters you'll reach for most. Each one is expanded with runnable commands further down.

Filter What it does Example
. Identity; pretty-prints the input jq '.' users.json
.foo, .foo.bar Field access; null if the key is missing jq '.[0].name' users.json
.["foo.bar"], ."foo-bar" Field access for keys that aren't identifiers jq '.["foo.bar"]'
.[] Emit each array element (or each object value) jq '.[] \| .name' users.json
.[0], .[-1] Index; negatives count from the end jq '.[-1].name' users.json
.[2:4], .[:3] Slice, end exclusive; works on strings too jq '.[1:3]' users.json
.foo?, .[]? Same as above, but no error on the wrong type jq '.[] \| .langs[]?'
select(cond) Keep the input only if cond is true select(.active)
map(f) Apply f to every element; same as [.[] \| f] map(.name)
length Array length, object key count, or string length jq 'length' users.json
keys, has("k") Sorted key list; key existence jq '.[0] \| keys' users.json
group_by(f), sort_by(f), unique_by(f) Group, sort or dedupe by an expression group_by(.team)
to_entries, from_entries, with_entries(f) Object to key/value pairs and back with_entries(.value \|= tostring)
add, min_by(f), max_by(f) Sum or concatenate; pick an extreme element map(.age) \| add
a // b Use b when a is null or false .route // "-"
"\(.a) \(.b)" String interpolation pair with -r for plain text
@csv, @tsv Format an array as one CSV or TSV row [.ts, .msg] \| @tsv

The jq manual documents every builtin with an input and output pair, and it's the place to check when a filter behaves differently than you expect.

Selecting and filtering with select#

select(f) passes its input through unchanged when f is true and produces nothing otherwise. It works on a stream of values, so you pair it with .[], which emits each array element, or wrap it in map.

bash
# Active users, whole objects
jq '.[] | select(.active)' users.json

# Names only, as plain strings
jq -r '.[] | select(.team == "web") | .name' users.json

# Combine conditions
jq '.[] | select(.active and .age < 40) | .name' users.json

# Regex on a field
jq '.[] | select(.name | test("^[AC]")) | .name' users.json

# Array field contains an exact value
jq '.[] | select(any(.langs[]; . == "js")) | .name' users.json

# Keep the result as an array instead of a stream
jq 'map(select(.age > 30))' users.json

The two shapes differ in output. .[] | select(...) writes each match as a separate JSON value, which is what you want in a terminal or with -r. map(select(...)) returns one array, which you need before length, sort_by, or handing the result to another program as a single document.

Shell values go in through --arg (always a string) or --argjson (parsed as JSON):

bash
team=web
jq --arg t "$team" '.[] | select(.team == $t) | .name' users.json
jq --argjson min 30 '.[] | select(.age >= $min) | .name' users.json

If you know JavaScript, map and select line up with Array.prototype.map and filter; the JavaScript array methods cheat sheet covers the same operations on that side.

Transforming arrays with map#

map(f) is shorthand for [.[] | f]: run f on each element and collect the results into a new array.

bash
jq 'map(.name)' users.json                   # ["Ana","Ben","Chloe","Dev"]
jq 'map({name, team})' users.json            # pick fields
jq 'map(.age + 1)' users.json
jq 'map(.age) | add' users.json              # sum
jq 'map(.age) | add / length' users.json     # average
jq 'sort_by(.age) | map(.name)' users.json
jq 'sort_by(.age) | reverse | .[0].name' users.json   # oldest
jq 'max_by(.age) | .name' users.json         # same thing, shorter
jq 'map(.langs) | add | unique' users.json   # flatten and dedupe
jq 'map(del(.langs))' users.json             # drop a field from each
jq 'map(.active |= not)' users.json          # update a field in place

Two notes. {name, team} is shorthand for {name: .name, team: .team}. And add / length applies both add and length to the same array, so the average falls out in one step.

Grouping and counting elements with group_by#

Searches for "jq count elements" usually mean one of these:

bash
jq 'length' users.json                           # 4 elements in the array
jq '.[0] | length' users.json                    # 5 keys in the first object
jq 'map(select(.active)) | length' users.json    # 3 active users
jq '[.[] | .langs | length]' users.json          # per-element counts
jq 'map(.langs | length) | add' users.json       # total across all users

length is type-aware: on an array it's the element count, on an object the key count, on a string the character count.

group_by(f) splits an array into sub-arrays that share the same value of f, sorted by that value. The idiom is to group, then collapse each group into a summary:

bash
# Array of arrays, one per team, sorted by team
jq 'group_by(.team)' users.json

# [{"team":"api","count":1},{"team":"data","count":1},{"team":"web","count":2}]
jq 'group_by(.team) | map({team: .[0].team, count: length})' users.json

# {"api":1,"data":1,"web":2}
jq 'group_by(.team) | map({key: .[0].team, value: length}) | from_entries' users.json

# Average age per team
jq 'group_by(.team) | map({team: .[0].team, avg_age: (map(.age) | add / length)})' users.json

Because group_by sorts, the output order is alphabetical by team, not the input order. If you want input order or want to skip building the intermediate arrays, use reduce:

bash
jq 'reduce .[] as $u ({}; .[$u.team] += 1)' users.json

.[$u.team] += 1 works on a missing key because null + 1 is 1 in jq.

Here's the same grouping and counting in plain Python, so you can confirm the shape jq produces. If you handle JSON in Python a lot, the Python JSON examples page covers dumps, loads, and the file variants.

python
import json
from itertools import groupby

users = [
    {"name": "Ana", "team": "web", "age": 31, "active": True},
    {"name": "Ben", "team": "api", "age": 45, "active": False},
    {"name": "Chloe", "team": "web", "age": 28, "active": True},
    {"name": "Dev", "team": "data", "age": 39, "active": True},
]

# jq: group_by(.team) | map({team: .[0].team, count: length})
by_team = sorted(users, key=lambda u: u["team"])
counts = [
    {"team": team, "count": len(list(group))}
    for team, group in groupby(by_team, key=lambda u: u["team"])
]
print(json.dumps(counts))

# jq: map(select(.active)) | length
print(len([u for u in users if u["active"]]))

# jq: map(.age) | add / length
print(sum(u["age"] for u in users) / len(users))

Working with object keys: to_entries, with_entries, keys#

to_entries turns {"a": 1, "b": 2} into [{"key": "a", "value": 1}, {"key": "b", "value": 2}]. That makes keys ordinary data you can filter, rename, or format. from_entries reverses it, and with_entries(f) is to_entries | map(f) | from_entries in one call.

bash
jq '.[0] | to_entries' users.json

# Drop array-valued keys
jq '.[0] | with_entries(select(.value | type != "array"))' users.json

# Rename keys
jq '.[0] | with_entries(.key |= ascii_upcase)' users.json

# KEY=value lines, one per key
jq -r '.[0] | to_entries[] | "\(.key)=\(.value)"' users.json

# Every value as a string
jq '.[0] | map_values(tostring)' users.json

jq '.[0] | keys' users.json           # sorted key list
jq '.[0] | has("email")' users.json   # false

# Arrays work too; the key is the index
jq 'map(.name) | to_entries' users.json

keys returns keys sorted; keys_unsorted keeps the object's own order. For a flat object where you only want to transform values, map_values(f) is shorter than with_entries.

Reading CLI JSON logs with jq#

Log files with one JSON object per line don't need --slurp. jq already runs the filter once per input value, so select and -r do most of the work:

bash
# One compact line per entry
jq -c '.' app.log

# Only errors
jq 'select(.level == "error")' app.log

# Human-readable lines
jq -r '"\(.ts) \(.level | ascii_upcase) \(.msg)"' app.log

# Slow requests as tab-separated rows
jq -r 'select(.ms > 500) | [.ts, .route, .ms] | @tsv' app.log

# CSV for a spreadsheet
jq -r '[.ts, .level, .msg] | @csv' app.log

# Follow a live log, skipping info lines
tail -f app.log | jq --unbuffered -r 'select(.level != "info") | .msg'

select(.ms > 500) is safe on lines without an ms field because a missing key reads as null, and null > 500 is false, so the line is just dropped.

Counting across the whole file needs either -s, which reads everything into one array, or -n with inputs, which consumes the stream without building that array:

bash
# {"error":1,"info":2,"warn":1}
jq -s 'group_by(.level) | map({key: .[0].level, value: length}) | from_entries' app.log

# Same result, streaming
jq -n 'reduce inputs as $e ({}; .[$e.level] += 1)' app.log

# Number of lines
jq -s 'length' app.log

Some tools interleave plain-text lines with JSON. -R reads each line as a string, fromjson? parses it and silently skips lines that fail, and objects drops anything that parsed but isn't an object:

bash
jq -R 'fromjson? | objects | select(.level == "error")' mixed.log

-e turns the last output into an exit status: 0 for a value that isn't false or null, 1 for false or null, and 4 if nothing was produced. That makes jq usable in shell conditionals:

bash
if jq -e -n 'any(inputs; .level == "error")' app.log >/dev/null; then
  echo "errors found"
fi

The following Node.js snippet reproduces two of the log filters above so you can see the expected output shape without installing anything:

javascript
const log = `
{"ts":"2026-10-16T09:00:01Z","level":"info","msg":"server started","port":8080}
{"ts":"2026-10-16T09:00:05Z","level":"warn","msg":"slow query","ms":812,"route":"/api/orders"}
{"ts":"2026-10-16T09:00:07Z","level":"error","msg":"upstream timeout","ms":5003,"route":"/api/orders"}
{"ts":"2026-10-16T09:00:09Z","level":"info","msg":"request","ms":42,"route":"/health"}
`.trim().split("\n").map(JSON.parse);

// jq -r 'select(.ms > 500) | [.ts, .route, .ms] | @tsv'
for (const e of log.filter((e) => e.ms > 500)) {
  console.log([e.ts, e.route, e.ms].join("\t"));
}

// jq -n 'reduce inputs as $e ({}; .[$e.level] += 1)'
const perLevel = {};
for (const e of log) perLevel[e.level] = (perLevel[e.level] ?? 0) + 1;
console.log(JSON.stringify(perLevel));

jq command cheat sheet: flags you'll use#

Flag Effect
-r Print string results raw, without quotes
-c Compact output, one JSON value per line
-s Read all inputs into one array and run the filter once
-n Don't read input; run once with null (use with inputs or to build JSON)
-R Treat each input line as a string instead of parsing JSON
-e Exit 1 if the last output is false or null, 4 if there was no output
-S Sort object keys in the output
--arg name value Bind $name to a string
--argjson name json Bind $name to a parsed JSON value
--slurpfile name file Bind $name to an array of the JSON values in file
--rawfile name file Bind $name to the text of file as one string
--args Remaining arguments become $ARGS.positional strings
--tab, --indent n Indent with tabs, or with n spaces (no more than 7)
-f file Read the filter from a file instead of the command line
--stream Emit [path, leaf] pairs for very large inputs

-n is also how you build JSON from scratch, which is handy for request bodies:

bash
jq -n --arg name "Ana" --argjson age 31 '{name: $name, age: $age}'

Without -e, jq exits 0 when the program ran, 2 on a usage or system error, and 3 when the filter failed to compile. For very large single documents, --stream avoids parsing the whole value into memory; combine it with reduce or foreach to fold the input incrementally.

Common jq mistakes and gotchas#

Quoting. On Unix shells, always single-quote the filter. jq "foo" fails because the shell hands jq the bare word foo, which isn't defined. On Windows cmd.exe, put double quotes around the filter and backslash-escape the double quotes inside it. In PowerShell, use single quotes outside and \" inside. The Bash manual's quoting section explains why $ and | are the usual culprits.

--arg is always a string. --arg min 30 binds $min to "30". select(.age > $min) then compares a number to a string, which jq allows and quietly evaluates as false for every row, so you get empty output and no error. Use --argjson min 30 or ($min | tonumber).

Missing keys are null, not errors. .nmae on every user returns null four times and nothing complains. When output looks suspiciously empty, check spelling first, or guard with has("name").

Indexing an array with a field name is an error. jq '.name' users.json fails because the top level is an array. Use .[] | .name, map(.name), or .[0].name. The ? suffix, as in .name?, suppresses the error if you'd rather get nothing.

Keys with dots or dashes need brackets or quotes. .["foo.bar"] and ."foo-bar" work. .foo::bar does not, and .foo.bar means the nested path, not a literal key named foo.bar.

Counting NDJSON without -s. jq 'length' app.log prints one number per line, the key count of each object, not the number of lines. Use jq -s 'length' or jq -n '[inputs] | length'.

Sorting you didn't ask for. group_by, keys, unique, and -S all sort. Use keys_unsorted or a reduce when original order matters.

-r only unquotes strings. Arrays and objects still print as JSON. To get one plain line from several fields, use string interpolation "\(.a) \(.b)" or [.a, .b] | @tsv.

Big numbers. The manual notes that arithmetic converts numbers to IEEE 754 doubles, and that 1E1234567890 | . can print as 1.7976931348623157e+308. Treat IDs and other long integers as strings and don't do math on them.

Slices exclude the end. .[2:4] returns two elements, index 2 and 3. Negative indexes count from the end, so .[-1] is the last element.

Quick reference#

  • Pretty-print or validate: jq '.' file.json. Compact: jq -c '.' file.json.
  • Filter: .[] | select(.k == "v"). Keep as an array: map(select(.k == "v")).
  • Count: length. Count matches: map(select(...)) | length. Count NDJSON lines: jq -s 'length'.
  • Group and count: group_by(.k) | map({key: .[0].k, value: length}) | from_entries.
  • Pick fields: map({a, b}). Drop fields: map(del(.c)).
  • Object as rows: to_entries[] | "\(.key)=\(.value)" with -r.
  • Plain text out: -r plus "\(.a) \(.b)", @tsv, or @csv.
  • Shell variables in: --arg name "$str" for strings, --argjson name 42 for numbers and JSON.
  • Scripts: -e for exit codes, -f filter.jq for long filters.
  • Odd or optional keys: .["odd.key"], .k?, .k // "default".
  • Single quotes around the filter on Linux and macOS, every time.

Quick answers

Is there a jq cheat sheet PDF?

The official jq manual is a web page, not a PDF. If you want an offline copy, print this page to PDF from your browser; the sample files and commands are all on it.

How do I count elements with jq?

Use length on an array for the element count, or map(select(condition)) | length to count matches. For a newline-delimited log, add -s so the whole file becomes one array first, as in jq -s 'length' app.log.

What's the difference between .[] | select(...) and map(select(...))?

The first emits each match as a separate JSON value, which is ideal for terminals and -r. The second returns a single array, which you need before length, sort_by, or passing the result on as one document.

How do I pass a bash variable into a jq filter?

Use --arg name "$var" and reference $name inside the filter. --arg always binds a string, so for numbers or JSON use --argjson name value instead.

Is jq syntax the same on Linux, macOS and Windows?

The filters are identical. Only shell quoting differs: single quotes around the filter on Unix shells, double quotes with escaped inner quotes on Windows cmd.exe, and single quotes outside with \" inside on PowerShell.

Did this solve it?

One tap. No account.

Saved you an hour? Send a tip.

USDC or ETH on Base, Ethereum, Arbitrum and more. Keeps the notes free and the examples running.

Base

Network

0xc783bc5c3fa14cEC3f09A22284bC91C5AF0d59e1
Open in wallet · 5 USDC

On a computer? Scan the code with your phone wallet, or copy the address.

Pick the network your wallet or exchange uses. USDC works on all six; ETH on Base, Ethereum, Arbitrum and Optimism. Don't send on Tron or Solana: this address doesn't exist there. Tips are optional and unlock nothing; every guide stays free.

Discussion

Start the discussion

Indra Wijaya

Builds and runs AI agents, bots and content pipelines, and writes down what works and what broke.

About · Email · RSS

Keep reading

New notes, no spam.

One post at a time, only when there's something worth reading. Follow in any feed reader.

Subscribe via RSS