Scripts that work on one subscription often fail on fifty. They run slowly, get throttled, miss resources because nobody followed nextLink, or break because a JMESPath filter returned an empty list without an error. This cheat sheet collects the patterns that hold up at fleet scale.
Ground rules
| Rule | Why |
|---|---|
Use az graph query for inventory, not az <service> list per subscription | One call covers every subscription you can read, and the data is already indexed |
Use az rest when a command group lacks a property or API version | You get the raw API and can pin the api-version |
Run --debug once to see the REST call any command makes | Shows the URL and api-version to reuse in az rest |
Pin output: -o tsv for loops, -o json plus jq for structure | Table output is for people and gets truncated |
Add --only-show-errors in pipelines | Warnings on stderr confuse parsers and log scanners |
| Never rely on list order | CLI output is not guaranteed to be ordered; sort explicitly |
az config set core.only_show_errors=true
az config set extension.use_dynamic_install=yes_without_prompt
JMESPath that works
| Need | Expression |
|---|---|
| Rename and pick fields | [].{name:name, rg:resourceGroup, size:hardwareProfile.vmSize} |
| Filter on a value | [?location=='westeurope'].name |
| Filter on a boolean | [?isDefault].id and [?!isDefault].id |
| Substring / prefix / suffix | [?contains(name, 'prod')], [?starts_with(name, 'vm-')], [?ends_with(id, '-01')] |
| Combine conditions | [?location=='westeurope' && tags.env=='prod'] |
| Sort | sort_by([], &name)[].name |
| Count | length([?powerState=='VM running']) |
| Filter after projecting | [].{n:name, sku:sku.name} | [?sku=='Standard_LRS'] |
| Tag with special characters | [].tags."cost-center" |
| Join to one string | join(',', [].name) |
| Missing value instead of error | Fields that don't exist return null; filter with [?tags.owner == null] |
Quoting traps:
- String literals in filters take single quotes. Double quotes inside a predicate are treated as an identifier and match nothing, without an error.
- Number and boolean literals need backticks:
[?diskSizeGb > `512`]. In Bash, escape them inside double quotes or put the whole query in single quotes. In PowerShell, double the backticks:``512``. - PowerShell treats
|and&inside double quotes as plain characters, which is fine. Avoid$inside double quotes, because PowerShell expands it.
# Disks over 512 GiB that are unattached, sorted by size (Bash)
az disk list --query 'sort_by([?diskState==`Unattached` && diskSizeGb > `512`], &diskSizeGb)[].{name:name, rg:resourceGroup, gb:diskSizeGb}' -o table
az rest against ARM
# Relative resource ID: the ARM endpoint and {subscriptionId} are filled in for you
az rest --method get \
--url "/subscriptions/{subscriptionId}/resourceGroups/rg-app/providers/Microsoft.Web/sites/app-orders?api-version=2023-12-01" \
--query "properties.siteConfig"
# PATCH one property without touching the rest (Bash quoting)
az rest --method patch \
--url "https://management.azure.com/subscriptions/$SUB/resourceGroups/rg-app/providers/Microsoft.Storage/storageAccounts/stapp01?api-version=2023-05-01" \
--body '{"properties":{"allowSharedKeyAccess":false}}'
# Body from a file, query string parameters separately
az rest --method put --url "$ID?api-version=2023-05-01" --body @payload.json
az rest --url "https://management.azure.com/subscriptions/$SUB/resources" \
--url-parameters api-version=2021-04-01 '$top=100'
Notes:
Content-Type: application/jsonis set automatically when--bodyis valid JSON. Add--headersfor anything else.- The token audience is chosen from the URL host when it is a known Azure endpoint. For other hosts, pass
--resource <audience>. --output-filewrites the raw response, which is useful for binary or very large payloads.- Check the API version a resource type supports with:
az provider show -n Microsoft.Web --query "resourceTypes[?resourceType=='sites'].apiVersions[0:3]".
az rest against Microsoft Graph
# App registrations and the expiry dates of their client secrets (first page only, see pagination below)
az rest --url "https://graph.microsoft.com/v1.0/applications?\$select=displayName,appId,passwordCredentials" \
--query "value[].{app:displayName, id:appId, expires:passwordCredentials[].endDateTime}"
The caller needs the right Graph permissions. Signing in with az login as a user gives the CLI's own delegated consent. For automation, use a managed identity or a workload identity with application permissions granted.
Pagination: follow nextLink yourself
Do not assume az rest returns everything. ARM list APIs return a nextLink, and Graph returns @odata.nextLink. Loop until it is empty:
url="https://management.azure.com/subscriptions/$SUB/resources?api-version=2021-04-01"
while [ -n "$url" ]; do
page=$(az rest --url "$url" -o json)
echo "$page" | jq -r '.value[] | [.name, .type, .location] | @tsv'
url=$(echo "$page" | jq -r '.nextLink // empty')
done
$url = "https://graph.microsoft.com/v1.0/servicePrincipals?`$select=displayName,appId&`$top=999"
$all = while ($url) {
$page = az rest --url $url -o json | ConvertFrom-Json
$page.value
$url = $page.'@odata.nextLink'
}
$all.Count
Resource Graph at scale
az graph query -q "Resources
| where type =~ 'microsoft.compute/virtualmachines'
| extend size = tostring(properties.hardwareProfile.vmSize)
| summarize count() by size, location
| order by count_ desc" --first 1000 -o table
--first accepts 1-1000. For larger result sets, page with the skip_token the CLI returns:
token=""
while :; do
if [ -z "$token" ]; then
out=$(az graph query -q "Resources | project id, type, location | order by id asc" --first 1000 -o json)
else
out=$(az graph query -q "Resources | project id, type, location | order by id asc" --first 1000 --skip-token "$token" -o json)
fi
echo "$out" | jq -r '.data[] | [.id, .type, .location] | @tsv'
token=$(echo "$out" | jq -r '.skip_token // empty')
[ -z "$token" ] && break
done
Use order by on a stable column (such as id) when paging, so results come back in a consistent order. Scope the query with --management-groups <mg> --allow-partial-scopes to cover large hierarchies without failing on one subscription you can't read. Resource Graph data can lag by a short time after a write. For decisions made immediately after a change, read the resource directly.
Bulk actions with --ids
Most commands accept --ids with many resource IDs and run them in one invocation:
# Deallocate every running VM tagged env=dev, from one Resource Graph query
ids=$(az graph query -q "Resources
| where type =~ 'microsoft.compute/virtualmachines' and tags.env =~ 'dev'
| where properties.extended.instanceView.powerState.code =~ 'PowerState/running'
| project id" --first 1000 --query "data[].id" -o tsv)
[ -n "$ids" ] && az vm deallocate --ids $ids --no-wait
Caution
Bulk commands built from a query act on whatever the query returns. Run the query on its own first, check the count and a sample, and keep
--no-waitbulk deletes out of unattended jobs unless there is an approval step. An empty or wrong tag filter can select the whole fleet.
Throttling and parallelism
- ARM throttles per principal and per subscription. Splitting work across subscriptions in parallel is usually safe; hammering one subscription is not.
- Use bounded parallelism such as
xargs -P 4, not unbounded background jobs. - On 429, back off.
az restreturns a non-zero exit code. Retry with an increasing delay, and honourRetry-Afterif you call the API yourself. - Prefer one Resource Graph query over many
showcalls. Resource Graph has its own quota, which is reported in response headers, so check the current limits for heavy reporting jobs.
az account list --query "[?state=='Enabled'].id" -o tsv |
xargs -P 4 -I{} sh -c 'az vm list --subscription {} --query "length(@)" -o tsv | sed "s/^/{} /"'
Prevent it next time
- Keep a shared library of tested queries and pagination helpers instead of ad-hoc one-liners in every runbook.
- Run fleet scripts as a managed identity with Reader at the management group, and grant write roles only to the jobs that need them.
- Make every bulk script support a dry run that prints the target IDs, and make dry run the default.
- Pin
api-versioninaz restcalls and review them when the API versions in use are retired.