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

  1. Open Resource Graph Explorer

    In the Azure portal, type “Resource Graph Explorer” in the search bar at the top and open it.

  2. 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.

  3. Paste the query

    Copy the query below and paste it into the editor, replacing anything already there.

  4. Run it

    Select Run query. The results appear under the editor, one row per item. To keep them, select Download formatted results as CSV.

What the results look like: the test estate, 9 October 2026
FindingResourceNameSkuSizeGB
Disk not attached to any VMwa-orphan-disk-3StandardSSD_LRS32
Public IP address with nothing attachedwa-pip-orphanStandard
Load balancer with no backend memberswa-lb-emptyStandard
Disk snapshot: is it still needed?wa-snap-01Standard_LRS128
Network security group not used anywhere (free; clutter)wa-nsg-orphan
5 of the 11 rows these checks returned. Highlighted rows bill every month; the last is free clutter. The Site Recovery disk in the same estate wasn’t listed.

82 lines; Copy query copies all of them

The query

unused-resources.kql Download
// 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
The box scrolls; Copy query and Download give all 82 lines. Resource Graph can only read: a query can’t change or delete anything.

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.

FindingWhat it costsCheck before deleting
Disk not attached to any VMBilled 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 attachedA 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 deallocatedShutting 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 membersGateways 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 databasesBilled 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.