Read-only: changes nothing, sends nothing to me
Find what you’re paying for that nobody uses
Read-only: changes nothing, sends nothing to me
A free, read-only query you paste into your own Azure portal. In about two minutes it lists disks attached to nothing, unused public IP addresses, empty load balancers and other leftovers, across every subscription you can see.
On a phone? The query runs in the Azure portal, so open this page on your computer. Email yourself the link
About two minutes
Run it
Open Resource Graph Explorer
In the Azure portal, type “Resource Graph Explorer” in the search bar at the top and open it.
Check the scope
Above the query editor, the scope should show the directory or subscriptions you want to check. The query sees only what your account can read.
Paste the query
Copy the query below and paste it into the editor, replacing anything already there.
Run it
Select Run query. The results appear under the editor, one row per item. To keep them, select Download formatted results as CSV.
| Finding | ResourceName | Sku | SizeGB |
|---|---|---|---|
| Disk not attached to any VM | wa-orphan-disk-3 | StandardSSD_LRS | 32 |
| Public IP address with nothing attached | wa-pip-orphan | Standard | |
| Load balancer with no backend members | wa-lb-empty | Standard | |
| Disk snapshot: is it still needed? | wa-snap-01 | Standard_LRS | 128 |
| Network security group not used anywhere (free; clutter) | wa-nsg-orphan |
82 lines; Copy query copies all of them
The query
// Find what you're paying for that nobody uses: a free, read-only Azure Resource Graph query.
// From rohitpasham.com/unused-resources.html. It lists resources and changes nothing.
// Paste into Azure portal > Resource Graph Explorer > Run query. Covers every subscription you can see.
// One row per unused or orphaned resource. Check every row before deleting anything.
// Disks not attached to any VM (Site Recovery replicas excluded), and disk snapshots
resources
| where type in~ ('microsoft.compute/disks', 'microsoft.compute/snapshots')
| extend tagText = tolower(tostring(tags))
| extend Check = case(
type =~ 'microsoft.compute/snapshots', 'C7',
isempty(managedBy) and tostring(properties.diskState) != 'ActiveSAS' and tostring(tags['ASR']) !~ 'true'
and tagText !contains 'asr-replicadisk' and tagText !contains 'asrseeddisk', 'C1',
'')
| where isnotempty(Check)
| project Check, Kind = iff(Check == 'C7', 'snapshot', 'disk'), ResourceId = tolower(id), ResourceName = name,
ResourceGroup = resourceGroup, SubscriptionId = subscriptionId, Location = location, Sku = tostring(sku.name),
SizeGB = toint(properties.diskSizeGB), Incremental = tobool(properties.incremental),
TimeCreated = tostring(properties.timeCreated), Source = tolower(tostring(properties.creationData.sourceResourceId))
// Public IP addresses with nothing attached; unused network interfaces, security groups and route tables
| union (resources
| where type in~ ('microsoft.network/publicipaddresses', 'microsoft.network/networkinterfaces',
'microsoft.network/networksecuritygroups', 'microsoft.network/routetables')
| extend Kind = case(type =~ 'microsoft.network/publicipaddresses', 'publicip',
type =~ 'microsoft.network/networkinterfaces', 'nic',
type =~ 'microsoft.network/networksecuritygroups', 'nsg', 'routetable')
| where (Kind == 'publicip' and isempty(properties.ipConfiguration) and isempty(properties.natGateway))
or (Kind == 'nic' and isnull(properties.virtualMachine) and isnull(properties.privateEndpoint))
or (Kind == 'nsg' and isnull(properties.networkInterfaces) and isnull(properties.subnets))
or (Kind == 'routetable' and (isnull(properties.subnets) or array_length(properties.subnets) == 0))
| project Check = iff(Kind == 'publicip', 'C2', 'C4'), Kind, ResourceId = tolower(id), ResourceName = name,
ResourceGroup = resourceGroup, SubscriptionId = subscriptionId, Location = location, Sku = tostring(sku.name),
AllocationMethod = tostring(properties.publicIPAllocationMethod))
// Load balancers and Application Gateways whose backend pools have no members
| union (resources
| where (type =~ 'microsoft.network/loadbalancers' and tostring(sku.name) =~ 'Standard')
or type =~ 'microsoft.network/applicationgateways'
| extend Kind = iff(type =~ 'microsoft.network/loadbalancers', 'loadbalancer', 'appgateway'),
Rules = coalesce(array_length(properties.loadBalancingRules), 0) + coalesce(array_length(properties.outboundRules), 0)
+ coalesce(array_length(properties.requestRoutingRules), 0) // null + n = null in KQL
| mv-expand pool = iff(array_length(properties.backendAddressPools) > 0, properties.backendAddressPools, dynamic([{}]))
| extend m = coalesce(array_length(pool.properties.backendIPConfigurations), 0)
+ coalesce(array_length(pool.properties.loadBalancerBackendAddresses), 0)
+ coalesce(array_length(pool.properties.backendAddresses), 0)
| summarize Members = sum(m), Rules = max(Rules)
by id, name, resourceGroup, subscriptionId, location, Kind, Sku = iff(Kind == 'loadbalancer', tostring(sku.name), tostring(properties.sku.name))
| where Members == 0
| project Check = 'C5', Kind, ResourceId = tolower(id), ResourceName = name, ResourceGroup = resourceGroup,
SubscriptionId = subscriptionId, Location = location, Sku, Members, Rules)
// VMs stopped from inside the OS: still billed for compute until deallocated
| union (resources
| where type =~ 'microsoft.compute/virtualmachines'
| extend PowerState = tostring(properties.extended.instanceView.powerState.code)
| where PowerState =~ 'PowerState/stopped'
| project Check = 'C3', Kind = 'vm', ResourceId = tolower(id), ResourceName = name, ResourceGroup = resourceGroup,
SubscriptionId = subscriptionId, Location = location, VmSize = tostring(properties.hardwareProfile.vmSize), PowerState)
// SQL elastic pools with no databases
| union (resources
| where type =~ 'microsoft.sql/servers/elasticpools'
| extend poolId = tolower(id)
| join kind = leftouter (resources
| where type =~ 'microsoft.sql/servers/databases'
| extend poolId = tolower(tostring(properties.elasticPoolId))
| where isnotempty(poolId)
| summarize Databases = count() by poolId) on poolId
| where isnull(Databases) or Databases == 0
| project Check = 'C6', Kind = 'elasticpool', ResourceId = poolId, ResourceName = name, ResourceGroup = resourceGroup,
SubscriptionId = subscriptionId, Location = location, Sku = tostring(sku.name), Tier = tostring(sku.tier),
Capacity = toint(sku.capacity))
// Plain-English label for each row
| extend Finding = case(
Check == 'C1', 'Disk not attached to any VM',
Check == 'C7', 'Disk snapshot: is it still needed?',
Check == 'C2', 'Public IP address with nothing attached',
Check == 'C4' and Kind == 'nic', 'Network interface not attached to anything (free; clutter)',
Check == 'C4' and Kind == 'nsg', 'Network security group not used anywhere (free; clutter)',
Check == 'C4', 'Route table not used by any subnet (free; clutter)',
Check == 'C5' and Kind == 'appgateway', 'Application Gateway with no backend members',
Check == 'C5', 'Load balancer with no backend members',
Check == 'C3', 'VM stopped from inside the OS: still billed until deallocated',
Check == 'C6', 'SQL elastic pool with no databases',
'')
| project Finding, ResourceName, ResourceGroup, SubscriptionId, Location, Sku, SizeGB, TimeCreated, Members, Rules, VmSize, PowerState, ResourceId
Check every row before deleting anything
What each row means
Check every row before deleting anything
The first column of each result, Finding, says which of these it is.
| Finding | What it costs | Check before deleting |
|---|---|---|
| Disk not attached to any VM | Billed every month for its full size, attached or not. | Who owns it, and when it was last attached. Take a snapshot first if you’re unsure. |
| Disk snapshot: is it still needed? | Billed every month for the data it holds. | Whether it belongs to a backup or retention policy. |
| Public IP address with nothing attached | A static IP is billed by the hour whether anything uses it or not. | That no DNS record or firewall rule points at it. A released static address can’t be recovered. |
| VM stopped from inside the OS: still billed until deallocated | Shutting down from inside Windows or Linux stops the machine but keeps its compute reserved, and billed. | If it should stay off, stop it from the portal, which deallocates it. Delete it only if nobody needs it. |
| Load balancer or Application Gateway with no backend members | Gateways bill by the hour; Standard load balancers bill for their rules. Both are easy to leave behind when the VMs go. | Whether a deployment is about to add backends to it. |
| SQL elastic pool with no databases | Billed for its capacity, with or without databases in it. | Whether databases are about to be moved into it. |
| Network interface, security group or route table not used (free; clutter) | No charge, but they clutter the estate and hide the items that do cost money. | That nothing is about to be attached to it. |
Left out on purpose
- Disks used by Azure Site Recovery. They look unattached, but deleting one breaks disaster recovery.
- VMs stopped from the portal (deallocated). They aren’t billed for compute.
Test estate, September–October 2026
How this query was tested
It’s the clean-up part of the query used in every assessment. On a real Azure estate with planted waste, it returned exactly the planted disks, snapshots, public IP address, network items and load balancer, and left the Site Recovery disk alone. The stopped-VM check found the VM that had been shut down from inside Windows.
The Application Gateway and SQL pool parts haven’t been run against a planted example in this exact form. Microsoft’s published versions of those checks found the planted gateway and pool.
$600; no invoice under five times the fee
What it can’t tell you
The query finds rows. It doesn’t say what each one costs, which to fix first, or what’s safe to delete in your estate. It also leaves out the checks that usually hold more money, such as Windows and SQL Server licensing, development machines running around the clock, VM sizes and reservations, because those need your cost data and some judgement.
That’s what the assessment does:
- Every finding priced per year from your own cost data, with the arithmetic shown.
- Double counts removed, so nothing is claimed twice.
- Everything ranked by effort and risk, with the portal path or command to act on it.
- The licensing, scheduling and sizing checks this query leaves out.
$600 for the first three clients. No invoice unless the report finds at least five times the fee.
Getting started means sending one line: your approximate monthly Azure spend.