Someone in the security review asks a question that sounds trivial: “How many public IPs do we have across all subscriptions, and which ones are attached to nothing?” You open the portal, change subscription in the top filter, count, switch subscription, count again — and forty minutes later you have a number you don’t trust, because the portal’s resource list is paginated, scoped to one subscription at a time, and silently filtered by what your eyes can hold. Multiply that by every “just a quick inventory question” you get in a week and you have lost a day to clicking. Azure Resource Graph exists precisely to kill this work: it is a read-only, sub-second query service that indexes the Azure Resource Manager (ARM) metadata of every resource you can see across every subscription, and lets you ask questions of it in KQL (Kusto Query Language) — the same language as Log Analytics, but here pointed at your inventory, not your logs.
This is a cookbook, not a tour. You will write real queries you can paste today: count resources by type and region, find every untagged or mistagged resource, list VMs by size and power state, find public IPs with no association, surface storage accounts that still allow blob-public-access, and diff what is deployed against what your tagging standard says should be there — configuration drift, the thing nobody notices until an audit. Every query runs three ways — in the Azure portal Resource Graph Explorer, from the az graph CLI, and (for the keepers) saved as a shared query resource you can deploy with Bicep — and the centrepiece is a long, numbered hands-on lab from zero to a saved, scoped drift query.
The mental shift is small but powerful: the portal answers “what is in this blade”; Resource Graph answers “what is true about my estate”. Once you can express an estate-wide question as four lines of KQL and get the answer before your coffee cools, the forty-minute click-hunt never happens again — and the same query becomes a governance guardrail, a cost-cleanup worklist, and an exam answer for the inventory questions on AZ-104 and AZ-305.
What problem this solves
The Azure portal is built around navigation, not interrogation. Its resource list is scoped to a single subscription (or whatever your global filter currently holds), it pages results, and it has no way to express “join VMs to their NICs to their public IPs and show me the orphans.” So the moment a question spans subscriptions, correlates two resource types, or needs an exact count, the portal stops being a tool and becomes a chore. People work around it with brittle scripts: a for loop over az resource list per subscription, parsed with jq, that takes minutes, hammers the ARM API, throws 429 Too Many Requests on a large tenant, and gives a different answer each run because pagination drifts.
What breaks without Resource Graph is trust in your own inventory. Security asks how many storage accounts allow public blob access and the honest answer is “I’ll get back to you.” Finance asks which resources lack a costCenter tag for chargeback and the answer is a spreadsheet that was stale the moment it was pasted. An incident asks “which VMs are in West Europe running an old image” and the on-call engineer hand-counts under pressure. None of these are hard questions — they are hard only because the data was never queryable. Resource Graph makes the data queryable, indexed and fast, so the answer is a saved query away.
Who hits this: anyone responsible for an Azure estate beyond a handful of resources — platform teams policing tags and SKUs, security teams hunting misconfiguration, FinOps teams finding orphaned disks and idle IPs, and SREs who need a fleet view during an incident. It bites hardest in multi-subscription tenants, in environments with a tagging standard nobody can verify, and in any audit where “show me everything that violates rule X, right now” is the literal ask. Resource Graph is the difference between guessing about your estate and knowing.
Learning objectives
By the end of this article you can:
- Explain what Azure Resource Graph indexes, which tables (
resources,resourcecontainers,advisorresources,securityresources) hold what, and how query scope (tenant, management group, subscription) is controlled. - Write core KQL against your estate:
project,where,summarize ... by,extend,order by,count, and the string/JSON functions you need to reach intopropertiesandtags. - Build an inventory cookbook: counts by type/region/subscription, VMs by size and power state, public IPs and disks with no association, and resources by tag value.
- Detect drift: find resources missing a required tag, with the wrong tag value, in a disallowed region, or with an insecure setting — and turn each into a reusable query.
- Use
joinandmv-expandto correlate resource types (VM → NIC → public IP) and to explode array/object properties into rows. - Run every query three ways — portal Resource Graph Explorer,
az graph query, and a saved shared query deployed via Bicep — and page past the 1,000-row default with--first/--skip-token. - Avoid the real traps: stale index lag,
propertiescasing differences across API versions, throttling, and the cross-subscription permission model.
Prerequisites & where this fits
You should be comfortable in the Azure portal and running az in Cloud Shell or locally (az login, az account set). You need at least Reader on the scopes you want to query — Resource Graph never shows you a resource you couldn’t already see in the portal; it is bounded by your RBAC, so an empty result can mean “no such resource” or “you lack Reader there.” A passing familiarity with KQL helps but is not assumed; if you have written a Log Analytics query you already know 80% of what you need, because Resource Graph speaks a subset of KQL (the language is shared; the available operators differ — see the limits table later). You do not need to install anything beyond the resource-graph CLI extension, which az offers to auto-install the first time you run az graph query.
This sits in the Governance & Operations track, downstream of the resource model and upstream of automated enforcement. It assumes you understand the Azure Resource Hierarchy Explained: Subscriptions, Resource Groups and Resources, because scope (management group vs subscription) is how you point a query. It pairs tightly with Azure Resource Tagging Strategy: Drive Cost Allocation and Governance From Day One — a tagging standard is only real if you can audit it, and Resource Graph is the auditor. Where this article finds drift in a read-only way, Azure Policy Effects Decoded: Deny vs Audit vs Modify vs DeployIfNotExists is how you prevent or remediate it; the two are complementary, and the securityresources and policy-compliance tables connect them. Because the same KQL dialect powers monitoring, the muscle you build here transfers directly to Azure Monitor and Application Insights: Full-Stack Observability.
Here is how Resource Graph relates to the tools it is often confused with:
| Tool | Question it answers | Scope | Speed | When to reach for it |
|---|---|---|---|---|
| Resource Graph | “What resources exist and how are they configured?” | Tenant / MG / subscription, all at once | Sub-second | Inventory, drift, fleet-wide config queries |
| Portal resource list | “What is in this blade right now?” | One subscription (global filter) | Interactive | Eyeballing a handful of resources |
az resource list |
“List resources (ARM live read)” | One subscription per call | Slow, throttles at scale | Scripting a small, current read |
| Log Analytics / KQL | “What happened over time? (logs/metrics)” | Workspace(s) | Sub-second to seconds | Telemetry, not inventory |
| Azure Policy compliance | “Which resources violate policy P?” | Assignment scope | Minutes (eval cycle) | Enforcement and remediation |
| Cost Management | “What did this cost?” | Billing scope | Seconds to minutes | Spend, not config |
The one-line rule: Resource Graph is for metadata about resources right now; Log Analytics is for events over time; Cost Management is for money. They share a query language, not a purpose.
Core concepts
Five ideas make every query in this cookbook obvious.
Resource Graph is an indexed, read-only mirror of ARM metadata. Every time you create, change or delete a resource, Resource Manager notifies Resource Graph, which updates a denormalised index of that resource’s type, location, tags, identity, SKU and properties. You query the index, not the live resources, so queries are fast and never mutate anything — there is no az graph command that can change a resource. The index is eventually consistent: a new resource appears within seconds to a couple of minutes, which matters only when you query right after a deployment (the lab calls this out).
Everything lives in tables, and the table you choose is the most important decision. The default table is resources — one row per resource (VMs, storage accounts, NICs, public IPs, everything that is a resource). Containers (subscriptions, resource groups, management groups) live in resourcecontainers; recommendations in advisorresources, security findings in securityresources, policy state under the policy resources provider. Pick the wrong table and your where type == matches nothing. Ninety percent of inventory work is in resources.
KQL flows top-to-bottom through a pipeline. A query is a source table followed by operators chained with the pipe |, each transforming the rows above it: resources | where type == "microsoft.compute/virtualmachines" | project name, location | order by name asc reads like a sentence — take resources, keep only VMs, keep two columns, sort. The four you use constantly are where (filter), project/extend (choose/add columns), summarize (aggregate, almost always with by), and order by.
The interesting data is buried in dynamic columns. type, location, name, id, resourceGroup, subscriptionId are flat strings. But the configuration you want — a storage account’s allowBlobPublicAccess, a VM’s hardwareProfile.vmSize, a public IP’s association — lives inside a JSON blob column called properties (tags live in tags). Reach in with dot notation (properties.hardwareProfile.vmSize) and treat casing carefully, because properties reflects the provider’s own JSON and can differ across API versions. tostring(), toint(), isnull() convert and test those dynamic values.
Scope is RBAC plus an explicit flag. By default a query runs across every subscription you can read in the tenant — the magic (one query, whole estate) and the gotcha (a query “missing” resources usually means you lack Reader there). Narrow it with --subscriptions/--management-groups on the CLI or the portal scope picker. You never see anything your RBAC doesn’t already grant; Resource Graph is a faster lens on the same access, never a privilege escalation.
The vocabulary in one table
Pin these down before the deep sections; the glossary repeats them for lookup.
| Term | One-line definition | Where it shows up |
|---|---|---|
| Resource Graph | Indexed, read-only query service over ARM metadata | az graph, portal Resource Graph Explorer |
| KQL | Kusto Query Language — the pipeline query dialect | Every query you write |
| Table | The dataset you query (resources, resourcecontainers, …) |
First token of a query |
resources |
One row per Azure resource | Default table; most queries |
resourcecontainers |
One row per subscription / RG / MG | Container-level inventory |
type |
The ARM resource type, lower-cased | where type == in nearly every query |
properties |
Dynamic JSON of resource-specific config | properties.<...> drill-downs |
tags |
Dynamic JSON of the resource’s tags | tags['env'], drift queries |
| Scope | The set of subscriptions/MGs queried | --subscriptions, --management-groups |
| Drift | Deployed state diverging from intended state | The whole “find what’s wrong” half |
| Shared query | A saved query as a deployable resource | Microsoft.ResourceGraph/queries |
| Skip token | Cursor to fetch the next page of results | --skip-token, paging past 1,000 |
The Resource Graph data model: tables and columns
Before writing queries, know what you are querying. Choosing the right table and knowing which fields are flat columns versus inside properties is the single biggest source of “why does my query return nothing.” Here are the tables you will actually use:
| Table | One row per | Typical use | Key columns |
|---|---|---|---|
resources |
Any Azure resource | Inventory, drift, config queries | id, name, type, location, resourceGroup, subscriptionId, tags, sku, kind, identity, properties |
resourcecontainers |
Subscription, RG, or MG | Count subs/RGs, find RGs missing tags | id, name, type, subscriptionId, tags, properties |
advisorresources |
An Advisor recommendation | Cost/rightsizing worklists | properties.category, properties.shortDescription, properties.impact |
securityresources |
A Defender assessment/alert | Misconfiguration hunting | properties.status, properties.metadata, type |
policyresources |
A policy assignment/definition/state | Compliance correlation | properties.complianceState, properties.policyAssignmentId |
healthresources |
A resource-health entry | Availability sweeps | properties.availabilityState |
patchassessmentresources |
A patch/update record | Update compliance | properties.* (patch state) |
The columns every resources row has, and where each lives:
| Column | Flat or in properties? |
Example value | Notes |
|---|---|---|---|
id |
Flat | /subscriptions/…/providers/Microsoft.Compute/virtualMachines/vm1 |
Full ARM resource ID; unique key |
name |
Flat | vm1 |
Short name only |
type |
Flat | microsoft.compute/virtualmachines |
Lower-cased in the index — match case-insensitively |
location |
Flat | westeurope |
Region short name |
resourceGroup |
Flat | rg-prod-web |
RG short name |
subscriptionId |
Flat | 1111-… |
GUID; join to resourcecontainers for the name |
tags |
Flat (dynamic) | {"env":"prod"} |
JSON object; index by key |
sku |
Flat (dynamic) | {"name":"Standard_LRS"} |
Present on many but not all types |
kind |
Flat | StorageV2 |
Sub-type discriminator (storage, app) |
identity |
Flat (dynamic) | {"type":"SystemAssigned"} |
Managed identity config |
properties |
The blob itself | {...resource-specific...} |
Everything type-specific lives here |
tenantId |
Flat | aaaa-… |
The tenant the resource belongs to |
Two rules that save hours. First, type is stored lower-case in the index, so where type == "Microsoft.Compute/virtualMachines" can miss rows depending on how you compare — always treat it case-insensitively (use the exact lower-case form, or =~ for case-insensitive equality). Second, properties mirrors the resource provider’s JSON, whose casing and shape can differ by API version, so confirm a field’s exact path on a sample resource before filtering on it (the lab shows the trick: project the whole properties for one resource first).
KQL essentials for inventory: the operators you actually need
Resource Graph speaks a subset of KQL — the tabular operators below are supported; some Log Analytics niceties (like join with every flavour, or time-series functions) are limited or absent. This is the working set for inventory and drift.
| Operator | What it does | Minimal example |
|---|---|---|
where |
Keep rows matching a predicate | where location == "westeurope" |
project |
Choose columns (and rename) | project name, type, location |
project-away |
Drop named columns | project-away tags, id |
extend |
Add a computed column | extend size = tostring(properties.hardwareProfile.vmSize) |
summarize |
Aggregate, usually by groups |
summarize count() by type |
count |
Just the row count | `resources |
order by (sort) |
Sort rows | order by name asc |
top |
First N after a sort | top 10 by count_ desc |
distinct |
Unique combinations | distinct type |
join |
Correlate two queries on a key | join kind=leftouter (…) on id |
mv-expand |
Explode an array/object into rows | mv-expand tags |
parse |
Pull fields from a string | parse id with "…" sub "…" |
The functions you reach for constantly when digging into properties and tags:
| Function | Purpose | Example |
|---|---|---|
tostring() |
Cast a dynamic value to string | tostring(properties.provisioningState) |
toint() / tolong() |
Cast to integer | toint(properties.diskSizeGB) |
isnull() / isnotnull() |
Test for a missing dynamic value | where isnull(properties.networkProfile) |
isempty() / isnotempty() |
Test for empty string/tag | where isempty(tags['env']) |
tolower() / toupper() |
Normalise case for comparison | where tolower(tags['env']) == "prod" |
coalesce() |
First non-null of several | coalesce(tags['owner'], "untagged") |
array_length() |
Length of a JSON array | array_length(properties.ipConfigurations) |
split() |
Break a string into parts | split(id, "/") |
bag_keys() |
Keys of a dynamic object | bag_keys(tags) |
dcount() |
Distinct count (aggregation) | summarize dcount(subscriptionId) |
Three syntax facts trip up newcomers. Tags are their own top-level column, so write tags['env'], never properties.tags.env. Name your aggregates — summarize n = count() then order by n, rather than relying on the auto-generated count_. And type is lower-cased in the index, so where type == "microsoft.storage/storageaccounts" (or =~ for case-insensitive equality) matches where the Pascal-cased form may not.
A query you can paste right now to feel the pipeline — the ten resource types you have most of:
resources
| summarize total = count() by type
| order by total desc
| top 10 by total
The inventory cookbook: counts, lists and breakdowns
This is the half of the book you run to know what you have. Each recipe is a small, complete query. Run them in Resource Graph Explorer (paste, Run query) or with az graph query -q "<the KQL>".
Total resource count and a breakdown by type
The “how big is my estate” baseline:
// Total resources you can see across all subscriptions
resources | count
// Resource count by type, biggest first
resources
| summarize count = count() by type
| order by count desc
Run it from the CLI exactly as written:
az graph query -q "resources | summarize count = count() by type | order by count desc" -o table
Resources by region and by subscription
Region spread (useful before a residency or DR review):
resources
| summarize count = count() by location
| order by count desc
Per-subscription counts, with the subscription name (not just the GUID) by joining to resourcecontainers:
resources
| summarize resourceCount = count() by subscriptionId
| join kind=leftouter (
resourcecontainers
| where type == "microsoft.resources/subscriptions"
| project subscriptionId, subName = name
) on subscriptionId
| project subName, subscriptionId, resourceCount
| order by resourceCount desc
Resource groups and what’s in them
Count resource groups (these are containers, so they live in resourcecontainers); to find empty RGs — a common cleanup target — leftouter-join them to a per-RG resource count and keep the rows where the count is zero (the join pattern is the same as the VM→NIC join later):
resourcecontainers
| where type == "microsoft.resources/subscriptions/resourcegroups"
| summarize rgCount = count() by subscriptionId
| order by rgCount desc
Virtual machines by size, OS and power state
VMs grouped by size — the fleet shape that drives compute cost:
resources
| where type == "microsoft.compute/virtualmachines"
| extend vmSize = tostring(properties.hardwareProfile.vmSize)
| summarize count = count() by vmSize
| order by count desc
VM list with OS and power state. Power state lives in the instance view, which Resource Graph exposes via properties.extended.instanceView.powerState.code on the VM — handy for finding stopped-but-not-deallocated machines still on the bill:
resources
| where type == "microsoft.compute/virtualmachines"
| extend
vmSize = tostring(properties.hardwareProfile.vmSize),
osType = tostring(properties.storageProfile.osDisk.osType),
powerState = tostring(properties.extended.instanceView.powerState.code)
| project name, resourceGroup, location, vmSize, osType, powerState
| order by name asc
Note:
properties.extended.instanceViewis populated for VMs in the index, but for power state that is guaranteed live you may still confirm withaz vm get-instance-view. Treat the Graph value as “fresh within the index lag.”
Storage accounts, public IPs and disks at a glance
Storage accounts with their SKU, kind, TLS minimum and public-blob setting — the security posture at a glance (the drift section filters this down to the violators):
resources
| where type == "microsoft.storage/storageaccounts"
| extend
skuName = tostring(sku.name),
minTls = tostring(properties.minimumTlsVersion),
publicBlob = tostring(properties.allowBlobPublicAccess)
| project name, resourceGroup, location, skuName, kind, minTls, publicBlob
| order by name asc
The same pattern covers any type: swap microsoft.storage/storageaccounts for microsoft.network/publicipaddresses (project properties.ipAddress, properties.ipConfiguration.id) or microsoft.compute/disks (project properties.diskSizeGB, properties.diskState) to list IPs or disks — the orphan-hunting versions of both are in the drift section.
Resources by tag value
How many resources carry each value of a tag — say environment — including the untagged bucket:
resources
| extend env = tostring(tags['environment'])
| extend env = iff(isempty(env), "<<untagged>>", env)
| summarize count = count() by env
| order by count desc
A quick reference of the recipes above and what each answers:
| Recipe | Question it answers | Primary table | Key field |
|---|---|---|---|
| Total count | “How big is the estate?” | resources |
— |
| By type | “What do we have most of?” | resources |
type |
| By region | “Where is it?” | resources |
location |
| By subscription (named) | “How is it split across subs?” | resources + resourcecontainers |
subscriptionId |
| RG counts / empty RGs | “What can we delete?” | resourcecontainers + resources |
resourceGroup |
| VMs by size | “What’s the compute shape?” | resources |
properties.hardwareProfile.vmSize |
| VM power state | “What’s running vs stopped?” | resources |
properties.extended.instanceView.powerState.code |
| Storage posture | “TLS / public-blob settings?” | resources |
properties.minimumTlsVersion |
| By tag value | “Tag coverage by value?” | resources |
tags['environment'] |
The drift cookbook: finding what’s wrong
The second half is where Resource Graph earns its keep: turning “we have a standard” into “here is every resource that violates it.” Each recipe produces a worklist — a list of resource IDs you can hand to a remediation script or an Azure Policy Effects Decoded: Deny vs Audit vs Modify vs DeployIfNotExists assignment.
Resources missing a required tag
The most-requested drift query. Find everything lacking an owner tag (swap in whichever tag your standard mandates):
resources
| where isempty(tostring(tags['owner']))
| project name, type, resourceGroup, subscriptionId, location
| order by type asc, name asc
Missing any of several required tags at once — flag the resource and say which tags are absent:
resources
| extend
missingEnv = isempty(tostring(tags['environment'])),
missingOwner = isempty(tostring(tags['owner'])),
missingCC = isempty(tostring(tags['costCenter']))
| where missingEnv or missingOwner or missingCC
| extend missing = strcat(
iff(missingEnv, "environment ", ""),
iff(missingOwner, "owner ", ""),
iff(missingCC, "costCenter ", ""))
| project name, type, resourceGroup, subscriptionId, missing
| order by type asc
Resources with a wrong or non-standard tag value
A tag exists but holds a value outside your allowed set — e.g. environment must be one of prod/staging/dev:
resources
| extend env = tolower(tostring(tags['environment']))
| where isnotempty(env) and env !in ("prod", "staging", "dev")
| project name, type, resourceGroup, subscriptionId, env
| order by env asc
Resources in a disallowed region
Your policy says everything lives in westeurope or northeurope; find the strays (the classic “someone deployed in East US” finding):
resources
| where location !in ("westeurope", "northeurope", "global")
| summarize count = count() by location, type
| order by count desc
The "global" location covers resources like Front Door, DNS zones and policy assignments that legitimately have no region — exclude it so it doesn’t show up as a false violation.
Insecure configuration drift
These are the queries security actually asks for. Storage accounts that allow public blob access or sit below TLS 1.2 — both posture violations in one sweep:
resources
| where type == "microsoft.storage/storageaccounts"
| extend
publicBlob = tostring(properties.allowBlobPublicAccess),
minTls = tostring(properties.minimumTlsVersion)
| where publicBlob =~ "true" or minTls != "TLS1_2"
| project name, resourceGroup, subscriptionId, publicBlob, minTls
NSGs with a rule allowing inbound from the internet to a sensitive port — explode the rules with mv-expand, then filter:
resources
| where type == "microsoft.network/networksecuritygroups"
| mv-expand rule = properties.securityRules
| extend
direction = tostring(rule.properties.direction),
access = tostring(rule.properties.access),
src = tostring(rule.properties.sourceAddressPrefix),
dstPort = tostring(rule.properties.destinationPortRange)
| where direction == "Inbound" and access == "Allow"
and src in ("*", "0.0.0.0/0", "Internet")
and dstPort in ("22", "3389", "*")
| project nsg = name, resourceGroup, ruleName = tostring(rule.name), src, dstPort
Orphaned resources (cost drift)
Public IPs attached to nothing — billed monthly for no reason:
resources
| where type == "microsoft.network/publicipaddresses"
| where isnull(properties.ipConfiguration) and isnull(properties.natGateway)
| project name, resourceGroup, subscriptionId, location, sku = tostring(sku.name)
Unattached managed disks (diskState == "Unattached") — pure waste:
resources
| where type == "microsoft.compute/disks"
| where tostring(properties.diskState) == "Unattached"
| extend sizeGB = toint(properties.diskSizeGB)
| project name, resourceGroup, subscriptionId, sizeGB, sku = tostring(sku.name)
| order by sizeGB desc
Empty network security groups (no subnet and no NIC associated) — drift left behind by deleted workloads:
resources
| where type == "microsoft.network/networksecuritygroups"
| where isnull(properties.subnets) and isnull(properties.networkInterfaces)
| project name, resourceGroup, subscriptionId, location
A consolidated drift catalogue — what each recipe finds and who usually asks for it:
| Drift query | What it finds | Owner who asks | Remediation route |
|---|---|---|---|
| Missing required tag | Resources lacking owner/env/costCenter |
FinOps, platform | Policy modify or bulk az tag |
| Wrong tag value | Tag outside allowed set | Platform | Correct value; Policy deny on create |
| Disallowed region | Resources outside approved regions | Security, compliance | Policy deny allowed-locations |
| Public blob access | Storage allowing anonymous blobs | Security | az storage account update |
| Weak min TLS | Storage below TLS 1.2 | Security | Policy modify; update account |
| Open NSG rule | Inbound *→22/3389 |
Security, network | Tighten rule; Policy audit |
| Orphan public IP | Unassociated public IP | FinOps | Delete after confirm |
| Unattached disk | Unattached managed disk |
FinOps | Snapshot then delete |
| Empty NSG | NSG with no associations | Platform | Delete |
A short decision table for “I found drift — now what”:
| If the violation is… | It’s probably… | Do this |
|---|---|---|
| A missing/wrong tag on many resources | A standard nobody enforced at create time | Bulk-fix now (az tag), then add a Policy deny/modify so new ones can’t drift |
| An insecure setting (public blob, weak TLS) | A default that was never hardened | Remediate the listed IDs, then assign Policy to keep it fixed |
| A resource in a wrong region | A one-off manual deploy | Move or delete; assign allowed-locations Policy |
| An orphaned IP/disk | Leftover from a deleted workload | Confirm it’s truly unused, snapshot if disk, then delete |
Correlating resource types with join and mv-expand
The portal can never answer “show me VMs whose public IP is exposed” because that spans three resource types. Resource Graph can, with join (correlate two result sets on a key) and mv-expand (turn an array property into one row per element). These two operators are what make Resource Graph a graph.
join: VM → NIC → public IP
Find which VMs are reachable from the internet by walking VM → NIC → public IP. The join key is the ARM resource ID, normalised to lower-case so the match is reliable:
// Public IPs, projected to the NIC ipconfig they attach to
resources
| where type == "microsoft.network/publicipaddresses"
| extend nicId = tolower(tostring(properties.ipConfiguration.id))
| where isnotempty(nicId)
| project pipName = name, publicIp = tostring(properties.ipAddress), nicId
| join kind=inner (
// NICs, exploded to each ipConfiguration, projected to the owning VM
resources
| where type == "microsoft.network/networkinterfaces"
| mv-expand ipconfig = properties.ipConfigurations
| extend
nicId = tolower(tostring(ipconfig.id)),
vmId = tolower(tostring(properties.virtualMachine.id))
| project nicId, vmId
) on nicId
| project publicIp, pipName, vmId
| order by publicIp asc
The join flavours Resource Graph supports, and when to use each:
kind= |
Keeps | Use when |
|---|---|---|
inner (default innerunique) |
Only rows that match on both sides | “VMs that have a public IP” |
leftouter |
All left rows; right columns null if no match | “All VMs, with IP if any” — find the orphans by where right isnull |
rightouter |
All right rows | Rare; usually rewrite as leftouter |
fullouter |
All rows from both sides | Reconciling two inventories |
A practical note: keep the smaller, more-filtered result on the left and join into the larger set, and always normalise the key (tolower) — ID casing differs between providers and an un-normalised join silently returns zero rows.
mv-expand: explode arrays into rows
mv-expand turns one resource with an array property into many rows. You saw it on NSG rules and NIC ipconfigs; here it is on tags themselves, to audit tag keys across the estate — which tag keys are in use, and how often:
resources
| extend tagKeys = bag_keys(tags)
| mv-expand tagKey = tagKeys
| extend tagKey = tostring(tagKey)
| where isnotempty(tagKey)
| summarize count = count() by tagKey
| order by count desc
That single query is the fastest way to discover the accidental tag sprawl that every estate accumulates — env vs Environment vs environment, cost-center vs costCenter — because they all surface as distinct keys with their own counts. The rule of thumb: join (on a lower-cased id) correlates two resource types; mv-expand turns an array property into rows; bag_keys() then mv-expand walks the keys of a JSON object; and join kind=leftouter followed by where isnull(...) is how you surface the orphans of any relationship.
Running queries everywhere: portal, CLI and shared queries
Every recipe runs three ways. Pick by purpose: explore in the portal, automate with the CLI, standardise with a saved shared query.
Portal — Resource Graph Explorer
Search Resource Graph Explorer in the portal. Set the scope (Directory / a management group / specific subscriptions) at the top, paste KQL into the editor, Run query, and read the grid. From there you can Download as CSV, Pin to dashboard, or Save the query (private to you, or shared to a resource group as a Microsoft.ResourceGraph/queries resource). It is the right surface for building a query interactively, because the column explorer on the left lets you discover field paths.
CLI — az graph query
The automation surface. The first run offers to install the resource-graph extension; accept it. The essential flags:
| Flag | Purpose | Default / note |
|---|---|---|
-q / --graph-query |
The KQL string | Required |
--subscriptions |
Limit scope to listed sub IDs | Default: all you can read |
--management-groups |
Scope to a management group (and its subs) | Mutually exclusive intent with --subscriptions |
--first |
Page size, 1–1000 | Default 100 (CLI); max 1000 per call |
--skip |
Skip N rows | For simple offset paging |
--skip-token |
Cursor from a previous page | The correct way to page large sets |
-o |
Output format (table, json, tsv) |
json default |
A scoped, formatted query — and the @file form for long queries, to avoid quoting headaches:
az graph query \
-q "resources | where type =~ 'microsoft.storage/storageaccounts' | project name, resourceGroup, kind" \
--subscriptions 1111-2222-3333 4444-5555-6666 --first 1000 -o table
az graph query -q "@drift-missing-owner.kql" -o table # KQL read from a file
The read→act pattern in one pipe — feed the IDs straight into a remediation loop (dry-run shown; uncomment to apply):
az graph query -q "resources | where isempty(tostring(tags['owner'])) | project id" -o tsv \
| while read -r rid; do
echo "Would tag: $rid"
# az tag update --resource-id "$rid" --operation merge --tags owner=unassigned
done
Paging past 1,000 rows (the trap everyone hits)
A single Resource Graph call returns at most 1,000 rows. On a large estate your inventory query will be truncated, and — this is the dangerous part — a bare query gives you the first 1,000 with no error, so you under-count silently. The fix is the skip token: each response includes a token; pass it as --skip-token on the next call and loop until the token comes back empty (the full paging loop is Step 11 of the lab). The simplest defence, though, is to aggregate server-side — a query that ends in summarize/count returns the grouped result, not the rows, so it can’t be truncated in the first place.
Paging facts you must internalise:
| Fact | Value | Consequence |
|---|---|---|
| Max rows per call | 1,000 | Larger result is silently truncated without a token |
Default CLI page (--first) |
100 | Set --first 1000 for big reads |
| How to get the rest | --skip-token from the response |
Loop until token is empty |
| Aggregations | Not paged the same way | summarize returns the grouped set; prefer aggregating server-side |
| Best practice | Aggregate in KQL, not in the client | Move summarize/count into the query so you fetch fewer rows |
Shared queries — save the good ones as a resource
A query you run often should become a shared query: a Microsoft.ResourceGraph/queries resource in a resource group, visible to anyone with Reader on it, editable in the portal, and deployable as code. This is how a team standardises “the orphaned-disk query” so everyone runs the same logic.
az graph shared-query create \
--name "orphaned-disks" \
--resource-group rg-governance \
--description "Unattached managed disks across the estate" \
--graph-query "resources | where type == 'microsoft.compute/disks' | where tostring(properties.diskState) == 'Unattached' | project name, resourceGroup, subscriptionId, toint(properties.diskSizeGB)"
The same as Bicep, so it lives in your governance repo and deploys with everything else:
@description('Resource Graph shared query: unattached disks worklist')
resource orphanedDisks 'Microsoft.ResourceGraph/queries@2024-04-01' = {
name: 'orphaned-disks'
location: 'global'
properties: {
description: 'Unattached managed disks across the estate'
query: '''resources
| where type == "microsoft.compute/disks"
| where tostring(properties.diskState) == "Unattached"
| extend sizeGB = toint(properties.diskSizeGB)
| project name, resourceGroup, subscriptionId, sizeGB
| order by sizeGB desc'''
}
}
Where each surface fits:
| Surface | Strength | Use for |
|---|---|---|
| Resource Graph Explorer | Interactive, column discovery, CSV/dashboard | Building and exploring queries |
az graph query |
Scriptable, pageable, pipe-able | Automation, scheduled reports, remediation feeds |
| Shared query (portal/Bicep) | Saved, shareable, version-controlled | Team standards, governance-as-code |
| Workbooks / dashboards | Visualised, parameterised | Recurring reports for non-query users |
Architecture at a glance
Hold this picture and the whole service makes sense. On the write path, every resource lifecycle event — create, update, delete, tag change — flows through Azure Resource Manager, the control plane fronting every resource provider. ARM emits that change to Resource Graph, which maintains a denormalised, columnar index of metadata: the flat fields (id, type, location, resourceGroup, subscriptionId), the dynamic tags/sku/identity, and the per-type properties blob. The index spans your whole tenant, partitioned so a query fans out across thousands of subscriptions and still returns in well under a second. Because it is fed by ARM events rather than polled, it is eventually consistent — fresh within seconds to a couple of minutes, which is why a query right after a deploy can momentarily miss the new resource.
On the read path, your query — from Resource Graph Explorer, az graph query, or the resources REST API — arrives at the service, which first applies your RBAC: it intersects the query scope with the subscriptions and management groups you hold at least Reader on, so you only ever see what you could already see. It then runs the KQL pipeline against the index for those scopes and streams back rows (up to 1,000 per page, with a skip token for more). The crucial model is the separation of planes: Resource Manager is the write/control plane — it changes resources, and is rate-limited and slow to enumerate at scale; Resource Graph is the read/query plane — a fast read replica of metadata only, that can never change a thing. When you need to know, you query the read plane; when you need to act, you go back through ARM (az tag/update/delete) on the exact IDs the query handed you. Everything in this cookbook is the read plane producing a worklist the control plane then acts on.
Real-world scenario
Northwind Retail runs Azure across 14 subscriptions — production, non-production and three years of team sandboxes — under one management group. The trigger was an audit finding: security could not produce, on demand, a list of storage accounts allowing public blob access, and finance could not attribute 18% of monthly spend to a cost centre because the costCenter tag was missing on thousands of resources. The platform lead, Asha, was told to “fix the governance gap” — no new tooling budget, two weeks.
Asha’s first move was one Resource Graph query to size the problem honestly. The tag-key audit (bag_keys(tags) exploded with mv-expand) exposed the mess: eleven distinct casings of the environment tag (env, Env, environment, ENVIRONMENT…) and costCenter present on only 61% of resources — invisible for years because no portal blade aggregates tag keys across subscriptions. The insecure-config recipes found 23 storage accounts with allowBlobPublicAccess = true and 9 below TLS 1.2; the orphan sweep found 140 unattached disks and 38 idle public IPs quietly costing about ₹47,000/month for nothing. Every number came from one query, scoped at the management group, back in under two seconds — and each was saved as a shared query in rg-governance via Bicep so security and finance could re-run the exact same logic themselves. The drift lists became worklists: a reviewed az-CLI loop reclaimed the ₹47k; a bulk az tag update --operation merge filled the missing costCenter; the eleven environment casings were normalised and then locked in with an Azure Policy Effects Decoded: Deny vs Audit vs Modify vs DeployIfNotExists modify policy plus a deny on disallowed regions.
The instructive part is what went wrong mid-flight: Asha’s first cleanup under-counted the disks because a bare az graph query returned exactly 1,000 rows with no error — the silent truncation. The suspiciously round number tipped her off; the skip-token paging loop revealed the real count was 1,340. The runbook lesson: any query that might exceed 1,000 rows must page with the skip token, or aggregate server-side so it can’t. Two weeks later the audit gap was closed, the ₹47k/month leak stopped, and — the durable win — every governance question had a saved, repeatable query instead of a click-hunt. The estate became knowable.
Advantages and disadvantages
| Advantages | Disadvantages |
|---|---|
| Queries the whole tenant at once — no per-subscription looping | Read-only — finds drift, can’t fix it (you still go back to ARM) |
| Sub-second even across thousands of subscriptions | Eventually consistent — seconds-to-minutes index lag after changes |
| No cost for the service itself (you pay nothing to query) | KQL subset — not every Log Analytics operator is available |
No throttling pain vs hammering az resource list per sub |
1,000-row page cap silently truncates without a skip token |
| RBAC-bounded — never shows more than you can already see | RBAC-bounded — a “missing” resource may be a permission gap, not absence |
properties exposes deep per-type config for drift queries |
properties casing/shape varies by API version — must verify paths |
| Shared queries make governance repeatable and code-deployable | Power state / instance-view freshness is index-lagged, not live |
| Same KQL skill transfers to Log Analytics and Workbooks | Some niche providers populate properties sparsely in the index |
When the advantages dominate: fleet-wide inventory and drift detection, where speed across all subscriptions and zero cost are exactly what you need, and read-only is a feature (you cannot accidentally break anything). When the disadvantages bite: anything needing live, to-the-second state (use the resource-specific API), anything needing mutation (use ARM/az), and any single query you expect to exceed 1,000 rows without paging. The healthy pattern is the one Northwind landed on: Resource Graph reads and produces the worklist; ARM and Policy act on it.
Hands-on lab
This is the centrepiece. You will go from an empty Cloud Shell to a saved, scoped, paged drift query — in both the portal and the az CLI, plus a Bicep-deployed shared query. It is free: Resource Graph queries cost nothing, and the few resources you create (a storage account, a public IP) are deleted at teardown. Budget ~25 minutes.
Prerequisites
| Need | How to get it | Verify |
|---|---|---|
| An Azure subscription | Any pay-as-you-go or free account | az account show |
| Reader (at least) on it | You have it on your own sub | Queries below return rows |
Cloud Shell or local az |
shell.azure.com or installed CLI |
az version |
resource-graph extension |
Auto-offered on first az graph |
az extension show -n resource-graph |
Step 1 — Sign in and confirm scope
az login # skip in Cloud Shell (already signed in)
az account show -o table
az account list --query "[].{name:name, id:id, default:isDefault}" -o table
Expected output: a table of your subscriptions with one marked default. Note the subscription id of the one you’ll use; set it explicitly so later steps are deterministic:
az account set --subscription "<your-sub-id>"
Step 2 — Install the Resource Graph extension and run your first query
az graph query -q "resources | count"
Expected output: the CLI prompts “The command requires the extension resource-graph. Do you want to install it now?” — answer y. Then a small JSON with your total resource count, e.g. "count_": 137. If you get 0, you genuinely have no resources yet (or you’re on the wrong sub) — Step 6 creates some.
Confirm the extension is registered:
az extension show -n resource-graph --query "{name:name, version:version}" -o table
Step 3 — Inventory: counts by type and by region
az graph query -q "resources | summarize count = count() by type | order by count desc" -o table
az graph query -q "resources | summarize count = count() by location | order by count desc" -o table
Expected output: two tables — resource types with counts, and regions with counts. This is your baseline estate shape. If you have very little, the tables are short; that’s fine.
Step 4 — The same query in the portal
- In the portal, search Resource Graph Explorer and open it.
- At the top, set the scope (leave at Directory to query everything you can read, or pick your subscription).
- Paste into the editor:
resources | summarize count = count() by type | order by count desc - Click Run query. Expected: the same counts as Step 3, in a grid.
- Click Download as CSV to confirm export works, and note the Save / Save as buttons — you’ll use Save as in Step 9.
This cross-check proves the portal and CLI run identical KQL against the same index — the only difference is the surface.
Step 5 — Reach into properties: discover a field path safely
Before filtering on a properties field, confirm its exact path. Project the whole blob for one resource of a type you have (use storage if you have one; otherwise skip to Step 6 first and return):
az graph query -q "resources | where type == 'microsoft.storage/storageaccounts' | project name, properties | limit 1" -o json
Expected output: one resource’s full properties JSON. Read off the real casing — you’ll see minimumTlsVersion, allowBlobPublicAccess, etc. This is the habit that prevents “my filter returns nothing”: never guess a properties path, project it once and read it.
Step 6 — Create lab resources to query (and to find as drift)
Make a resource group, a storage account deliberately missing the owner tag and allowing public blob access, and an orphaned public IP attached to nothing:
RG=rg-graph-lab
LOC=eastus
SA=graphlab$RANDOM
az group create -n $RG -l $LOC -o none
# Storage account: NO owner tag, public blob access ON, TLS left at default — three drift signals
az storage account create -n $SA -g $RG -l $LOC \
--sku Standard_LRS --kind StorageV2 \
--allow-blob-public-access true \
--tags environment=Dev -o none
# A standalone public IP, attached to nothing → an orphan
az network public-ip create -n pip-graph-lab -g $RG -l $LOC --sku Standard --tags environment=dev -o none
echo "Created $SA and pip-graph-lab in $RG"
Expected output: the resources create without error (storage names must be globally unique; $RANDOM handles that). The storage account intentionally carries environment=Dev (wrong casing vs a dev standard) and no owner tag — both will surface as drift.
Index-lag note: wait 30–60 seconds before querying, so Resource Graph has indexed the new resources. If a query below shows nothing, wait and re-run — you are seeing eventual consistency first-hand.
Step 7 — Drift query 1: resources missing the owner tag
az graph query -q "resources | where isempty(tostring(tags['owner'])) | project name, type, resourceGroup, location | order by type asc" -o table
Expected output: a table that includes your new storage account and public IP (and any other untagged resources you had). You have just produced a real worklist of tag drift across the estate.
Step 8 — Drift query 2: insecure storage + orphaned public IPs
Public blob access enabled:
az graph query -q "resources | where type == 'microsoft.storage/storageaccounts' | extend publicBlob = tostring(properties.allowBlobPublicAccess) | where publicBlob =~ 'true' | project name, resourceGroup, publicBlob" -o table
Expected: your $SA appears with publicBlob = true.
Orphaned public IPs:
az graph query -q "resources | where type == 'microsoft.network/publicipaddresses' | where isnull(properties.ipConfiguration) and isnull(properties.natGateway) | project name, resourceGroup, sku = tostring(sku.name)" -o table
Expected: pip-graph-lab appears — it is attached to nothing. Two security/cost findings, each one line of KQL.
Step 9 — Save it as a shared query (CLI and portal)
Persist the orphaned-IP query so the team runs identical logic:
az graph shared-query create \
--name "orphaned-public-ips" \
--resource-group $RG \
--description "Public IPs attached to nothing" \
--graph-query "resources | where type == 'microsoft.network/publicipaddresses' | where isnull(properties.ipConfiguration) and isnull(properties.natGateway) | project name, resourceGroup, subscriptionId, location"
Expected output: JSON describing a Microsoft.ResourceGraph/queries resource. Confirm it:
az graph shared-query list -g $RG --query "[].{name:name, desc:description}" -o table
In the portal: back in Resource Graph Explorer, run any query, click Save as, choose Shared query, pick rg-graph-lab, name it, Save — now it appears under Resource Graph Explorer → Shared queries for anyone with Reader.
Step 10 — Deploy a shared query as Bicep (governance-as-code)
Save this as shared-query.bicep:
@description('Unattached managed disks worklist as a shared Resource Graph query')
resource unattachedDisks 'Microsoft.ResourceGraph/queries@2024-04-01' = {
name: 'orphaned-disks'
location: 'global'
properties: {
description: 'Unattached managed disks across the estate'
query: '''resources
| where type == "microsoft.compute/disks"
| where tostring(properties.diskState) == "Unattached"
| extend sizeGB = toint(properties.diskSizeGB)
| project name, resourceGroup, subscriptionId, sizeGB
| order by sizeGB desc'''
}
}
Deploy and verify:
az deployment group create -g $RG -f shared-query.bicep -o none
az graph shared-query show -g $RG -n orphaned-disks --query "{name:name, desc:description}" -o table
Expected output: the deployment succeeds and the show command returns the orphaned-disks query — your governance query now lives in code and can be PR-reviewed and redeployed.
Step 11 — Page past 1,000 rows (prove the trap)
Even with few resources, exercise the paging pattern so it’s muscle memory before a big estate:
QUERY="resources | project id, name, type"
TOKEN=""; > /tmp/all-resources.json
while : ; do
if [ -z "$TOKEN" ]; then
RESP=$(az graph query -q "$QUERY" --first 1000 -o json)
else
RESP=$(az graph query -q "$QUERY" --first 1000 --skip-token "$TOKEN" -o json)
fi
echo "$RESP" | jq -c '.data[]' >> /tmp/all-resources.json
TOKEN=$(echo "$RESP" | jq -r '.skip_token // empty')
[ -z "$TOKEN" ] && break
done
echo "Total rows fetched: $(wc -l < /tmp/all-resources.json)"
Expected output: a total row count that equals your full estate (one page, token empty, on a small lab). On a large tenant this loop is the difference between a true count and a silent under-count at exactly 1,000.
Step 12 — Teardown
Remove everything so the lab costs nothing ongoing:
az graph shared-query delete --name "orphaned-public-ips" -g $RG --yes 2>/dev/null
az group delete -n $RG --yes --no-wait
rm -f /tmp/all-resources.json shared-query.bicep
Expected output: the resource group deletion starts (--no-wait returns immediately); the shared query and the Bicep-deployed query are removed with the RG. Confirm later with az group exists -n rg-graph-lab returning false.
A recap of what each step produced:
| Step | You did | You learned |
|---|---|---|
| 1–2 | Sign in, first count |
Scope and extension install |
| 3–4 | Counts by type/region, portal + CLI | Same KQL, two surfaces |
| 5 | Project properties for one resource |
Never guess a field path |
| 6 | Create drift-bait resources | Eventual consistency in practice |
| 7–8 | Missing-tag, public-blob, orphan-IP | Drift queries as worklists |
| 9–10 | Save shared query (CLI, portal, Bicep) | Governance-as-code |
| 11 | Skip-token paging loop | Beat the 1,000-row trap |
| 12 | Teardown | Leave nothing running |
Common mistakes & troubleshooting
The failure modes below are the ones that actually waste time. Each is symptom → root cause → confirm → fix.
1 — Query returns zero rows but the resource clearly exists
Symptom: where type == "..." returns nothing for a resource you can see in the portal.
Root cause: usually case (type is lower-cased in the index) or a wrong properties path, occasionally a scope/RBAC gap.
Confirm: drop the filter and run resources | where name == "<the-name>" | project type, properties to see the actual stored type and field casing.
Fix: match type in lower-case (or use =~), and copy the exact properties path from the projected JSON rather than guessing.
2 — Results capped at exactly 1,000
Symptom: every big inventory query returns a suspiciously round 1,000 rows.
Root cause: the per-call 1,000-row cap; a bare query truncates silently with no error.
Confirm: add | count — if it also says 1,000 and you expect more, you’re truncated; check for a skip_token in the JSON response.
Fix: page with --skip-token (Step 11), or — better — push summarize/count into the query so you fetch the aggregate, not the rows.
3 — A brand-new resource is missing from results
Symptom: you deploy, immediately query, and the new resource isn’t there.
Root cause: the index is eventually consistent; ARM hasn’t propagated the change yet.
Confirm: wait 30–60 seconds and re-run; it appears.
Fix: for post-deploy validation, add a short wait/retry, or validate with the resource-specific az show (live ARM) when you need to-the-second certainty.
4 — Tag filter never matches
Symptom: where tags.env == "prod" or where properties.tags.env ... returns nothing.
Root cause: tags is a top-level dynamic column, not under properties, and is accessed by key with bracket syntax.
Confirm: resources | where isnotempty(tags) | project name, tags | limit 5 shows the real shape.
Fix: use tostring(tags['env']), and normalise case (tolower(...)) because tag values and keys vary in casing across teams.
The remaining six are common enough to memorise but quick to fix; the matrix is the playbook. Two worth a word: a join returning zero rows is almost always ID casing — providers store IDs in different cases, so wrap both keys in tolower(tostring(...)) before joining; and a Bicep shared query that won’t deploy is usually inline-quoting pain — use Bicep’s triple-quote '''...''' for the KQL and set location: 'global'.
| # | Symptom | Root cause | Confirm | Fix |
|---|---|---|---|---|
| 1 | Zero rows, resource exists | Case / wrong path / RBAC | Project type + properties for the name |
Lower-case type; copy exact path |
| 2 | Capped at 1,000 | Page cap, silent truncation | | count; look for skip_token |
Skip-token paging; aggregate server-side |
| 3 | New resource missing | Eventual consistency | Wait 30–60s, re-run | Retry; live az show for certainty |
| 4 | Tag filter never matches | tags is top-level, bracketed |
Project tags |
tostring(tags['k']), normalise case |
| 5 | join no matches |
ID casing differs | Project both keys | tolower() both keys |
| 6 | mv-expand errors |
Field isn’t an array | Project the field | Expand the real array / bag_keys |
| 7 | 429 throttling | Per-tenant quota | 429 + retry header | Consolidate, page, back off |
| 8 | Stale power state | Instance-view index lag | az vm get-instance-view |
Confirm single VM live |
| 9 | Bicep query won’t deploy | Escaping / bad location | Read deploy error | Triple-quote KQL; location: 'global' |
| 10 | Disagrees with Policy/Cost | Different question/latency | Compare scope + timestamps | Reconcile by intent, not equality |
Best practices
- Aggregate server-side, and page the rest. Push
summarize/count/dcountinto the query so you fetch a small grouped result, not thousands of rows — it’s faster, cheaper on quota, and immune to the 1,000-row trap. When you genuinely need the rows, page with the skip token and treat a result of exactly 1,000 as suspicious until proven complete. - Always normalise case on joins and tag comparisons. Wrap join keys and tag values in
tolower(tostring(...)); ID and tag casing vary across providers and teams, and un-normalised comparisons silently return nothing. - Verify
propertiespaths on a real sample first. Project the wholepropertiesfor one resource before filtering on a nested field — API-version casing differences are the top cause of empty results. - Scope deliberately. Tenant-wide default is great for discovery, but pass
--subscriptions/--management-groupsfor scheduled jobs to keep results stable, fast and within the access you intend. - Save repeated queries as shared queries, deployed via Bicep. A governance query in a repo is reviewable, versioned and identical for everyone — turn “Asha’s clever query” into a team asset.
- Read with Graph, act with ARM, prevent with Policy. Produce the ID list with Graph, fix with a one-off
azcommand or bulkaz tag, then assign a Policy so the drift can’t recur — and never expect to mutate from Graph itself. - Exclude
"global"and legitimate exceptions in region/security drift queries so real violations don’t drown in false positives. - Re-run drift queries on a schedule, not once. Drift is continuous; wire the important ones into a recurring report or workbook rather than running them at audit time and forgetting.
- Build in the portal, run from the CLI, and treat instance-view/power-state fields as index-lagged — fine for fleet shape, confirmed via the resource’s own API when to-the-second truth matters.
Security notes
Resource Graph is read-only and RBAC-bounded, which makes it inherently safe: no command mutates a resource, and you never see anything you don’t already have at least Reader on. Respect that boundary as a subtlety — an empty result might mean “no such resource” or “you lack Reader there,” so when an inventory looks suspiciously thin, check your role assignments at the queried scope before concluding the estate is clean. For pipelines, grant the service principal or managed identity running the queries Reader at the highest scope you need (often a management group) and nothing more — read access to metadata is all it requires, so least privilege here is genuinely read-only.
The data returned is metadata, not secrets: you see that a Key Vault exists, its SKU, network rules and tags, but never the values inside; a storage account’s configuration, not its keys or data. Still, treat the output as sensitive — a drift report listing every internet-exposed NSG rule, every public-blob account and every VM’s size and location is a reconnaissance gift if it leaks, so store results and CSV exports like any infrastructure inventory. The security drift queries reproduce a slice of what Microsoft Defender for Cloud assesses; lean on Defender’s securityresources and continuous evaluation for ongoing posture, and use your own queries for the ad-hoc questions Defender doesn’t phrase your way. Finally, the real risk lives in remediation: the query is harmless, but the az tag/delete/update loop you feed with its output runs through ARM with your write permissions — always dry-run (echo the IDs) first, and snapshot disks before deleting “orphans,” because an Unattached disk today may be a deliberate detached volume awaiting reattachment tomorrow.
Cost & sizing
The headline: Azure Resource Graph itself is free. Querying — via the portal, az graph, the REST API, or shared queries — incurs no charge. There is no ingestion cost, no per-query cost, and no storage cost for the index; you pay only the standard cost of the resources you query about, which Resource Graph helps you reduce, not increase. What you “spend” is quota, not money: Resource Graph enforces per-tenant query throughput limits to keep the shared service fair, so the sizing question is about request volume, not bill.
| Cost / limit dimension | Reality | Practical guidance |
|---|---|---|
| Service charge | Free | No line item for Resource Graph |
| Per-query cost | None | Query freely; cost is in acting on results |
| Rows per call | Max 1,000 | Page with skip token, or aggregate |
| Throughput | Per-tenant quota (throttles → 429) | Consolidate queries; back off on 429 |
| Index storage | Microsoft-managed, free | Nothing to size or pay for |
| Shared queries | A free ARM resource | No cost; just RBAC and a name |
Where Resource Graph saves money is its real cost story: the orphan-hunting recipes routinely find five-figure-monthly waste. Northwind’s example — 38 idle Standard public IPs at roughly ₹400–450/month each plus 140 unattached premium disks — added up to about ₹47,000/month (~US$560) reclaimed from queries that cost nothing to run. A reasonable cadence: run the orphan and idle-resource queries weekly as a FinOps worklist (public IPs, unattached disks, deallocated-but-not-deleted VMs, empty NSGs), the security drift queries continuously or daily, and the tag-coverage queries before every billing close. The only “sizing” discipline you need is to keep automated query volume sane — aggregate, page, and don’t spin tight loops of small queries — so you never trip the per-tenant throttle. In short: the tool is free, the savings are real, and the only budget you manage is request throughput.
Interview & exam questions
1. What is Azure Resource Graph and how does it differ from the resource list in the portal? Resource Graph is an indexed, read-only query service over ARM metadata that you query with KQL across all subscriptions you can read, returning sub-second results. The portal’s resource list is scoped to one subscription, paginated, and can’t aggregate or correlate. Use Graph for estate-wide inventory and drift; the portal for eyeballing a few resources. (AZ-104, AZ-305 governance.)
2. Which table holds Azure resources, and which holds subscriptions and resource groups?
Resources live in the resources table (one row per resource). Subscriptions, resource groups and management groups — the containers — live in resourcecontainers. Choosing the wrong table is why a where type == matches nothing.
3. How do you reach a resource-type-specific setting like a VM’s size or a storage account’s TLS minimum?
Those live inside the dynamic properties column, accessed by dot path and cast — e.g. tostring(properties.hardwareProfile.vmSize) or tostring(properties.minimumTlsVersion). Because properties mirrors the provider’s JSON and varies by API version, you confirm the exact path by projecting properties on a sample resource first.
4. You run an inventory query and get exactly 1,000 rows. What’s happening and how do you fix it?
You’ve hit the per-call 1,000-row cap, which truncates silently with no error. Fix it by paging with the skip token returned in the response (--skip-token), or — preferably — push summarize/count into the query so you fetch the aggregate rather than the rows.
5. How does Resource Graph decide what you’re allowed to see? It’s RBAC-bounded: a query only returns resources in scopes where you have at least Reader. It never escalates privilege — it’s a faster lens on the same access. So a “missing” resource can mean genuine absence or a permission gap on that scope.
6. Write the shape of a query to find every resource missing an owner tag.
resources | where isempty(tostring(tags['owner'])) | project name, type, resourceGroup, subscriptionId. The key points: tags is a top-level column accessed by bracketed key, tostring() casts it, and isempty() tests for absent/empty.
7. When would you use join versus mv-expand?
join correlates two result sets on a key (e.g. VM → NIC → public IP, matched on the resource ID). mv-expand explodes an array property on a single resource into one row per element (e.g. an NSG’s securityRules, or a NIC’s ipConfigurations). Normalise IDs with tolower() before joining.
8. How is Resource Graph consistency characterised, and when does it bite? It’s eventually consistent — fed by ARM change events, fresh within seconds to a couple of minutes. It bites when you query immediately after a deploy and the new resource hasn’t indexed yet. For to-the-second certainty (e.g. live power state) confirm with the resource-specific API.
9. How do you make a Resource Graph query reusable across a team and deploy it as code?
Save it as a shared query — a Microsoft.ResourceGraph/queries resource in a resource group — via the portal Save as, az graph shared-query create, or Bicep. Anyone with Reader on the RG runs the same logic, and the Bicep form makes it PR-reviewable governance-as-code.
10. Resource Graph found drift — say, missing tags. How do you remediate it, and why can’t Graph do it directly?
Graph is read-only, so you use its output (a list of resource IDs) as a worklist and act through ARM — a bulk az tag update --operation merge for a one-off fix, and an Azure Policy modify/deny assignment to prevent recurrence. Read with Graph; act with ARM and Policy. (And don’t expect its counts to match Policy compliance or Cost Management exactly — those answer different questions on different latencies; reconcile by intent.)
Quick check
- Which table do you query for subscriptions and resource groups, and which for actual resources?
- A
where type == "Microsoft.Storage/storageAccounts"filter returns nothing though the accounts exist. Name the most likely cause. - Your inventory query returns exactly 1,000 rows on a large tenant. What’s wrong and what’s the fix?
- How do you access an
ownertag in KQL, and how do you test that it’s missing? - You need to correlate VMs to their public IPs. Which two operators do this, and what must you do to the join key?
Answers
resourcecontainersholds subscriptions, resource groups and management groups (the containers);resourcesholds the actual resources, one row each.- Case —
typeis stored lower-cased in the index, so matchmicrosoft.storage/storageaccounts(or use=~for case-insensitive equality). A wrongpropertiespath or an RBAC gap are the runner-up causes. - You’ve hit the 1,000-row per-call cap, which truncates silently. Fix by paging with the
--skip-tokenfrom the response until it’s empty, or pushsummarize/countinto the query to aggregate server-side. - Access it as
tostring(tags['owner'])—tagsis a top-level dynamic column, indexed by key with brackets. Test absence withisempty(tostring(tags['owner'])). joincorrelates VM/NIC/public-IP result sets andmv-expandexplodes the NIC’sipConfigurationsarray; you must lower-case both join keys (tolower(tostring(id))) because ID casing differs across providers and breaks an exact match.
Glossary
| Term | Definition |
|---|---|
| Azure Resource Graph | A read-only, indexed query service over ARM resource metadata, queried with KQL across all subscriptions you can read. |
| KQL (Kusto Query Language) | The pipeline query language used by Resource Graph and Log Analytics; operators chained with |. |
resources table |
The default table: one row per Azure resource, with flat fields plus tags, sku and properties. |
resourcecontainers table |
One row per subscription, resource group or management group. |
properties |
The dynamic JSON column holding resource-type-specific configuration; accessed by dot path and cast. |
tags |
A top-level dynamic column of the resource’s tags, accessed by key with brackets (tags['env']). |
type |
The ARM resource type, stored lower-cased in the index; the primary filter in most queries. |
| Scope | The set of subscriptions/management groups a query runs against; defaults to everything you can read. |
| Drift | Divergence of deployed resource state from the intended standard (tags, region, security settings). |
join |
The operator that correlates two result sets on a key (typically a lower-cased resource ID). |
mv-expand |
The operator that explodes an array or object property into one row per element. |
summarize |
The aggregation operator, almost always with by to group (counts, dcount, etc.). |
| Skip token | A cursor returned with a result page; passed as --skip-token to fetch the next page past 1,000 rows. |
| Shared query | A saved query stored as a Microsoft.ResourceGraph/queries resource, shareable and Bicep-deployable. |
| Eventual consistency | The index lag (seconds to minutes) between an ARM change and its appearance in Resource Graph. |
| Orphaned resource | A billable resource attached to nothing (idle public IP, unattached disk, empty NSG) — cost drift. |
Next steps
- Lock the tagging standard your drift queries audit against — read Azure Resource Tagging Strategy: Drive Cost Allocation and Governance From Day One and make every
costCenter/ownerquery return zero violations. - Turn the drift you find into prevention with Azure Policy Effects Decoded: Deny vs Audit vs Modify vs DeployIfNotExists, so new resources can’t drift in the first place.
- Understand the scopes you point queries at — Management Groups 101: Designing a Hierarchy That Scopes Policy and RBAC and Azure Resource Hierarchy Explained: Subscriptions, Resource Groups and Resources.
- Feed the orphan and idle-resource worklists into a cost-cleanup loop with Azure FinOps and Cost Management: Controlling Cloud Spend at Scale and Azure Advisor for Cost: Acting on Rightsizing and Idle-Resource Recommendations.
- Carry the same KQL skill into telemetry with Azure Monitor and Application Insights: Full-Stack Observability — the language is identical, the table is your logs.