OnCallReady

Lesson 22.4 · Azure I: CLI, Identity & Data Planes · 19 min read

JMESPath: --query properly

In plain words

Imagine asking a librarian: "From all the books, give me only the ones about dogs, and for each one just the title and the author." You don't want the whole catalogue, just a filtered, trimmed list. But the librarian is very literal: say "dogs" in the wrong kind of quotes and she thinks you mean a shelf called Dogs, finds nothing, and hands you an empty list without complaining.

JMESPath is how you ask az that question with --query. [?location=='westeurope'] filters, .name picks a field, {Name:name, Count:count} builds your own columns. The literal part: single quotes are strings, double quotes are field names, backticks are numbers. Get them wrong and you get nothing back, with no error.

"Which node pools have more than two nodes?" "Which resource groups have no owner tag?" The answer is somewhere in a big JSON document, and you want just that piece, in one command, the same way every time. --query does it.

What you need to know already: 22.3 (az output formats, tsv), 7.11 (JSON: objects, lists, fields; jq filters).

--query takes a JMESPath expression and evaluates it over the JSON before printing. JMESPath is a small query language for JSON - the same job as jq (Ch 7), different syntax, built into az. It is small, and the half that people skip is the half that bites.

A reminder of two JSON words: an object is { "key": value, ... }, a list (array) is [ value, value, ... ]. A field is one key of an object.

Fields and indexes

name                       one field
networkProfile.networkPlugin    nested field (a field inside a field)
agentPoolProfiles[0]       first element of a list (counting from 0)
agentPoolProfiles[-1]      last element
agentPoolProfiles[0:2]     a slice: elements 0 and 1
$ az aks show -g rg-oncall-lab -n aks-sysop --query networkProfile.networkPlugin -o tsv
azure

Projections: [] and [*]

[] (flatten) or [*] (wildcard) over a list starts a projection: the rest of the expression runs once per element, and nulls are dropped. Think of it as "for each element, take ...".

$ az aks show -g rg-oncall-lab -n aks-sysop --query "agentPoolProfiles[].name"
[
  "system",
  "user"
]

"For each node pool profile, take its name."

List commands already return a list, so their queries start with [] or [?:

az group list --query "[].name" -o tsv

Filters: [?condition]

[?condition] keeps only the elements where the condition is true - like jq's select.

$ az group list --query "[?location=='westeurope'].name" -o tsv

"For each group whose location is westeurope, take its name."

The quoting is the whole trick. In JMESPath:

'westeurope'    a string literal (raw string)
"westeurope"    a FIELD called westeurope (quoted identifier)
`2`             a JSON literal: the number 2

So [?location=="westeurope"] compares location with a field that does not exist, gets null, matches nothing, and prints nothing, without an error. It is the single most common --query bug. Wrap the whole query in double quotes for bash and use single quotes inside - or the other way round.

Numbers need backticks:

$ az aks nodepool list -g rg-oncall-lab --cluster-name aks-sysop --query '[?count > `2`].name' -o tsv
user

[?count > '2'] compares a number with a string, which is null in JMESPath, so it silently matches nothing. And a bare 2 without backticks is a parse error. Because of the backticks, put numeric filters in single quotes so bash does not treat them as command substitution (bash runs whatever is between backticks as a command, like $( )).

Combine conditions with && (and), || (or), ! (not):

--query "[?mode=='User' && enableAutoScaling].name"
--query "[?!(tags.env=='prod')].name"

A field on its own (enableAutoScaling) counts as true when it is true.

Multiselect: build your own rows

$ az aks show -g rg-oncall-lab -n aks-sysop --query "agentPoolProfiles[].{Name:name, Count:count, Size:vmSize, Spot:scaleSetPriority}" -o table
Name    Count  Size              Spot
------  -----  ----------------  ----
system  2      Standard_D4ds_v5
user    3      Standard_D8ds_v5

{Key:expr, ...} (a hash multiselect) makes a new object per element with the keys you name; [a, b] (a list multiselect) makes a list, which is what you want for tsv. With --query, the table uses your keys (first letter capitalised: name becomes Name) in your order. This is the way to get nested fields into a table. (Spot is empty because neither pool uses spot VMs - Ch 23.)

Functions

length(@)                          how many
contains(name, 'shop')             substring, or list membership
starts_with(name, 'MC_')
sort_by(@, &name)                  & makes an expression reference
max_by(@, &count).name
join(', ', [].name)
to_number(pretaxCost)              strings to numbers

@ is the current value - in a list command, the whole result list. &name is an expression reference: you hand sort_by the expression name to evaluate on each element, instead of its value now.

$ az group list --query "[?starts_with(name, 'MC_')] | length(@)"
2

"Keep the groups whose name starts with MC_, then count them." (The MC_ groups are created by Azure for each cluster's own machines - Ch 23.)

Pipes stop projections

This is the second-most common bug:

[?starts_with(name,'rg-')].name[0]      # [0] applies to EACH name: "r", "r", ...
[?starts_with(name,'rg-')].name | [0]   # first of the filtered list: "rg-oncall-lab"

A projection keeps going through every later .x and [n]. | ends it and applies the right-hand side to the whole result. Whenever you want "the first one" after a filter, you want | [0].

A few real ones

# the client ID of the cluster's kubelet identity (lesson 22.9)
az aks show -g rg-oncall-lab -n aks-sysop --query identityProfile.kubeletidentity.clientId -o tsv

# roles a principal holds, anywhere (lesson 22.13)
az role assignment list --all --query "[?principalId=='<oid>'].roleDefinitionName" -o tsv

# every subnet with its address range, sorted
az network vnet subnet list -g rg-oncall-lab --vnet-name vnet-sysop \
  --query "sort_by(@, &name)[].[name, addressPrefix]" -o tsv

# resource groups without an owner tag
az group list --query "[?tags.owner == null].name" -o tsv

Tags are free-form key=value labels you put on resources (env=prod, owner=team-shop); Azure itself ignores them, people and cost reports use them.

When not to use it

JMESPath cannot group or sum across items (no "sum cost by service"). That is a job for -o tsv | awk (chapter 7) or jq. Use each for what it is good at.

What you can now do

Why it helps

Most of the useful Azure one-liners you'll write depend on --query: the kubelet identity's client ID, every role a principal holds, unattached disks, resource groups without an owner tag. A precise query turns a 300-line JSON dump into the one line you need during an incident.

Its failures are silent, which makes them dangerous in scripts: a filter with double-quoted strings matches nothing, a cleanup script sees an empty list and reports success. Knowing the quoting rules, that numbers need backticks, and that | [0] is needed to get the first item after a filter, saves you from scripts that quietly do nothing, or worse, act on the wrong thing.

Commands in this lesson

az

FAQ

Why does my filter return nothing?

Almost always quoting. In JMESPath 'westeurope' is a string literal, but "westeurope" is a field name, so [?location=="westeurope"] compares location with a non-existent field, gets null, and matches nothing without an error. Numbers need backticks, which make them JSON literals:

[?count > `2`]

Comparing a number with a string gives null too. Wrap the whole query in double quotes for bash and use single quotes inside, or single quotes outside when you need backticks.

What is a projection?

[] or [*] over a list starts a projection: everything after it is applied to each element, and null results are dropped. agentPoolProfiles[].name gives the name of each pool. The projection continues through every following .field and [n], which is why [?...].name[0] gives the first character of each name rather than the first name.

How do I get the first item after a filter?

Use a pipe: [?starts_with(name,'rg-')].name | [0]. The pipe ends the projection and applies [0] to the whole filtered list, giving the first name. Without the pipe, [0] is applied inside the projection, to each name, and you get the first character of each. Whenever you want "the first one" or length(@) after a filter, you want |.

How do I show nested fields in a table?

Build the rows yourself with a multiselect hash: --query "agentPoolProfiles[].{Name:name, Count:count, Size:vmSize}" -o table. With --query, the table uses your keys as column headers in your order, and nested values like networkProfile.networkPlugin can be pulled up into a column. For tsv, use a multiselect list [name, count] instead, which gives predictable column order.

What can't JMESPath do?

Aggregation and grouping, like summing cost per service or counting resources per region, and anything that needs variables or complex logic. It selects, filters, reshapes, sorts and has a handful of functions such as length, contains, starts_with, sort_by, max_by and join. For grouping, pipe -o tsv into sort | uniq -c or awk, or use jq or Python.

In an interview Mid

How would you list the names of all resource groups in one region with the Azure CLI, and what mistakes do people make with --query?

az group list --query "[?location=='westeurope'].name" -o tsv

[?condition] filters the list, .name projects each element, -o tsv prints bare names.

The silent mistakes (no error, just no output):

az aks nodepool list -g rg-oncall-lab --cluster-name aks-sysop --query '[?count > `2`].name' -o tsv

For your own table: --query "[].{Name:name, Count:count}" -o table - the way to get nested fields into a table. JMESPath cannot group or sum; use -o tsv | awk or jq for that.

Also asked: Explain the difference between [?filter].name[0] and [?filter].name | [0] in JMESPath. · How do you show nested fields of az output in a table? · When would you use jq or awk instead of --query?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.