---
title: "jq"
description: "A jq reference for engineers who live in JSON APIs and AWS CLI output. Field selection, array iteration, select and conditionals, map and reduce, string interpolation, @tsv and @csv, and the flags that make jq safe inside a shell script."
author: "Mohammad Abu Mattar"
canonical: https://mkabumattar.com/cheatsheets/jq
---

# jq

jq clicked for me the day I stopped thinking of it as a JSON string transformer. It is a stream of values, and every filter takes a stream in and emits a stream out. That one idea explains the parts that otherwise look arbitrary, including why `.[]` behaves like a loop and why `select` can make a value disappear.

_How a value moves through a jq pipeline_

Two consequences fall out of that model. Piping into a filter is not iteration syntax, it is just a stream being consumed, which is why `.[] | .name` and `.[].name` are the same thing. And filtering is not a feature, it is a filter that sometimes emits zero values, which is exactly what `select` does.

Most of my jq use is against the AWS CLI, where there is a second filtering mechanism competing for the same job.

_Where jq sits next to the AWS CLI, and the three flags that bite_

The reference below is grouped the way the work splits in practice: getting at fields, filtering them down, reshaping what survives, aggregating, and then handing the result back to the shell.

## Selecting fields

Reaching into an object, safely, including when the key might not be there.

### Paths and nesting

The identity filter, plain keys, nested keys, and keys that need quoting.

**Keywords:** path, identity, nested, dot

#### Pretty-print without changing anything

```bash
jq . data.json
```

_input_
```json
{"name":"api","port":8080}
```

_output_
```json
{
  "name": "api",
  "port": 8080
}
```

`.` is the identity filter. It emits its input unchanged, and jq pretty-prints by default, so this is the shortest JSON formatter you have.

- Add `-S` to sort object keys, which makes two JSON documents diffable.
- `-c` prints compact single-line output, which is what you want when piping into another tool or writing JSON Lines.

#### Nested keys, and keys that are not identifiers

```bash
jq '.server.tls.enabled' config.json
jq '.["content-type"]' headers.json
jq '.a.b.c' data.json
```

_input_
```json
{"server":{"tls":{"enabled":true}}}
```

_output_
```json
true
```

Chain keys with dots. Anything containing a dash, a space, or a leading digit has to go through the bracket form.

- `.foo-bar` is parsed as `.foo` minus `bar`, which is a confusing arithmetic error rather than a syntax error.
- Always single-quote the filter in a shell. Double quotes let the shell expand `$` and backticks inside your jq program.

#### Missing keys, and how not to crash

```bash
# null, no error: a missing key is null
jq '.nope' <<< '{"a":1}'

# error: cannot index a string with "b"
jq '.a.b' <<< '{"a":"hello"}'

# null, no error: ? swallows the type error
jq '.a.b?' <<< '{"a":"hello"}'

# a default instead of null
jq '.port // 8080' <<< '{}'
```

_output_
```text
null
jq: error: Cannot index string with "b"
null
8080
```

A missing key gives `null`. Indexing the wrong *type* is a hard error, and `?` is what turns that error back into `null`.

- `//` is the alternative operator. It substitutes when the left side is `null` or `false`, so `.count // 0` also replaces a legitimate `false`.
- Prefer `?` narrowly. Wrapping a whole filter in `?` hides real bugs along with the one you meant to tolerate.

**Best practices:**

- Single-quote the filter. Double quotes hand your jq program to the shell first.
- Reach for `// default` rather than `?` when you want a value, and `?` only when you want to skip.

**Common errors:**

- **jq: error: Cannot index array with "name"**: You are at an array but wrote an object key. Insert `.[]` to get to the elements first, as in `.items[].name`.
- **jq: error: syntax error, unexpected INVALID_CHARACTER**: Usually an unquoted key containing a dash. Use `.["my-key"]`. It can also be a shell-expanded `$` from using double quotes.

**Advanced notes:**

- **Optional chaining is not the same as a default:** `.a.b?` suppresses the error and gives you nothing to work with. `.a.b // "x"` gives you a usable value. They look similar and behave differently the moment you pipe the result somewhere that cannot accept `null`.

### Arrays and slices

Iteration, indexing from either end, and slicing.

**Keywords:** array, iterate, index, slice

#### Iterate, index, and slice

```bash
jq '.[]'     <<< '[10,20,30]'   # each element, separately
jq '.[0]'    <<< '[10,20,30]'   # 10
jq '.[-1]'   <<< '[10,20,30]'   # 30, counting from the end
jq -c '.[1:3]' <<< '[10,20,30,40]'  # [20,30]
jq 'length'  <<< '[10,20,30]'   # 3
```

_output_
```text
10
20
30
10
30
[20,30]
3
```

`.[]` emits each element as its own value, which is why the first command prints three lines rather than one array.

- Slices are half-open, so `.[1:3]` is elements 1 and 2. Same convention as Python.
- `.[]` on an object emits its *values*, not its keys. That is often exactly what you want.

#### Collect a stream back into an array

```bash
# three separate values
jq '.[] | .name' <<< '[{"name":"a"},{"name":"b"}]'

# one array, because of the surrounding brackets
jq -c '[.[] | .name]' <<< '[{"name":"a"},{"name":"b"}]'

# same thing, shorter
jq -c '[.[].name]' <<< '[{"name":"a"},{"name":"b"}]'
```

_output_
```text
"a"
"b"
["a","b"]
["a","b"]
```

Square brackets around a filter collect everything it emits into a single array. This is the fix for most "jq produced invalid JSON" complaints.

- Two JSON values printed one after another is not invalid output, it is a valid JSON stream. It is only invalid if the consumer expected one document.
- Use `-s` (slurp) to do the reverse: read a whole stream of input documents into one array before the filter runs.

**Best practices:**

- Decide up front whether the consumer wants a stream or one document, then bracket accordingly.
- Prefer `.[].name` over `.[] | .name` for the simple case. Fewer moving parts, same result.

**Common errors:**

- **A downstream tool reports "extra data" or "trailing garbage" when parsing your output**: You emitted a stream of values and it wanted a single document. Wrap the filter in `[ ]`, or add `-c` and have the consumer read JSON Lines.
- **jq: error: Cannot iterate over null**: The key you dereferenced does not exist. Use `.items[]?` or `(.items // [])[]` so an absent array behaves like an empty one.

**Advanced notes:**

- **slurp versus collect:** `-s` and `[ ]` both produce an array and are not interchangeable. `-s` operates on the *input* stream, turning many input documents into one array before your filter sees anything. `[ ]` operates on your filter output. Reading a file of JSON Lines needs `-s`; collecting your own results needs brackets.

## Filtering and tests

Keeping the values you want, using select, conditionals, and membership tests.

### select and conditionals

The filter that emits nothing when its test fails.

**Keywords:** select, filter, if, test

#### Keep matching values

```bash
jq -c '.[] | select(.state == "running")' instances.json
jq -c '.[] | select(.cpu > 80)'            metrics.json
jq -c '.[] | select(.name | startswith("prod"))' apps.json
jq -c '.[] | select(.tags | index("db"))' hosts.json
```

_input_
```json
[{"state":"running","id":1},{"state":"stopped","id":2}]
```

_output_
```json
{"state":"running","id":1}
```

`select` passes its input through when the test is truthy and emits nothing at all when it is not. Nothing is removed, some values simply never arrive.

- Only `false` and `null` are falsy in jq. Zero and the empty string are both truthy, which differs from most shells.
- Combine tests with `and`, `or`, `not`. Note that `not` is a filter you pipe into: `select(.x | not)`.

#### Regular expressions and case-insensitive matching

```bash
jq -r '.[] | select(.name | test("^web-[0-9]+$")) | .name' hosts.json
jq -r '.[] | select(.msg | test("timeout"; "i")) | .msg'  logs.json
```

`test` returns a boolean. `match` returns capture details, and `capture` returns named groups as an object.

- The second argument holds flags. `i` for case-insensitive, `x` for extended whitespace-insensitive patterns.
- jq uses Oniguruma regular expressions, which are close to PCRE. Very old jq builds compiled without regex support at all.

#### if, then, else as a value

```bash
jq -r '.[] | if .cpu > 80 then "HOT  \(.host)"
             elif .cpu > 50 then "warm \(.host)"
             else "ok   \(.host)" end' metrics.json
```

_output_
```text
HOT  web-1
warm web-2
ok   web-3
```

An `if` in jq is an expression that produces a value, so it can sit anywhere a value can. The `end` is required.

- `else` is optional in modern jq. Omitting it passes the input through unchanged when no branch matches, which is occasionally what you want and often a surprise.

**Best practices:**

- Put `select` as early in the pipeline as you can, so later filters process fewer values.
- When a test gets long, name it: `def is_stale: .age > 3600; .[] | select(is_stale)`.

**Common errors:**

- **select returns everything, including values you expected it to drop**: The test is probably returning a non-boolean truthy value. `select(.count)` keeps anything where count is not null or false, including `0`. Write the comparison out: `select(.count > 0)`.
- **jq: error: test/1 is not defined**: Your jq was built without Oniguruma. Check `jq --version` and prefer the official static binary over a distribution package on old systems.

**Advanced notes:**

- **Why select is not special:** `select(f)` is defined in jq's own standard library as `if f then . else empty end`. The filtering behaviour comes entirely from `empty`, which emits zero values. Any filter you write can do the same.

### keys, values, has, and length

Asking a document about its own shape, which is most of what exploratory jq is for.

**Keywords:** keys, has, length, type

#### What is in here?

```bash
jq -c 'keys'          <<< '{"b":1,"a":2}'   # ["a","b"], sorted
jq -c 'keys_unsorted' <<< '{"b":1,"a":2}'   # ["b","a"], document order
jq -c '[.[]]'         <<< '{"a":1,"b":2}'   # [1,2], the values
jq 'has("port")'      <<< '{"port":80}'     # true
jq 'length'           <<< '{"a":1,"b":2}'   # 2
jq -r 'type'          <<< '[1,2]'           # array
```

_output_
```text
["a","b"]
["b","a"]
[1,2]
true
2
array
```

`length` is polymorphic: element count for an array, key count for an object, character count for a string, absolute value for a number, and 0 for null.

- `has` takes a key name for objects and an index for arrays. `in` is the same test with the arguments the other way round.
- `keys` sorts. Use `keys_unsorted` when document order matters, and note it is also faster.

#### Find the shape of an unfamiliar API response

```bash
# every distinct path in the document, as dotted strings
jq -r '[paths | join(".")] | unique | .[]' response.json

# what type does each top-level field hold?
jq -r 'to_entries[] | "\(.key): \(.value | type)"' response.json
```

_output_
```text
id: number
name: string
tags: array
owner: object
```

This is the first thing to run against an API you have not seen before. It replaces scrolling through 400 lines of pretty-printed JSON.

- `paths` emits every path as an array of keys and indices. `leaf_paths` restricts it to paths ending at a scalar.
- `to_entries` converts an object into an array of `{key, value}` pairs, which is how you iterate an object while keeping its keys.

**Best practices:**

- Run the paths one-liner before writing a real filter against an unfamiliar payload.
- Use `has` rather than comparing to null when absence and an explicit null mean different things in that API.

**Common errors:**

- **jq: error: has/1 requires a string key for objects**: You passed a number. `has(0)` is for arrays, `has("0")` for an object whose key happens to be the character zero.

**Advanced notes:**

- **getpath and setpath for computed paths:** When the path is data rather than something you can type, `getpath(["a","b"])` and `setpath(["a","b"]; 1)` take it as an array. This is how you write a filter that operates on a path supplied through `--arg`, which is otherwise impossible.

## Transforming

Reshaping values with map, object construction, and string interpolation.

### map, map_values, and building objects

Applying a filter to every element, and assembling a new shape from the pieces.

**Keywords:** map, map_values, object construction, with_entries

#### map versus .[]

```bash
# map keeps the array wrapper
jq -c 'map(.v)'        <<< '[{"v":1},{"v":2}]'   # [1,2]

# .[] does not
jq -c '.[] | .v'       <<< '[{"v":1},{"v":2}]'   # 1 then 2

# map_values works on an object, preserving its keys
jq -c 'map_values(. * 2)' <<< '{"a":1,"b":2}'    # {"a":2,"b":4}
```

_output_
```text
[1,2]
1
2
{"a":2,"b":4}
```

`map(f)` is just `[.[] | f]`. Knowing that is enough to stop guessing which one you need.

- `map` on an object returns an *array* of transformed values and throws the keys away. `map_values` is the one that keeps them.
- `map(select(f))` is the idiomatic way to filter and stay inside an array.

#### Build exactly the shape you want

```bash
jq -c '.Reservations[].Instances[] | {
  id:   .InstanceId,
  type: .InstanceType,
  az:   .Placement.AvailabilityZone,
  name: ((.Tags // []) | from_entries | .Name // "untagged")
}' instances.json
```

_output_
```json
{"id":"i-0abc123","type":"t3.medium","az":"eu-west-1a","name":"web-1"}
```

Object construction is the most useful thing jq does with AWS output: turn a deeply nested response into a flat record you can feed to anything else.

- AWS returns tags as `[{"Key":"Name","Value":"web-1"}]`. That is not `from_entries` shape, which wants lowercase `key`/`value`. jq accepts both `Key`/`Value` and `key`/`value`, which is a genuinely helpful special case.
- `{id: .InstanceId}` needs the colon form. `{InstanceId}` is shorthand that keeps the original key name.

#### Rename or rewrite every key

```bash
# snake_case every key at the top level
jq -c 'with_entries(.key |= (gsub("(?<c>[A-Z])"; "_\(.c | ascii_downcase)") | ltrimstr("_")))' \
  <<< '{"InstanceId":"i-1","InstanceType":"t3.micro"}'
```

_output_
```json
{"instance_id":"i-1","instance_type":"t3.micro"}
```

`with_entries(f)` is `to_entries | map(f) | from_entries`, so `f` sees each pair as a `{key, value}` object and can change either side.

- `|=` updates a path in place. `=` replaces it with a value computed against the *original* input, which is a subtle and important difference.
- This only touches the top level. Recursive renaming needs `walk`.

**Best practices:**

- Construct an explicit object rather than deleting fields. It documents the contract and survives the upstream API adding fields.
- Use `|=` for updates so the filter on the right receives the current value at that path.

**Common errors:**

- **jq: error: Cannot index number with "v"**: You used `map(.v)` on an array of numbers, or applied `map` twice. Print the intermediate value to see what the filter actually receives.
- **Object construction silently produces null fields**: A path in the construction does not exist. Add `// "default"` per field, since one missing path does not fail the whole object, it just contributes null.

**Advanced notes:**

- **walk, for when the nesting is arbitrary:** `walk(f)` applies `f` to every value in a document bottom-up, at any depth. Stripping every null from a deeply nested response is `walk(if type == "object" then with_entries(select(.value != null)) else . end)`. It is the one builtin that makes recursive cleanup practical.

### String interpolation and output formats

Producing text rather than JSON, which is usually the last step of a shell pipeline.

**Keywords:** interpolation, tsv, csv, raw output

#### Interpolate values into a string

```bash
jq -r '.[] | "\(.host) is at \(.cpu)% cpu"' metrics.json
jq -r '.[] | "\(.name): \(.tags | length) tags"' apps.json
```

_output_
```text
web-1 is at 91% cpu
web-2 is at 44% cpu
```

Anything inside `\( )` is a jq filter, not just a variable, so you can compute in place.

- `-r` is what strips the surrounding quotes. Without it every line comes out as a quoted JSON string.
- Interpolating an object or array gives you its JSON text, which is handy for logging and awful for parsing.

#### Tabular output for the rest of your pipeline

```bash
jq -r '.[] | [.id, .name, .cpu] | @tsv' metrics.json
jq -r '.[] | [.id, .name] | @csv'       metrics.json
jq -r '.[] | @base64'                   secrets.json
jq -rn --arg q 'a b' '@uri "?search=\($q)"'
```

_output_
```text
i-1	web-1	91
"i-1","web-1"
?search=a%20b
```

`@tsv` and `@csv` take an array and emit one properly escaped line. They handle embedded quotes and tabs, which a hand-rolled `join` does not.

- `@tsv` output is what `cut -f`, `awk -F"\t"` and `while IFS=$'\t' read` expect, so this is the usual handover point back to the shell.
- `@csv` refuses non-scalar values. Stringify first with `map(tostring)` if a field might be an object.

#### Pass shell values in safely

```bash
# right: --arg passes a string, quoted and escaped for you
jq --arg env "prod" '.[] | select(.env == $env)' apps.json

# right: --argjson passes a number, boolean, or object
jq --argjson min 80 '.[] | select(.cpu > $min)' metrics.json

# wrong: string interpolation by the shell, and an injection waiting to happen
jq ".[] | select(.env == \"$ENV\")" apps.json
```

`--arg` always produces a string. `--argjson` parses its value as JSON, which is how you pass numbers and booleans.

- A value containing a quote or a backslash breaks the interpolated form and can change what the filter does. `--arg` cannot.
- `-n` starts with `null` as input rather than reading stdin, which is how you build JSON entirely from arguments.

**Best practices:**

- Always `--arg` or `--argjson`. Never interpolate a shell variable into a jq program.
- End a shell-facing pipeline with `@tsv` rather than a hand-built string. It escapes correctly and reads more clearly.

**Common errors:**

- **Your output has quotes around every value and the next command chokes**: Add `-r`. It only affects top-level strings, so `@tsv` output comes through as real tabs.
- **jq: error: $var is not defined**: You referenced a variable without declaring it. Pass it with `--arg name value`, and remember the filter refers to it as `$name`.

**Advanced notes:**

- **@sh for building shell commands:** `@sh` quotes a value for safe use inside a shell command, so `jq -r '.[] | "rm \(.path | @sh)"'` produces lines that survive spaces and quotes in filenames. It is the correct tool when generating commands, and much safer than hoping no path contains a space.

## Aggregating

Turning many values into one, with reduce, group_by, and the arithmetic builtins.

### reduce, foreach, and the shortcuts

Accumulating across a stream, plus the builtins that cover the common cases without a reduce.

**Keywords:** reduce, foreach, group_by, add

#### Use the builtin before reaching for reduce

```bash
jq '[.[].bytes] | add'                <<< '[{"bytes":10},{"bytes":32}]'  # 42
jq '[.[].cpu]   | add / length'       <<< '[{"cpu":40},{"cpu":60}]'      # 50
jq '[.[].cpu]   | max'                <<< '[{"cpu":40},{"cpu":60}]'      # 60
jq -c 'group_by(.az) | map({az: .[0].az, n: length})' instances.json
jq -c 'map(.type) | unique'           instances.json
```

_output_
```text
42
50
60
[{"az":"eu-west-1a","n":3},{"az":"eu-west-1b","n":1}]
["m5.large","t3.medium"]
```

`add` sums, `group_by` buckets after sorting by the key, and `unique` sorts and dedupes. Most aggregation you need is already a builtin.

- `add` on an empty array returns `null`, not `0`. Write `(add // 0)` when the array can be empty.
- `group_by` sorts first, so the buckets come out in key order and the cost is a sort. `unique_by(f)` is the same idea for dedup.

#### reduce, when you genuinely need an accumulator

```bash
# total bytes per host, in one pass
jq -c 'reduce .[] as $row ({};
         .[$row.host] += $row.bytes
       )' traffic.json
```

_input_
```json
[{"host":"a","bytes":10},{"host":"b","bytes":5},{"host":"a","bytes":7}]
```

_output_
```json
{"a":17,"b":5}
```

`reduce STREAM as $var (INIT; UPDATE)` runs UPDATE once per value with `.` bound to the accumulator, and emits only the final result.

- `+=` on a missing key works because `null + 10` is `10` in jq. That is what makes this pattern short.
- `foreach` is the same form but emits the accumulator after *every* step, which is how you produce running totals.

#### A running total, with foreach

```bash
jq -r 'foreach .[] as $r (0; . + $r.bytes; "\($r.host)\t\(.)")' traffic.json
```

_output_
```text
a	10
b	15
a	22
```

`foreach` takes a third argument, the extract expression, which decides what gets emitted at each step.

- Use `reduce` for a single answer and `foreach` for a per-step trace. Reaching for `foreach` and discarding all but the last value is a `reduce`.

**Best practices:**

- Try `add`, `group_by`, `unique_by`, `min_by` and `max_by` before writing a `reduce`. They are shorter and harder to get wrong.
- Guard sums that can see an empty array with `// 0`.

**Common errors:**

- **reduce produces null**: The UPDATE expression is not returning the accumulator. Every branch of it has to produce the new accumulator value, so an `if` without an `else` will silently break it.
- **jq: error: null (null) and number (1) cannot be added**: Reversed operands. `null + 1` works, `1 + null` also works, but `null | .x + 1` fails because `.x` on null is null and the *left* side of your real expression was not what you thought. Print the intermediate value.

**Advanced notes:**

- **Streaming very large documents:** jq loads each input document fully into memory. For a multi-gigabyte file, `--stream` converts the parse into a stream of `[path, value]` events that you can filter without holding the whole document. It is much harder to write, and it is the only option when the file does not fit in RAM. `jq --stream -n 'fromstream(1 | truncate_stream(inputs))'` is the usual starting point.

## In real pipelines

jq with curl, with the AWS CLI, in shell scripts, and in CI.

### With curl and HTTP APIs

Handling paginated APIs, JSON Lines, and error responses.

**Keywords:** curl, api, pagination, jsonl

#### Filter an API response

```bash
curl -sS https://api.github.com/repos/jqlang/jq/releases \
  | jq -r '.[] | select(.prerelease == false)
               | "\(.tag_name)\t\(.published_at[:10])"' \
  | head -5
```

_output_
```text
jq-1.7.1	2023-12-13
jq-1.7	2023-09-07
```

`-sS` keeps curl quiet but still reports errors. `[:10]` slices the first ten characters off the timestamp, since string slicing works the same way as array slicing.

- Add `--fail` to curl so an HTTP 500 becomes a non-zero exit instead of an error document that jq then fails to parse.
- jq on an HTML error page gives "parse error: Invalid numeric literal", which is confusing until you realise it is reading `<html>`.

#### Walk a paginated API

```bash
url='https://api.example.com/items?limit=100'
while [ -n "$url" ]; do
  body=$(curl -sS --fail "$url")
  printf '%s' "$body" | jq -c '.items[]'
  url=$(printf '%s' "$body" | jq -r '.next // empty')
done
```

`// empty` is the trick: when `.next` is absent, jq emits nothing, `$url` becomes the empty string, and the loop ends.

- `empty` emits zero values, so with `-r` the command substitution captures nothing. `// null` would print the string "null" and loop forever.
- Output is JSON Lines, one item per line, which streams into `while read` or straight into a file.

#### Read JSON Lines back in

```bash
# one document per line: jq handles this natively
jq -r 'select(.level == "error") | .msg' app.jsonl

# to aggregate across all lines, slurp them into an array first
jq -s 'group_by(.level) | map({(.[0].level): length}) | add' app.jsonl
```

_output_
```json
{"error":12,"info":840,"warn":31}
```

Without `-s`, jq applies the filter to each line independently. With it, the whole file becomes one array first.

- Structured application logs are almost always JSON Lines, which makes jq the fastest available log query tool on a single host.
- `{(.[0].level): length}` needs the parentheses. A computed object key without them is a syntax error.

**Best practices:**

- Use `curl --fail` so HTTP errors do not reach jq as unparseable bodies.
- Emit JSON Lines from intermediate steps. It streams, it greps, and it survives being cut in half.

**Common errors:**

- **parse error: Invalid numeric literal at line 1, column 2**: jq is being fed something that is not JSON, usually an HTML error page or a shell warning on stdout. Pipe the raw response to `head -c 200` and look at it.
- **The pagination loop never terminates**: You used `// null` or omitted the alternative, so the loop variable is the four-character string "null" rather than empty. Use `// empty` with `-r`.

**Advanced notes:**

- **jq is not a JSON validator:** jq accepts some input a strict parser rejects, and it will happily read a stream of concatenated documents where a validator wants one. If the goal is to *check* a payload rather than transform it, `jq empty file.json` exits non-zero on a parse failure and prints nothing on success, which is the closest thing to a validation mode.

### With the AWS CLI

Where jq fits next to --query, and the AWS-specific shapes worth memorising.

**Keywords:** aws cli, jmespath, query, tags

#### jq or --query

```bash
# --query: JMESPath, evaluated by the AWS CLI, and can reduce the payload
aws ec2 describe-instances \
  --query 'Reservations[].Instances[].[InstanceId,State.Name]' \
  --output text

# jq: full language, runs locally, does everything --query cannot
aws ec2 describe-instances --output json \
  | jq -r '.Reservations[].Instances[]
           | select(.State.Name == "running")
           | [.InstanceId, .InstanceType,
              ((.Tags // []) | from_entries | .Name // "-")]
           | @tsv'
```

_output_
```text
i-0abc123	t3.medium	web-1
i-0def456	m5.large	worker-2
```

`--query` is filtering on the way out, which matters on large accounts. jq is the one that can interpolate, compute, and default a missing tag.

- The CLI follows pagination for you and returns one merged document, so jq sees every page. A hand-rolled curl loop does not.
- Combining them is normal: `--query` to drop the fields you will never need, jq to shape what is left.

#### The AWS tag shape, once and for all

```bash
# AWS gives:  [{"Key":"Name","Value":"web-1"},{"Key":"env","Value":"prod"}]
# you want:   {"Name":"web-1","env":"prod"}
jq -c '.Reservations[].Instances[] | (.Tags // []) | from_entries' out.json

# find every resource missing a required tag
jq -r '.Reservations[].Instances[]
       | select((.Tags // []) | from_entries | has("owner") | not)
       | .InstanceId' out.json
```

_output_
```text
i-0def456
i-0ghi789
```

`from_entries` understands AWS's capitalised `Key`/`Value` pairs as well as the lowercase form, so no renaming step is needed.

- Always `(.Tags // [])`. An untagged resource has no `Tags` key at all, and `.Tags | from_entries` on null is an error.
- This one-liner is the basis of most tag-compliance checks people write a whole tool for.

#### Chain into another AWS command

```bash
# stop every instance tagged env=dev, safely
aws ec2 describe-instances \
  --filters 'Name=tag:env,Values=dev' 'Name=instance-state-name,Values=running' \
  --output json \
  | jq -r '.Reservations[].Instances[].InstanceId' \
  | xargs -r -n 20 aws ec2 stop-instances --instance-ids
```

Server-side `--filters` first, jq to extract the IDs, `xargs` to batch them within the API limit.

- `xargs -r` does nothing when the input is empty, which stops the command running with no arguments and failing confusingly.
- Prefer `--filters` over filtering in jq when the API supports it. It is faster, cheaper, and does not depend on your filter being right.

**Best practices:**

- Use `--filters` or `--query` to reduce the payload server-side, then jq for shaping.
- Guard every `.Tags` access with `// []`, since absent tags are the normal case, not the edge case.

**Common errors:**

- **jq: error: Cannot iterate over null (null) when reading .Tags[]**: The resource has no tags. Use `(.Tags // [])[]` or `.Tags[]?`.
- **The AWS CLI prints your jq filter as an error about an unknown argument**: A missing quote or a stray backslash ended the aws command early. Put the jq program in single quotes on its own line after the pipe.

**Advanced notes:**

- **Exit status inside a script:** jq exits 0 when a filter matches nothing, so `if aws ... | jq ...; then` is always true. `-e` changes this: jq exits 1 when the last output value was `null` or `false`, and 4 when there was no output at all. In a `set -e` script or a CI step, `-e` is the difference between a real check and one that always passes.
