Get the Zoom link
Cloud Security Office Hours Banner

jq and JMESPath, hands-on

Cloud security is JSON wrangling. Two query languages cover almost all of it, they look alike, and they share nothing. Learn both properly, including the traps that return an empty result instead of an error.

Jump to the walk-through All how-to guides

· · Vendor-neutral

Time: ~90 minutes  ·  Difficulty: Beginner  ·  You need: jq and a terminal. No cloud account: the lab uses a sample file.

The two query languages you will use every single day

Cloud security work is, mechanically, JSON wrangling. Every CLI returns JSON. Every API returns JSON. IAM policies are JSON, CloudTrail events are JSON, Terraform plans are JSON, and every scanner you run emits JSON. The bottleneck between "the data exists" and "I know the answer" is almost always a query you could not quite write.

There are two languages worth knowing, and confusing them is the single most common source of wasted time here, because they look similar and share nothing.

Google Cloud is the odd one out and uses neither: gcloud has its own --filter and --format syntax. Knowing that up front saves you trying JMESPath against gcloud and concluding you have forgotten how to write it.

On this page

  1. Streams versus projections
  2. Walk-through
  3. Hands-on exercises
  4. Common mistakes
  5. Where next

Streams versus projections

Almost every jq confusion traces back to one idea. jq filters produce a stream, not a value.

echo '{"a":[1,2,3]}' | jq '.a'      # one output: the array  [1,2,3]
echo '{"a":[1,2,3]}' | jq '.a[]'    # THREE outputs: 1, then 2, then 3
echo '{"a":[1,2,3]}' | jq '[.a[]]'  # collect the stream back into one array

Those square brackets in the third line are not decoration. Any time a jq expression produces something that looks almost right but repeated, or almost right but unwrapped, the answer is usually a missing pair of collecting brackets. select(), map(), group_by() and friends all become obvious once you know which of them expect a stream and which expect an array.

JMESPath has no equivalent concept. An expression takes one document and returns one JSON value, always. That constraint is why it is safe to embed in a CLI flag and why it is considerably less capable: there is no way to express "emit a line per element" because there are no lines and no emitting.

Walk-through

1. Build the sample data

This is a trimmed CloudTrail-shaped file. Everything below runs against it, so no cloud account and no credentials are needed.

mkdir -p ~/jq-lab && cd ~/jq-lab

cat > events.json <<'EOF'
{"Records":[
 {"eventTime":"2026-08-20T14:03:11Z","eventName":"AssumeRole","errorCode":null,
  "sourceIPAddress":"203.0.113.14",
  "userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/alice"}},
 {"eventTime":"2026-08-20T14:03:12Z","eventName":"GetObject","errorCode":"AccessDenied",
  "sourceIPAddress":"198.51.100.7",
  "userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/bob"}},
 {"eventTime":"2026-08-20T14:05:40Z","eventName":"PutBucketPolicy","errorCode":null,
  "sourceIPAddress":"10.0.4.19",
  "userIdentity":{"type":"AssumedRole","arn":"arn:aws:sts::111122223333:assumed-role/deploy/i-123"}},
 {"eventTime":"2026-08-20T14:06:02Z","eventName":"ListBuckets","errorCode":"AccessDenied",
  "sourceIPAddress":"203.0.113.99",
  "userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/mallory"}},
 {"eventTime":"2026-08-20T14:06:03Z","eventName":"ListBuckets","errorCode":"AccessDenied",
  "sourceIPAddress":"203.0.113.99",
  "userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/mallory"}}
]}
EOF

2. jq: navigate, then filter

jq '.Records | length' events.json          # 5

# .Records[] streams each record; select() passes through only the
# ones where its condition is true and emits nothing for the rest.
jq '.Records[] | select(.errorCode == "AccessDenied") | .userIdentity.arn' events.json

# -r = raw output: print strings unquoted, which is what you want
# the moment the output feeds another command.
jq -r '.Records[] | select(.errorCode == "AccessDenied") | .userIdentity.arn' events.json

select() is the workhorse. Read select(cond) as a gate in the pipe: the value goes through unchanged if cond is true, and vanishes otherwise.

3. jq: build new objects

# Construct an object per record. The keys are yours to name.
jq '[.Records[]
     | select(.errorCode != null)
     | {when: .eventTime, what: .eventName, who: .userIdentity.arn, from: .sourceIPAddress}]' \
   events.json

Reshaping at the edge of a pipeline matters more than it looks. A downstream script that consumes {who, what, when} keeps working when AWS adds fields, moves things around, or you switch to a different source of the same facts.

4. jq: the query you will actually run

"Who is generating repeated access denials, and from where." This is a real triage question and it is four operators.

jq -r '
  [.Records[] | select(.errorCode != null)]      # collect failures into an array
  | group_by(.userIdentity.arn)                  # array of arrays, one per principal
  | map({arn: .[0].userIdentity.arn, denials: length})
  | sort_by(-.denials)                           # negate to sort descending
  | .[] | "\(.denials)\t\(.arn)"                 # \(...) is string interpolation
' events.json

Note the shape change at each step: stream, array, array of arrays, array of objects, then back to a stream of strings for printing. group_by requires a sorted array and sorts for you, and it hands each group to map as a list, which is why .[0] is how you get at the shared key.

5. jq: emit something another tool can read

# @csv quotes and comma-joins an array. Pair it with -r or you get
# a JSON string containing escaped quotes, which no spreadsheet wants.
jq -r '.Records[]
       | select(.errorCode != null)
       | [.eventTime, .eventName, .sourceIPAddress, .userIdentity.arn]
       | @csv' events.json

# @tsv, @sh (shell-quoted), @base64, and @uri exist too. @sh is the one
# that saves you when a value contains a space or a quote.
jq -r '[.Records[].sourceIPAddress] | unique | join(", ")' events.json

6. JMESPath: the same questions, different language

Everything below works as an AWS CLI --query value. To practise without a cloud account, evaluate against the same file:

pip3 install jmespath        # brings the reference implementation

python3 - <<'PY'
import json, jmespath
doc = json.load(open('events.json'))
print(jmespath.search("Records[?errorCode=='AccessDenied'].userIdentity.arn", doc))
print(jmespath.search("length(Records[?errorCode!=`null`])", doc))
print(jmespath.search(
    "Records[?errorCode!=`null`].{when: eventTime, what: eventName, who: userIdentity.arn}", doc))
PY

Three pieces of syntax carry most of the weight:

Double quotes in JMESPath do not mean what you think. "ListBuckets" is a quoted identifier, that is, a field name, not a string. So [?eventName == "ListBuckets"] compares the event name against the value of a field called ListBuckets, which does not exist, and returns an empty list. No error, no warning, just nothing. Use single quotes for strings, always.

7. Against a real AWS CLI

# Public S3 buckets is not one call, so this is the shape you want:
# a client-side query per bucket, driven by a jq-produced list.
aws s3api list-buckets --query 'Buckets[].Name' --output text

# EC2 instances without IMDSv2 enforced.
aws ec2 describe-instances \
  --query 'Reservations[].Instances[?MetadataOptions.HttpTokens!=`required`].[InstanceId,PrivateIpAddress]' \
  --output text

# IAM users whose access keys are old. --filters is SERVER side; --query
# is CLIENT side. Mixing them up matters, see below.
aws ec2 describe-instances \
  --filters 'Name=instance-state-name,Values=running' \
  --query 'Reservations[].Instances[].InstanceId'

--query is client-side, and that is a security-relevant fact, not a performance footnote. The API returns every record and the CLI throws away what your query excluded. You are still billed for the calls, your CloudTrail still records that you read everything, and --query grants you nothing and restricts you from nothing. If you need the API itself to return less, that is --filters on the services that support it, and it is a different flag with different syntax.

8. The combination that gets used most

In practice the strongest move is not choosing between them. It is --output json piped into jq, using JMESPath only for the trivial cases.

# --output text collapses nulls to the string "None" and flattens nested
# lists with tabs, so columns silently misalign the moment a field is
# empty. For anything you intend to parse, take JSON and use jq.
aws iam list-users --output json \
  | jq -r '.Users[] | [.UserName, .CreateDate] | @tsv'

Hands-on exercises

Exercise 1

Using events.json, print one line per distinct source IP address, showing the IP and the number of events from it, most active first.

Show the answer
jq -r '
  [.Records[]]
  | group_by(.sourceIPAddress)
  | map({ip: .[0].sourceIPAddress, n: length})
  | sort_by(-.n)
  | .[] | "\(.n)\t\(.ip)"
' events.json

If your first attempt produced the counts but in the wrong order, you probably wrote sort_by(.n) | reverse, which is also correct. sort_by(-.n) is shorter and is the idiom you will see in other people's filters.

If it produced nothing at all, check whether you dropped the collecting brackets around .Records[]. group_by needs an array; handed a stream it operates on each element separately and every group has exactly one member.

Exercise 2

Translate this jq filter into JMESPath: .Records[] | select(.userIdentity.type == "IAMUser") | .userIdentity.arn. Then say what is different about the output.

Show the answer
Records[?userIdentity.type=='IAMUser'].userIdentity.arn

Note the single quotes. Double quotes would make IAMUser a field name and silently return an empty list.

The output difference is the whole distinction between the two languages. jq emits four separate JSON strings, one per line, which a shell loop can consume directly. JMESPath returns a single JSON array. That is why --query pairs with --output text so often: the CLI has to flatten the array back into lines for you, because the language cannot.

Exercise 3

A colleague reports that this command "returns nothing, so we have no running instances". They are looking at an account with forty running instances. What is wrong?

aws ec2 describe-instances --query 'Reservations[].Instances[?State.Name == "running"].InstanceId'
Show the answer

"running" is a quoted identifier, so the filter compares State.Name against a field named running, finds nothing there, and the comparison is never true. The fix is 'running', and in a shell that means escaping or switching the outer quoting:

aws ec2 describe-instances \
  --query "Reservations[].Instances[?State.Name == 'running'].InstanceId"

Worth sitting with the failure mode rather than just the fix. An empty result and a correct result look identical from the outside, and the reasonable interpretation of "no output" is "no matching resources". Before believing any empty result from a query language, run the query with the filter removed and confirm the data is there at all. Confirm the instrument works before trusting what it says is absent.

Exercise 4

Write a jq filter that finds any IAM policy statement granting "Action": "*", handling the fact that Action may be a string or an array of strings. Test it against both shapes.

Show the answer
# The trick is to normalise first. In jq, wrapping a value in an array
# and flattening is the idiomatic "make this a list either way".
cat > policy.json <<'EOF'
{"Statement":[
  {"Effect":"Allow","Action":"*","Resource":"*"},
  {"Effect":"Allow","Action":["s3:GetObject","s3:PutObject"],"Resource":"*"},
  {"Effect":"Allow","Action":["iam:*","*"],"Resource":"*"}
]}
EOF

jq -r '.Statement[]
       | select(.Effect == "Allow")
       | select([.Action] | flatten | index("*"))
       | "wildcard action: \(.Action)"' policy.json

[.Action] | flatten turns both a bare string and an array into a flat array, and index("*") returns the position of the first match or null. select() treats null as false, so the statement passes through only when the wildcard is genuinely present.

The equivalent normalise-then-compare pattern appears in the Rego page for the same reason: the IAM document schema is polymorphic in several places, and any tool that reads it has to handle both shapes or it will miss findings on whichever shape it did not consider.

Common mistakes

Where next