INSIGHTS

Find orphaned Azure resources: seven Resource Graph checks

Copyable Resource Graph queries for unattached disks, unused IPs, orphaned NICs, old snapshots, empty plans, idle gateways and backups of deleted VMs.

Patrick Rezende

·

Orphaned Azure resources are disks, IP addresses, network interfaces, snapshots, plans, gateways and backups left behind when the thing they served was deleted. Azure does not clean them up for you. A few Azure Resource Graph queries, grouped into seven checks below, find them across every subscription in minutes. The harder part is deleting them safely, which the second half of this article covers step by step.

Prices below are Azure list prices for East US, pay-as-you-go, as of October 2026, from the Azure Retail Prices API.

Why do orphaned resources pile up?

When you delete a virtual machine, Azure keeps its managed disks by default. Microsoft's own documentation explains this as protection against accidental data loss, and adds that you continue to pay for those disks. The same pattern repeats for public IPs, network interfaces, snapshots taken before a change, and the App Service plan whose last app was moved. Each one made sense when it was created. Nobody owns it after the project ends.

In mid-market tenants we usually find orphans in proportion to how often teams rebuild things by hand. Infrastructure-as-code deployments that destroy what they created leave fewer. Portal-built environments leave the most.

What does each kind of orphan cost?

Orphan

What makes it orphaned

What it costs while orphaned

Managed disk

Disk state is Unattached

Full provisioned tier price: a P30 (1 TiB Premium SSD) is about $135 a month

Public IP address

No IP configuration and no NAT gateway

Standard static IP: about $3.65 a month each, plus an address you might still depend on

Network interface

Attached to no VM, private endpoint or private link service

No separate line on the Virtual Network price list; it holds a private IP and can block subnet changes

Snapshot

Old, or its source disk is gone

Used size, at about $0.05 per GB-month on Standard storage or $0.132 on Premium

App Service plan

Zero apps on a paid tier

Every instance at full tier price, as if apps were running

Load balancer or NAT gateway

Nothing behind it

NAT gateway: hourly fee regardless of subnets. Standard load balancer: hourly fee only if rules exist

Backup item

Source VM deleted

Protected-instance fee plus backup storage, with no end date

Resource Graph is the Azure service that indexes resources across subscriptions and answers Kusto queries about them. Open Resource Graph Explorer in the portal, set the scope to your tenant or management group, and paste each query below. Two rules of the Resource Graph dialect matter here: join supports only the inner, innerunique, leftouter and fullouter flavours, so "not in" logic is written as a left outer join plus an empty check, and queries through the API are limited to three joins by default.

1. Which managed disks are unattached?

resources
| where type =~ 'microsoft.compute/disks'
| where properties.diskState =~ 'Unattached'
| project name, resourceGroup, subscriptionId,
    sku = tostring(sku.name),
    sizeGB = toint(properties.diskSizeGB),
    created = todatetime(properties.timeCreated),
    lastDetached = todatetime(properties.LastOwnershipUpdateTime),
    tags
| order by sizeGB desc
resources
| where type =~ 'microsoft.compute/disks'
| where properties.diskState =~ 'Unattached'
| project name, resourceGroup, subscriptionId,
    sku = tostring(sku.name),
    sizeGB = toint(properties.diskSizeGB),
    created = todatetime(properties.timeCreated),
    lastDetached = todatetime(properties.LastOwnershipUpdateTime),
    tags
| order by sizeGB desc
resources
| where type =~ 'microsoft.compute/disks'
| where properties.diskState =~ 'Unattached'
| project name, resourceGroup, subscriptionId,
    sku = tostring(sku.name),
    sizeGB = toint(properties.diskSizeGB),
    created = todatetime(properties.timeCreated),
    lastDetached = todatetime(properties.LastOwnershipUpdateTime),
    tags
| order by sizeGB desc

diskState has more values than attached and unattached. Reserved means the disk is attached to a deallocated VM, which is a different problem: the VM is off but its storage still bills. We cover that in why your dev VMs are still billing.

Before deleting, check two things. A disk detached in the last few days may belong to a migration in progress, so use lastDetached to leave recent ones alone. And Azure Site Recovery replica disks sit unattached in the DR region by design until a failover. Azure Advisor excludes them from its own unattached-disk recommendation. This query does not, so check the replicated items in your Recovery Services vaults before touching anything in a DR region.

2. Which public IP addresses are not associated?

resources
| where type =~ 'microsoft.network/publicipaddresses'
| where isnull(properties.ipConfiguration) and isnull(properties.natGateway)
| project name, resourceGroup, subscriptionId,
    sku = tostring(sku.name),
    allocation = tostring(properties.publicIPAllocationMethod),
    address = tostring(properties.ipAddress),
    tags
resources
| where type =~ 'microsoft.network/publicipaddresses'
| where isnull(properties.ipConfiguration) and isnull(properties.natGateway)
| project name, resourceGroup, subscriptionId,
    sku = tostring(sku.name),
    allocation = tostring(properties.publicIPAllocationMethod),
    address = tostring(properties.ipAddress),
    tags
resources
| where type =~ 'microsoft.network/publicipaddresses'
| where isnull(properties.ipConfiguration) and isnull(properties.natGateway)
| project name, resourceGroup, subscriptionId,
    sku = tostring(sku.name),
    allocation = tostring(properties.publicIPAllocationMethod),
    address = tostring(properties.ipAddress),
    tags

An IP attached to a VM, load balancer frontend, VPN gateway, Bastion host or firewall has an ipConfiguration pointing at that resource. One with neither that nor a NAT gateway is serving nothing.

Basic SKU public IPs were retired on September 30, 2025, so most results will be Standard and static, and Azure's pricing FAQ says a static public IP is charged irrespective of the associated resource. The dollar amount per address is small. The risk is not. Microsoft notes that a static address is released when the resource is deleted and cannot be recovered. Search your DNS zones, partner allowlists and firewall rules for the address value before deleting it.

3. Which network interfaces are orphaned?

resources
| where type =~ 'microsoft.network/networkinterfaces'
| where isnull(properties.virtualMachine)
    and isnull(properties.privateEndpoint)
    and isnull(properties.privateLinkService)
| project name, resourceGroup, subscriptionId,
    privateIp = tostring(properties.ipConfigurations[0].properties.privateIPAddress),
    tags
resources
| where type =~ 'microsoft.network/networkinterfaces'
| where isnull(properties.virtualMachine)
    and isnull(properties.privateEndpoint)
    and isnull(properties.privateLinkService)
| project name, resourceGroup, subscriptionId,
    privateIp = tostring(properties.ipConfigurations[0].properties.privateIPAddress),
    tags
resources
| where type =~ 'microsoft.network/networkinterfaces'
| where isnull(properties.virtualMachine)
    and isnull(properties.privateEndpoint)
    and isnull(properties.privateLinkService)
| project name, resourceGroup, subscriptionId,
    privateIp = tostring(properties.ipConfigurations[0].properties.privateIPAddress),
    tags

A detached NIC does not appear as its own charge. It still matters for three reasons: it holds a private IP in a subnet, it can block deletion or resizing of that subnet, and it is the clearest sign that a decommission was done halfway. Where you find orphaned NICs, look for the disks and IPs that went with them.

4. Which snapshots are old, and is their source disk still there?

resources
| where type =~ 'microsoft.compute/snapshots'
| extend created = todatetime(properties.timeCreated)
| where created < ago(90d)
| extend sourceId = tolower(tostring(properties.creationData.sourceResourceId))
| join kind=leftouter (
    resources
    | where type =~ 'microsoft.compute/disks'
    | project sourceId = tolower(id), sourceDisk = name
  ) on sourceId
| project name, resourceGroup, subscriptionId, created,
    sizeGB = toint(properties.diskSizeGB),
    incremental = tobool(properties.incremental),
    storage = tostring(sku.name),
    sourceStillExists = isnotempty(sourceDisk)
| order by created asc
resources
| where type =~ 'microsoft.compute/snapshots'
| extend created = todatetime(properties.timeCreated)
| where created < ago(90d)
| extend sourceId = tolower(tostring(properties.creationData.sourceResourceId))
| join kind=leftouter (
    resources
    | where type =~ 'microsoft.compute/disks'
    | project sourceId = tolower(id), sourceDisk = name
  ) on sourceId
| project name, resourceGroup, subscriptionId, created,
    sizeGB = toint(properties.diskSizeGB),
    incremental = tobool(properties.incremental),
    storage = tostring(sku.name),
    sourceStillExists = isnotempty(sourceDisk)
| order by created asc
resources
| where type =~ 'microsoft.compute/snapshots'
| extend created = todatetime(properties.timeCreated)
| where created < ago(90d)
| extend sourceId = tolower(tostring(properties.creationData.sourceResourceId))
| join kind=leftouter (
    resources
    | where type =~ 'microsoft.compute/disks'
    | project sourceId = tolower(id), sourceDisk = name
  ) on sourceId
| project name, resourceGroup, subscriptionId, created,
    sizeGB = toint(properties.diskSizeGB),
    incremental = tobool(properties.incremental),
    storage = tostring(sku.name),
    sourceStillExists = isnotempty(sourceDisk)
| order by created asc

Snapshots are billed on the used portion of the disk, not on diskSizeGB, so treat that column as an upper bound. Two columns tell you where the money is. storage showing Premium_LRS means the snapshot is kept on Premium storage, about 2.6 times the Standard price, and Microsoft recommends Standard storage for snapshots regardless of the source disk type. sourceStillExists = false means the snapshot is the only remaining copy of that disk, which makes it either valuable or forgotten. Find out which before deleting.

5. Which App Service plans have no apps?

resources
| where type =~ 'microsoft.web/serverfarms'
| where toint(properties.numberOfSites) == 0
| where tostring(sku.tier) !in~ ('Free', 'Shared', 'Dynamic')
| project name, resourceGroup, subscriptionId,
    tier = tostring(sku.tier),
    size = tostring(sku.name),
    instances = toint(sku.capacity),
    linux = tobool(properties.reserved)
resources
| where type =~ 'microsoft.web/serverfarms'
| where toint(properties.numberOfSites) == 0
| where tostring(sku.tier) !in~ ('Free', 'Shared', 'Dynamic')
| project name, resourceGroup, subscriptionId,
    tier = tostring(sku.tier),
    size = tostring(sku.name),
    instances = toint(sku.capacity),
    linux = tobool(properties.reserved)
resources
| where type =~ 'microsoft.web/serverfarms'
| where toint(properties.numberOfSites) == 0
| where tostring(sku.tier) !in~ ('Free', 'Shared', 'Dynamic')
| project name, resourceGroup, subscriptionId,
    tier = tostring(sku.tier),
    size = tostring(sku.name),
    instances = toint(sku.capacity),
    linux = tobool(properties.reserved)

Microsoft's App Service cost guide is explicit: when you delete all apps in a plan, the plan keeps accruing charges at its tier and instance count. The query excludes the Free and Shared tiers and the Dynamic (Consumption) tier, which cost nothing when empty. Multiply instances by the tier price for the size: a P1v3 Windows instance lists at about $230 a month. Confirm in the plan's Apps blade that it is really empty, then delete the plan or scale it to Free if you want to keep the name.

6. Which load balancers and NAT gateways have nothing behind them?

resources
| where type =~ 'microsoft.network/loadbalancers' and sku.name =~ 'Standard'
| where resourceGroup !startswith 'mc_'
| extend rules = coalesce(array_length(properties.loadBalancingRules), 0)
    + coalesce(array_length(properties.outboundRules), 0)
| mv-expand pool = iff(array_length(properties.backendAddressPools) > 0,
    properties.backendAddressPools, dynamic([{}]))
| extend members = coalesce(array_length(pool.properties.backendIPConfigurations), 0)
    + coalesce(array_length(pool.properties.loadBalancerBackendAddresses), 0)
| summarize members = sum(members) by id, name, resourceGroup, subscriptionId, rules
| where members == 0 and rules > 0
resources
| where type =~ 'microsoft.network/loadbalancers' and sku.name =~ 'Standard'
| where resourceGroup !startswith 'mc_'
| extend rules = coalesce(array_length(properties.loadBalancingRules), 0)
    + coalesce(array_length(properties.outboundRules), 0)
| mv-expand pool = iff(array_length(properties.backendAddressPools) > 0,
    properties.backendAddressPools, dynamic([{}]))
| extend members = coalesce(array_length(pool.properties.backendIPConfigurations), 0)
    + coalesce(array_length(pool.properties.loadBalancerBackendAddresses), 0)
| summarize members = sum(members) by id, name, resourceGroup, subscriptionId, rules
| where members == 0 and rules > 0
resources
| where type =~ 'microsoft.network/loadbalancers' and sku.name =~ 'Standard'
| where resourceGroup !startswith 'mc_'
| extend rules = coalesce(array_length(properties.loadBalancingRules), 0)
    + coalesce(array_length(properties.outboundRules), 0)
| mv-expand pool = iff(array_length(properties.backendAddressPools) > 0,
    properties.backendAddressPools, dynamic([{}]))
| extend members = coalesce(array_length(pool.properties.backendIPConfigurations), 0)
    + coalesce(array_length(pool.properties.loadBalancerBackendAddresses), 0)
| summarize members = sum(members) by id, name, resourceGroup, subscriptionId, rules
| where members == 0 and rules > 0

The iff keeps load balancers with no backend pools at all in the results, because mv-expand drops rows whose array is empty. The query keeps only load balancers with rules, since Microsoft's pricing FAQ says a Standard load balancer has no hourly charge when no rules are configured. It skips MC_ resource groups, where Azure Kubernetes Service (AKS) manages its own load balancer. Never edit those by hand.

resources
| where type =~ 'microsoft.network/natgateways'
| where coalesce(array_length(properties.subnets), 0) == 0
| project name, resourceGroup, subscriptionId, tags
resources
| where type =~ 'microsoft.network/natgateways'
| where coalesce(array_length(properties.subnets), 0) == 0
| project name, resourceGroup, subscriptionId, tags
resources
| where type =~ 'microsoft.network/natgateways'
| where coalesce(array_length(properties.subnets), 0) == 0
| project name, resourceGroup, subscriptionId, tags

NAT gateways bill hourly whether or not a subnet or public IP is attached, per the pricing FAQ. For VPN gateways, use Azure Advisor, which already flags virtual network gateways with zero connections. A VpnGw1 lists at $0.19 an hour, about $139 a month. Check for point-to-site users before deleting one, since they do not show up as connection resources.

7. Which backup items belong to VMs that no longer exist?

recoveryservicesresources
| where type =~ 'microsoft.recoveryservices/vaults/backupfabrics/protectioncontainers/protecteditems'
| where properties.backupManagementType =~ 'AzureIaasVM'
| extend vmId = tolower(tostring(properties.sourceResourceId))
| join kind=leftouter (
    resources
    | where type =~ 'microsoft.compute/virtualmachines'
    | project vmId = tolower(id), vmName = name
  ) on vmId
| where isempty(vmName)
| project item = tostring(properties.friendlyName),
    vault = tostring(split(id, '/')[8]),
    state = tostring(properties.currentProtectionState),
    lastRecoveryPoint = todatetime(properties.lastRecoveryPoint),
    sourceVm = vmId
recoveryservicesresources
| where type =~ 'microsoft.recoveryservices/vaults/backupfabrics/protectioncontainers/protecteditems'
| where properties.backupManagementType =~ 'AzureIaasVM'
| extend vmId = tolower(tostring(properties.sourceResourceId))
| join kind=leftouter (
    resources
    | where type =~ 'microsoft.compute/virtualmachines'
    | project vmId = tolower(id), vmName = name
  ) on vmId
| where isempty(vmName)
| project item = tostring(properties.friendlyName),
    vault = tostring(split(id, '/')[8]),
    state = tostring(properties.currentProtectionState),
    lastRecoveryPoint = todatetime(properties.lastRecoveryPoint),
    sourceVm = vmId
recoveryservicesresources
| where type =~ 'microsoft.recoveryservices/vaults/backupfabrics/protectioncontainers/protecteditems'
| where properties.backupManagementType =~ 'AzureIaasVM'
| extend vmId = tolower(tostring(properties.sourceResourceId))
| join kind=leftouter (
    resources
    | where type =~ 'microsoft.compute/virtualmachines'
    | project vmId = tolower(id), vmName = name
  ) on vmId
| where isempty(vmName)
| project item = tostring(properties.friendlyName),
    vault = tostring(split(id, '/')[8]),
    state = tostring(properties.currentProtectionState),
    lastRecoveryPoint = todatetime(properties.lastRecoveryPoint),
    sourceVm = vmId

This is often the most expensive orphan per item and the least visible. Microsoft documents that when a VM is deleted without stopping protection, its backup item stays active, expired recovery points are cleaned up by policy, and the most recent recovery point is retained forever and charged at backup pricing.

Two cautions. A VM moved to another resource group or subscription gets a new ID, so it appears here too; check sourceVm against the move history. And an item in ProtectionStopped state may have been kept deliberately for retention, legal or audit reasons. Ask before deleting. To remove an item, use "Stop protection and delete backup data" in the vault; the data stays in soft delete for 14 days. Recovery points already moved to the Archive tier carry a 180-day early deletion charge.

How do you delete orphaned resources safely?

Finding orphans takes an afternoon. Deleting them is where teams get nervous, with reason: a deleted managed disk cannot be recovered, and neither can a static IP address. Use the same sequence every time.

  1. Export the results. Download each query's output as CSV from Resource Graph Explorer. That file is your record of what existed, with sizes and tags.

  2. Find the owner. Read the tags first. If there are none, the activity log records who created a resource, but Azure keeps activity log events for only 90 days. Older than that, ask the team that owns the resource group.

  3. Check for locks. Run az lock list --resource-group <name>. Locks set on a resource group or subscription are inherited by everything inside it, and a CanNotDelete lock will stop the delete. A lock on an orphan usually means someone wanted it kept; find out why before removing the lock.

  4. Keep a cheap copy where data is involved. Advisor's own guidance for unattached disks is to snapshot before deleting. An incremental snapshot is billed on used size at Standard rates.

az snapshot create -g <resource-group> -n <disk-name>-predelete \
  --source <disk-resource-id> --incremental true
az snapshot create -g <resource-group> -n <disk-name>-predelete \
  --source <disk-resource-id> --incremental true
az snapshot create -g <resource-group> -n <disk-name>-predelete \
  --source <disk-resource-id> --incremental true
  1. Tag and wait. Add a tag such as pending-delete: 2026-11-15 and tell the owners. Two weeks of silence is a stronger signal than any query.

  2. Delete in dependency order. Backup protection first, then VMs if any, then disks, NICs, and public IPs last, once nothing references them.

  3. Verify on the bill. Filter Cost analysis by resource group next month and confirm the meters stopped. A deletion that did not reach the invoice did not happen.

Run all seven checks monthly. The orphans come back every time someone decommissions something in a hurry, which is why these checks are part of the Azure cost optimization checklist and why we run them weekly in Elos. Advisor will catch some of them, but as we wrote in what Azure Advisor does not do, a finding with no owner and no follow-up tends to survive the quarter.

Frequently asked questions

What is an orphaned resource in Azure?

An orphaned resource is one that was created to serve something else, such as a disk, IP address or network interface for a virtual machine, and was left behind when that thing was deleted. Azure does not delete dependent resources by default when you delete a VM, so they keep existing, and many of them keep billing, until someone removes them.

Do unattached managed disks cost money in Azure?

Yes. A managed disk is billed on its provisioned size tier whether or not it is attached to a virtual machine, and Microsoft's documentation states that you continue to pay for disks after the VM is deleted. As of October 2026, a 1 TiB Premium SSD (P30) lists at about $135 a month in East US on pay-as-you-go.

Is it safe to delete orphaned Azure resources?

Usually, if you follow a procedure: confirm the owner, check for resource locks, take an incremental snapshot of any disk, release IP addresses only after checking DNS and allowlists, and wait a short notice period with a tag on the resource. Deleting a managed disk or a static public IP cannot be undone, so the checks happen before the delete.

Does deleting a VM stop its Azure Backup charges?

No. If a VM is deleted without stopping protection, Microsoft documents that the backup item stays active, the most recent recovery point is retained indefinitely, and you keep paying backup charges. Stop protection and delete the backup data in the vault. The data then stays in soft delete for 14 days before it is permanently removed.

Sources

See where your Azure money is leaking.

Start with the free Score: ten questions, no tenant access. Or go straight to the 20-minute call.