Azure Resource Graph (ARG) answers "where is it, how many are there, and who changed it" across every subscription you can read, in seconds. It uses KQL, like Log Analytics, but it queries resource configuration, not logs or metrics. Keep these queries in a file and adapt them.
1. Running queries
Portal: Resource Graph Explorer. CLI and PowerShell:
az extension add --name resource-graph
az graph query -q "resources | summarize count() by type | order by count_ desc" --first 1000 -o table
# Scope to a management group or a list of subscriptions
az graph query -q "resources | count" --management-groups mg-prod
az graph query -q "resources | count" --subscriptions <sub-id-1> <sub-id-2>
Install-Module Az.ResourceGraph -Scope CurrentUser
Search-AzGraph -Query "resources | summarize count() by type" -First 1000 -UseTenantScope
Tip
The CLI returns 100 rows by default. Use
--first(up to 1000) and--skip-tokenfrom the previous result to page further. In PowerShell, use-Firstand-SkipToken. Without-UseTenantScopeor-ManagementGroup,Search-AzGraphonly searches the subscriptions in your current context.
Tables you will use most
| Table | Contains |
|---|---|
resources | Almost every ARM resource and its properties |
resourcecontainers | Subscriptions, resource groups, management groups |
resourcechanges | Create/update/delete history for resources (recent window only) |
healthresources | Resource Health availability status |
policyresources | Azure Policy compliance states |
advisorresources | Azure Advisor recommendations |
recoveryservicesresources | Azure Backup protected items and jobs |
2. Inventory
VMs with size, OS and power state
resources
| where type =~ "microsoft.compute/virtualmachines"
| extend size = tostring(properties.hardwareProfile.vmSize),
os = tostring(properties.storageProfile.osDisk.osType),
power = tostring(properties.extended.instanceView.powerState.code)
| project name, resourceGroup, subscriptionId, location, size, os, power
| order by power asc, name asc
power returns values such as PowerState/running and PowerState/deallocated. A VM that is PowerState/stopped (shut down from inside the guest) is still billed for compute.
Subscription names next to resource counts
resources
| summarize resourceCount = count() by subscriptionId
| join kind=leftouter (
resourcecontainers
| where type == "microsoft.resources/subscriptions"
| project subscriptionId, subscriptionName = name
) on subscriptionId
| project subscriptionName, subscriptionId, resourceCount
| order by resourceCount desc
Resources missing an owner tag
resources
| where isempty(tostring(tags["owner"]))
| summarize missing = count() by type, resourceGroup
| order by missing desc
Warning
Tag keys are matched as written. If some teams use
Ownerand othersowner, check both or normalise them with Azure Policy before you trust the report.
3. Orphaned resources that still cost money
Unattached managed disks
resources
| where type =~ "microsoft.compute/disks"
| where properties.diskState == "Unattached"
| project name, resourceGroup, sku = tostring(sku.name),
sizeGB = toint(properties.diskSizeGB), created = todatetime(properties.timeCreated)
| order by sizeGB desc
Public IPs that are not associated with anything
resources
| where type =~ "microsoft.network/publicipaddresses"
| where isnull(properties.ipConfiguration) and isnull(properties.natGateway)
| project name, resourceGroup, ip = tostring(properties.ipAddress), sku = tostring(sku.name)
NICs with no VM and no private endpoint
resources
| where type =~ "microsoft.network/networkinterfaces"
| where isnull(properties.virtualMachine) and isnull(properties.privateEndpoint)
| project name, resourceGroup, location
Caution
"Unattached" is not the same as "safe to delete". A disk may be a deliberate copy kept before a repair, or the OS disk of a VM someone plans to rebuild. Confirm with the owner, snapshot if in doubt, and only then delete.
4. Security and resilience checks
NSG rules that allow SSH or RDP from the internet
resources
| where type =~ "microsoft.network/networksecuritygroups"
| mv-expand rule = properties.securityRules
| where rule.properties.direction == "Inbound" and rule.properties.access == "Allow"
| where tostring(rule.properties.sourceAddressPrefix) in ("*", "Internet", "0.0.0.0/0")
| extend port = tostring(rule.properties.destinationPortRange)
| where port in ("*", "22", "3389")
| project nsg = name, resourceGroup, rule = tostring(rule.name), port,
priority = toint(rule.properties.priority)
This only checks the single-value destinationPortRange field. Rules that list several ports use destinationPortRanges (an array); add an mv-expand on that if your team writes rules that way.
Storage accounts with weak settings
resources
| where type =~ "microsoft.storage/storageaccounts"
| project name, resourceGroup,
publicNetwork = tostring(properties.publicNetworkAccess),
blobPublicAccess = tostring(properties.allowBlobPublicAccess),
minTls = tostring(properties.minimumTlsVersion),
httpsOnly = tostring(properties.supportsHttpsTrafficOnly)
| where blobPublicAccess == "true" or minTls != "TLS1_2" or httpsOnly == "false"
VMs that are not protected by Azure Backup
resources
| where type =~ "microsoft.compute/virtualmachines"
| extend vmId = tolower(id)
| join kind=leftouter (
recoveryservicesresources
| where type =~ "microsoft.recoveryservices/vaults/backupfabrics/protectioncontainers/protecteditems"
| where properties.backupManagementType == "AzureIaasVM"
| project protectedVmId = tolower(tostring(properties.sourceResourceId))
) on $left.vmId == $right.protectedVmId
| where isempty(protectedVmId)
| project name, resourceGroup, subscriptionId, location
Lowercase both sides of the join. Resource IDs are not always returned in the same case, and a mismatch makes protected VMs look unprotected.
5. Health, compliance and Advisor
Resources that Resource Health does not report as Available
healthresources
| where type =~ "microsoft.resourcehealth/availabilitystatuses"
| extend state = tostring(properties.availabilityState)
| where state != "Available"
| project id, state, reason = tostring(properties.reasonType)
Non-compliant resources by policy
policyresources
| where type =~ "microsoft.policyinsights/policystates"
| where properties.complianceState == "NonCompliant"
| summarize nonCompliant = count() by policy = tostring(properties.policyDefinitionName)
| order by nonCompliant desc
Cost recommendations from Advisor
advisorresources
| where type == "microsoft.advisor/recommendations"
| where properties.category == "Cost"
| project resource = tostring(properties.impactedValue),
problem = tostring(properties.shortDescription.problem),
solution = tostring(properties.shortDescription.solution)
6. Who changed what
During an incident, the first question is usually "what changed?".
resourcechanges
| extend changeTime = todatetime(properties.changeAttributes.timestamp),
target = tostring(properties.targetResourceId),
changeType = tostring(properties.changeType),
changedBy = tostring(properties.changeAttributes.changedBy),
client = tostring(properties.changeAttributes.clientType)
| where changeTime > ago(1d)
| where target has "rg-prod-web"
| project changeTime, changeType, target, changedBy, client, properties.changes
| order by changeTime desc
changedBy holds a UPN or an application ID. System means a platform change not tied to a user action, and Unspecified means no identity information was recorded. Change history only covers a recent window (about 14 days at the time of writing), so for anything older use the Activity Log in Log Analytics.
7. When results look wrong
| Symptom | Likely cause | Fix |
|---|---|---|
| Only 100 rows | CLI default page size | Add --first 1000 and page with --skip-token |
| A subscription is missing | No read access, or PowerShell limited to the current context | Check RBAC; use -UseTenantScope or --management-groups |
| Property is always empty | Wrong path or case in the property name | Run resources | where name == "x" | project properties and copy the exact path |
| Resource changed a minute ago doesn't show yet | ARG is updated shortly after ARM, not instantly | Wait a few minutes or query ARM directly with az resource show |
| Query throttled | Too many queries in a short window from scripts | Batch queries, add retries with back-off, and cache results |
Prevent it next time
- Save the queries you run every week as shared queries in Resource Graph Explorer so the whole team uses the same version.
- Pin the orphaned-resource and NSG queries to a dashboard or an Azure Workbook and review them weekly.
- Turn the checks you keep finding into Azure Policy (deny or audit) so the problem stops being created, instead of being found later.