Query every resource across every subscription with Azure Resource Graph, using KQL to answer questions the portal cannot.

Bishal Biswas
Self Learner
Why it exists
az resource list queries one subscription at a time and returns limited properties. Resource Graph indexes everything across every subscription you can read, and queries it with KQL in about a second.
az graph query -q "Resources | summarize count() by type | order by count_ desc"
az extension add --name resource-graph
Answering “how many public IP addresses exist across our forty subscriptions” is one query rather than a script and a coffee.
The tables
Resources almost everything
ResourceContainers subscriptions, resource groups, management groups
PolicyResources policy assignments and compliance states
SecurityResources Defender for Cloud assessments and alerts
AdvisorResources Advisor recommendations
HealthResources resource health
AuthorizationResources role assignments and definitions
Resources is where most work happens. The others answer governance and compliance questions.
Basic shape
Resources
| where type =~ 'microsoft.compute/virtualmachines'
| where location == 'uksouth'
| project name, resourceGroup, subscriptionId,
size = properties.hardwareProfile.vmSize,
osType = properties.storageProfile.osDisk.osType
| order by name asc
=~ case insensitive equals. Use it for type, since casing varies.
project choose columns
extend add computed columns
summarize aggregate
mv-expand expand an array into rows
Resource types are always lowercase in the index, so =~ saves a class of empty result sets.
Genuinely useful queries
Everything with a public IP:
Resources
| where type =~ 'microsoft.network/publicipaddresses'
| extend attachedTo = tostring(properties.ipConfiguration.id)
| project name, resourceGroup, subscriptionId,
ip = tostring(properties.ipAddress),
allocation = tostring(properties.publicIPAllocationMethod),
attachedTo
| order by subscriptionId asc
Resources with no tags, which is usually a governance problem:
Resources
| where isnull(tags) or array_length(bag_keys(tags)) == 0
| summarize count() by type, subscriptionId
| order by count_ desc
Storage accounts still allowing shared key access:
Resources
| where type =~ 'microsoft.storage/storageaccounts'
| where properties.allowSharedKeyAccess != false
| project name, resourceGroup, subscriptionId,
sku = sku.name,
publicAccess = properties.publicNetworkAccess
Unattached disks, which quietly cost money:
Resources
| where type =~ 'microsoft.compute/disks'
| where properties.diskState == 'Unattached'
| extend sizeGb = toint(properties.diskSizeGB)
| project name, resourceGroup, subscriptionId, sizeGb, sku = sku.name
| summarize totalGb = sum(sizeGb), count = count() by subscriptionId
| order by totalGb desc
Virtual machines missing a required tag:
Resources
| where type =~ 'microsoft.compute/virtualmachines'
| where isnull(tags['costCentre'])
| project name, resourceGroup, subscriptionId, tags
Resources created recently, when something appeared and nobody knows what:
Resources
| where todatetime(properties.timeCreated) > ago(7d)
| project name, type, resourceGroup, created = properties.timeCreated
| order by created desc
Joining tables
Resources
| where type =~ 'microsoft.compute/virtualmachines'
| project vmId = id, vmName = name, resourceGroup, subscriptionId
| join kind=leftouter (
ResourceContainers
| where type =~ 'microsoft.resources/subscriptions'
| project subscriptionId, subscriptionName = name
) on subscriptionId
| project vmName, resourceGroup, subscriptionName
Joining to ResourceContainers turns subscription GUIDs into names, which makes any cross subscription report readable.
Expanding arrays
Resources
| where type =~ 'microsoft.network/networksecuritygroups'
| mv-expand rule = properties.securityRules
| where rule.properties.access == 'Allow'
| where rule.properties.direction == 'Inbound'
| where rule.properties.sourceAddressPrefix in ('*', 'Internet', '0.0.0.0/0')
| where rule.properties.destinationPortRange in ('22', '3389')
| project nsg = name, resourceGroup, subscriptionId,
ruleName = tostring(rule.name),
port = tostring(rule.properties.destinationPortRange)
That query finds every NSG allowing SSH or RDP from the internet, across the entire estate. It is the single most useful security query here, and it takes seconds.
Compliance and security
PolicyResources
| where type =~ 'microsoft.policyinsights/policystates'
| where properties.complianceState == 'NonCompliant'
| summarize count() by
policy = tostring(properties.policyDefinitionName),
subscriptionId
| order by count_ desc
SecurityResources
| where type =~ 'microsoft.security/assessments'
| where properties.status.code == 'Unhealthy'
| project resource = properties.resourceDetails.Id,
assessment = properties.displayName,
severity = properties.metadata.severity
| order by severity asc
Cost related queries
Resources
| where type =~ 'microsoft.compute/virtualmachines'
| extend size = tostring(properties.hardwareProfile.vmSize)
| summarize count() by size, subscriptionId
| order by count_ desc
Resources
| where type =~ 'microsoft.sql/servers/databases'
| where name != 'master'
| project name, resourceGroup, subscriptionId,
tier = sku.tier, capacity = sku.capacity
| order by capacity desc
Resource Graph does not hold costs, and it does hold the sizes and tiers that drive them. Combined with the pricing you know, it finds oversized resources quickly.
From the CLI and PowerShell
az graph query -q "Resources | where type =~ 'microsoft.storage/storageaccounts' | project name, resourceGroup" --output table
# across specific subscriptions
az graph query -q "Resources | summarize count() by type" \
--subscriptions 0000... 1111... --output table
# paging, since results are capped per request
az graph query -q "Resources | project name, type" --first 1000 --skip 0
Search-AzGraph -Query "Resources | where type =~ 'microsoft.compute/disks' | where properties.diskState == 'Unattached'" -First 500
Results are paged, currently at 1000 per request. A query returning more needs --first and --skip, and it is easy to think there are only a thousand resources when there are not.
Building a report
az graph query -q "
Resources
| where type =~ 'microsoft.compute/disks'
| where properties.diskState == 'Unattached'
| extend sizeGb = toint(properties.diskSizeGB)
| project name, resourceGroup, subscriptionId, sizeGb, sku = sku.name
" --output json > unattached-disks.json
jq -r '.data[] | [.name, .resourceGroup, .sizeGb, .sku] | @csv' unattached-disks.json
Running a set of these on a schedule and comparing to last month is a cheap governance practice that finds drift nobody would otherwise notice.
Limits worth knowing
Read only. It cannot change anything.
Eventually consistent, usually within seconds and occasionally minutes.
Not every property is indexed, and nested ones sometimes need tostring().
1000 results per page.
Scoped to what your identity can read.
That last point matters for reporting: a query returns what you can see, so a report from an account with narrow access silently under reports.
Quick recap
- Resource Graph indexes every subscription you can read and queries it in seconds.
- Use
=~for resource types, since the index stores them lowercase. mv-expandover NSG rules finds management ports open to the internet across the whole estate.- Join to
ResourceContainersto turn subscription GUIDs into names. - Results are paged at 1000, so a large query needs
--firstand--skip. - It is read only and eventually consistent, so it is for reporting rather than control.
- A query returns only what your identity can read, so narrow access silently under reports.
// az resource list queries ONE subscription and returns limited properties.
// RESOURCE GRAPH INDEXES EVERYTHING you can read, ACROSS EVERY
// SUBSCRIPTION, and answers in about a second.
// az extension add --name resource-graph
// az graph query -q "Resources | summarize count() by type" --output table
//
// TABLES: Resources, ResourceContainers (subscriptions and groups),
// PolicyResources, SecurityResources, AdvisorResources, HealthResources.
//
// USE =~ FOR TYPES. The index stores them LOWERCASE, and this saves a whole
// class of empty result sets.
Resources
| where type =~ 'microsoft.compute/virtualmachines'
| project name, resourceGroup, subscriptionId,
size = properties.hardwareProfile.vmSize
//
// THE SINGLE MOST USEFUL SECURITY QUERY HERE. Every NSG allowing SSH or RDP
// from the internet, across the ENTIRE ESTATE, in seconds:
Resources
| where type =~ 'microsoft.network/networksecuritygroups'
| mv-expand rule = properties.securityRules
| where rule.properties.access == 'Allow'
| where rule.properties.direction == 'Inbound'
| where rule.properties.sourceAddressPrefix in ('*', 'Internet', '0.0.0.0/0')
| where rule.properties.destinationPortRange in ('22', '3389')
| project nsg = name, resourceGroup, subscriptionId, ruleName = tostring(rule.name)
//
// UNATTACHED DISKS, which quietly cost money every month:
Resources
| where type =~ 'microsoft.compute/disks'
| where properties.diskState == 'Unattached'
| extend sizeGb = toint(properties.diskSizeGB)
| summarize totalGb = sum(sizeGb), count = count() by subscriptionId
| order by totalGb desc
//
// STORAGE ACCOUNTS STILL ACCEPTING LONG LIVED ACCOUNT KEYS:
Resources
| where type =~ 'microsoft.storage/storageaccounts'
| where properties.allowSharedKeyAccess != false
| project name, resourceGroup, subscriptionId, publicAccess = properties.publicNetworkAccess
//
// UNTAGGED RESOURCES, usually a governance problem:
Resources
| where isnull(tags) or array_length(bag_keys(tags)) == 0
| summarize count() by type, subscriptionId
//
// JOIN TO ResourceContainers TO TURN SUBSCRIPTION GUIDs INTO NAMES, which
// is what makes any cross subscription report readable:
Resources
| where type =~ 'microsoft.compute/virtualmachines'
| project vmName = name, resourceGroup, subscriptionId
| join kind=leftouter (
ResourceContainers
| where type =~ 'microsoft.resources/subscriptions'
| project subscriptionId, subscriptionName = name
) on subscriptionId
//
// LIMITS WORTH KNOWING:
// READ ONLY. It cannot change anything.
// EVENTUALLY CONSISTENT, usually seconds, occasionally minutes.
// 1000 RESULTS PER PAGE: use --first and --skip, or you will believe
// there are a thousand resources when there are forty thousand.
// SCOPED TO WHAT YOUR IDENTITY CAN READ, so a report from an account with
// narrow access SILENTLY UNDER REPORTS.
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready