Azure Governance

Azure Resource Graph Cookbook: KQL Queries for Inventory and Drift at Scale

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:

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 aggregatessummarize 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.instanceView is populated for VMs in the index, but for power state that is guaranteed live you may still confirm with az 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

  1. In the portal, search Resource Graph Explorer and open it.
  2. At the top, set the scope (leave at Directory to query everything you can read, or pick your subscription).
  3. Paste into the editor:
    resources
    | summarize count = count() by type
    | order by count desc
    
  4. Click Run query. Expected: the same counts as Step 3, in a grid.
  5. 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

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

  1. Which table do you query for subscriptions and resource groups, and which for actual resources?
  2. A where type == "Microsoft.Storage/storageAccounts" filter returns nothing though the accounts exist. Name the most likely cause.
  3. Your inventory query returns exactly 1,000 rows on a large tenant. What’s wrong and what’s the fix?
  4. How do you access an owner tag in KQL, and how do you test that it’s missing?
  5. You need to correlate VMs to their public IPs. Which two operators do this, and what must you do to the join key?

Answers

  1. resourcecontainers holds subscriptions, resource groups and management groups (the containers); resources holds the actual resources, one row each.
  2. Casetype is stored lower-cased in the index, so match microsoft.storage/storageaccounts (or use =~ for case-insensitive equality). A wrong properties path or an RBAC gap are the runner-up causes.
  3. You’ve hit the 1,000-row per-call cap, which truncates silently. Fix by paging with the --skip-token from the response until it’s empty, or push summarize/count into the query to aggregate server-side.
  4. Access it as tostring(tags['owner'])tags is a top-level dynamic column, indexed by key with brackets. Test absence with isempty(tostring(tags['owner'])).
  5. join correlates VM/NIC/public-IP result sets and mv-expand explodes the NIC’s ipConfigurations array; 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

AzureResource GraphKQLGovernanceInventoryTaggingAzure CLIBicep
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading