Skip to content
cost optimization

Azure Orphaned Resources: The Complete Safe Cleanup Guide

Find every common type of Azure orphan with Resource Graph queries, confirm each one is really unused, delete safely with a snapshot and an audit trail, and set ownership rules that stop new orphans.

Updated Cloud Sleuth 15 min read

Updated October 2026. This guide has been rewritten around Azure Resource Graph, which finds orphans across every subscription in one query instead of looping through them. It now covers more resource types, explains the false positives that make cleanup risky, and ends with ownership rules that stop new orphans from appearing.

What counts as an orphaned resource

An orphaned resource is one that still exists, and often still bills, after the thing it was created for is gone. The classic example is a managed disk left behind when a VM is deleted: unless the VM was created with the “delete with VM” option set, Azure deletes the VM and keeps its disks, network interface and public IP.

Orphans pile up for three reasons:

  • Deleting a parent doesn’t delete its children unless someone set it up to.
  • Nothing tells you they’re there. An unattached disk raises no alert. It just appears on the invoice every month.
  • Nobody feels safe deleting them. If you can’t say who created a resource or why, the safe-looking choice is to leave it alone, so it stays forever.

The first two are technical, and this guide’s queries handle them. The third is an ownership problem, and it’s the reason orphan cleanups stall. We’ll come back to it at the end.

How to run the queries in this guide

Every query below is written in KQL for Azure Resource Graph, which indexes resources across all the subscriptions you can read. It’s read-only and fast, and it doesn’t need anything installed in your environment.

You can run them three ways:

  • Azure portal: open Resource Graph Explorer, paste a query, run it, and use Download as CSV.

  • Azure CLI:

    az extension add --name resource-graph
    az graph query -q "$(cat unattached-disks.kql)" --first 1000 -o table
  • PowerShell:

    Search-AzGraph -Query (Get-Content ./unattached-disks.kql -Raw) -First 1000

Results are capped at 1,000 rows per call. If you hit that, page through with the skip token, or run the query one subscription at a time.

Resource Graph tells you what exists, not what it costs. To rank results by cost, use Cost analysis in Cost Management grouped by resource, or join the results to a cost export on the resource ID.

The full orphan inventory

Resource Billed while orphaned? Check before deleting
Unattached managed disks Yes Site Recovery, AKS and backup disks look unattached too
Unassociated public IPs Yes DNS records and partner allowlists
Orphaned network interfaces No Reserved private IPs
Snapshots whose source disk is gone Yes Backup and retention requirements
Empty App Service plans Yes, except Free and Shared tiers Deployment pipelines that recreate apps
Load balancers with no backend pools Standard SKU, usually Rules and frontends still in use
Application gateways with no backend targets Yes WAF policies and certificates
NAT gateways with no subnets Yes Planned network changes
VMs stopped but not deallocated Yes, compute is still billed Whether anyone still needs the VM
NSGs, route tables and availability sets attached to nothing No Nothing, usually: they’re clutter

Start with the ones that cost money. Clear the free ones afterwards, because clutter hides the resources that matter.

1. Unattached managed disks

These are usually the largest orphan cost, because disks are billed by provisioned size whether anything reads them or not.

Resources
| where type =~ "microsoft.compute/disks"
| where tostring(properties.diskState) == "Unattached"
// Disks owned by Site Recovery, AKS persistent volumes and Azure Backup look unattached too
| where not(name endswith "-ASRReplica" or name startswith "ms-asr-" or name startswith "asrseeddisk-")
| where tags !contains "kubernetes.io-created-for-pvc" and tags !contains "ASR-ReplicaDisk"
    and tags !contains "asrseeddisk" and tags !contains "RSVaultBackup"
| project id, name, resourceGroup, subscriptionId, location,
          skuName = tostring(sku.name),
          sizeGB = toint(properties.diskSizeGB),
          unattachedSince = todatetime(properties.LastOwnershipUpdateTime),
          createdAt = todatetime(properties.timeCreated),
          tags
| order by sizeGB desc

Two details make this query safer than the usual managedBy == null check:

  • diskState is more precise than managedBy. A disk attached to a stopped, deallocated VM is in the Reserved state, and a disk someone is exporting through a SAS URL is ActiveSAS. Neither is an orphan, and the query leaves both out.
  • LastOwnershipUpdateTime tells you how long it’s been unattached. Azure updates it whenever the disk is attached, detached, or its VM is deallocated or started. A disk unattached since last week is a very different conversation from one unattached since 2023. The property was added in 2022, so an empty value means “nothing has changed since then”, not “never attached”.

2. Unassociated public IP addresses

Resources
| where type =~ "microsoft.network/publicipaddresses"
| where isnull(properties.ipConfiguration) and isnull(properties.natGateway)
// IPs from a public IP prefix: you pay for the prefix either way
| where isnull(properties.publicIPPrefix)
| project id, name, resourceGroup, subscriptionId, location,
          skuName = tostring(sku.name),
          ipAddress = tostring(properties.ipAddress),
          allocation = tostring(properties.publicIPAllocationMethod),
          fqdn = tostring(properties.dnsSettings.fqdn),
          tags

Public IPs are cheap individually, but a static IP can matter far more than it costs. Before you delete one, search your DNS zones for the address, and ask whether a partner, customer or SaaS vendor has it on an allowlist. Once a static IP is released, you won’t get the same address back.

3. Orphaned network interfaces

Resources
| where type =~ "microsoft.network/networkinterfaces"
| where isnull(properties.virtualMachine)
    and isnull(properties.privateEndpoint)
    and isnull(properties.privateLinkService)
    and coalesce(array_length(properties.hostedWorkloads), 0) == 0
| project id, name, resourceGroup, subscriptionId, location,
          privateIp = tostring(properties.ipConfigurations[0].properties.privateIPAddress),
          tags

NICs don’t cost anything on their own, but each one holds a private IP address in its subnet, and they make inventories harder to read. The filters skip NICs that belong to private endpoints, Private Link services and hosted workloads, which aren’t attached to a VM but aren’t orphans either. If a static private IP on an orphaned NIC is referenced in a firewall rule or config file, release it deliberately rather than by accident.

4. Snapshots whose source disk is gone

Resources
| where type =~ "microsoft.compute/snapshots"
| extend sourceId = tolower(tostring(properties.creationData.sourceResourceId))
| join kind=leftouter (
    Resources
    | where type =~ "microsoft.compute/disks"
    | project sourceId = tolower(id), sourceDiskName = name
  ) on sourceId
| where isempty(sourceDiskName)
| project id, name, resourceGroup, subscriptionId, location,
          sizeGB = toint(properties.diskSizeGB),
          incremental = tobool(properties.incremental),
          createdAt = todatetime(properties.timeCreated),
          sourceId, tags
| order by createdAt asc

A snapshot whose source disk no longer exists isn’t automatically waste. Sometimes it’s the only copy of that data, kept on purpose. What it does mean is that nobody can look at the disk to work out why the snapshot exists, so it needs an owner’s decision. Snapshots created from uploads or other snapshots have a different source and show up here as well, so check the sourceId.

Add | where createdAt < ago(180d) to focus on old snapshots, and check your backup policies and retention requirements before you remove anything.

5. Empty App Service plans

Resources
| where type =~ "microsoft.web/serverfarms"
| where toint(properties.numberOfSites) == 0
| where tostring(sku.tier) !in ("Free", "Shared")
| project id, name, resourceGroup, subscriptionId, location,
          tier = tostring(sku.tier), skuName = tostring(sku.name), tags

App Service plans are billed for their instances, with or without apps on them. An empty plan is usually what’s left after an app was deleted or moved. The exception is a plan that a deployment pipeline deletes apps from and recreates them on, so check recent deployments before you remove it.

6. Load balancers, application gateways and NAT gateways with nothing behind them

Resources
| where type =~ "microsoft.network/loadbalancers"
| where coalesce(array_length(properties.backendAddressPools), 0) == 0
| project id, name, resourceGroup, subscriptionId, location, skuName = tostring(sku.name), tags
Resources
| where type =~ "microsoft.network/natgateways"
| where coalesce(array_length(properties.subnets), 0) == 0
| project id, name, resourceGroup, subscriptionId, location, tags

A load balancer can also have backend pools with nobody in them, which the first query doesn’t catch. For load balancers and application gateways that do have pools, open Backend health (or the pool list) in the portal and check whether any targets are left. Application gateways bill by the hour whether or not anything is behind them, so they’re worth checking by hand even if there are only a few.

NAT gateways are billed hourly too, and one with no subnets serves nothing.

7. VMs that are stopped but not deallocated

Not strictly orphans, but they turn up in every cleanup and they’re often the single biggest line item. A VM shut down from inside the operating system stays allocated, and Azure keeps billing for the compute.

Resources
| where type =~ "microsoft.compute/virtualmachines"
| where tostring(properties.extended.instanceView.powerState.code) =~ "PowerState/stopped"
| project id, name, resourceGroup, subscriptionId, location,
          vmSize = tostring(properties.hardwareProfile.vmSize), tags

Deallocating stops the compute charges without deleting anything, which makes it the lowest-risk change in this guide. Do it as soon as the owner confirms, or as soon as the claim deadline passes (see below).

8. Free leftovers: NSGs, route tables and availability sets

Resources
| where type in~ ("microsoft.network/networksecuritygroups",
                  "microsoft.network/routetables",
                  "microsoft.compute/availabilitysets")
| extend attachments = coalesce(array_length(properties.subnets), 0)
                     + coalesce(array_length(properties.networkInterfaces), 0)
                     + coalesce(array_length(properties.virtualMachines), 0)
| where attachments == 0
| project id, name, type, resourceGroup, subscriptionId, location, tags

These don’t cost anything, but an unattached NSG with permissive rules is a risk if someone attaches it later without reading it, and all of them make the real orphans harder to see. Clear them in bulk once the expensive work is done.

How to confirm something is really orphaned

A query result is a candidate, not a verdict. Before anything is deleted, check three things: dependencies, recent activity and ownership.

Dependencies: what looks unattached but isn’t

These are the false positives that cause outages:

  • Disks managed by another service. Azure Site Recovery replica disks, AKS persistent volumes (the disk is unattached while the pod is scaled to zero, but the volume is still bound) and disks created by Azure Backup restores. The disk query excludes the common naming and tag patterns, but check anything in a resource group that a managed service created (MC_* for AKS, for example).
  • IP addresses with a life outside Azure. DNS records, partner allowlists, VPN peers and firewall rules can all point at a public IP that has nothing attached to it.
  • Things that are idle on purpose. A disaster-recovery standby, a disk kept for a legal hold, a snapshot your retention policy requires.
  • Resources in infrastructure as code. If a resource is still in a Terraform state file or a Bicep template, deleting it by hand means the next deployment either recreates it or fails. Remove it from the code instead.
  • Resource locks. A CanNotDelete lock means someone cared enough to protect it. Find out who before you remove the lock.

Recent activity

The Activity Log keeps 90 days of control-plane events: who created, changed, attached or detached a resource.

az monitor activity-log list --resource-id "$RESOURCE_ID" --offset 90d \
  --query "[].{time:eventTimestamp, operation:operationName.localizedValue, caller:caller}" \
  -o table

For disks, unattachedSince from the query above is the best single signal. For a stopped VM or an App Service plan, look at the metrics for the last 30 days. For anything older than 90 days with no other signal, assume nobody remembers it, which is exactly why it needs an owner’s decision rather than yours.

Ownership

Work through the signals in order of how much you can trust them:

  1. Subscription. If the subscription belongs to a single team, so does everything in it, unless something says otherwise.
  2. Resource group. In most Azure estates, the resource group is the real unit of ownership. Check the resource group’s own tags, which are often better kept than the resources’.
  3. Tags on the resource, if the value names a team that still exists.
  4. Name patterns (rg-payments-*, vm-etl-*).
  5. The caller in the Activity Log, if the resource changed in the last 90 days.
  6. Neighbors: the VNet, subnet or Key Vault the resource used to connect to.

For a deeper walkthrough of these techniques, see How to Manage Azure Costs When Your Tagging is a Complete Mess.

If none of them gives you an owner, the resource is orphaned twice over: no parent and no owner. That’s the group to send a claim notice for, not the group to delete first.

Safety checklist before you delete anything

Copy this into the change ticket for each batch:

  • The owner confirmed it can go, or the claim deadline passed with no response
  • Not managed by Site Recovery, AKS, Azure Backup or another service
  • No resource lock, and no tag like DoNotDelete or Keep
  • Disk state is Unattached, not Reserved, ActiveSAS or ActiveUpload
  • Unattached or idle for longer than your threshold (30 days is a reasonable start)
  • Not in any Terraform state, Bicep template or pipeline
  • For public IPs: not in DNS, not on an allowlist
  • For disks: a final snapshot taken, tagged with where it came from and when it can go
  • For VMs: deallocated first, deleted after a second notice period
  • The change is recorded: what was removed, why, and who approved it

Delete with a snapshot and a dry run

Never pipe query results straight into a delete command. Export them to a CSV, review the CSV, then run a function that defaults to a dry run. This one takes the CSV you exported from the unattached disk query, takes a final incremental snapshot of each disk, then deletes it. Because it uses SupportsShouldProcess, -WhatIf shows what it would do without changing anything.

function Remove-OrphanedDisk {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [Alias('id')]
        [string]$DiskId,

        [int]$KeepSnapshotDays = 30
    )
    process {
        $parts = $DiskId -split '/'
        $subscriptionId, $resourceGroup, $diskName = $parts[2], $parts[4], $parts[-1]
        Set-AzContext -Subscription $subscriptionId | Out-Null

        $disk = Get-AzDisk -ResourceGroupName $resourceGroup -DiskName $diskName
        if ($disk.DiskState -ne 'Unattached') {
            Write-Warning "$diskName is $($disk.DiskState) now; skipping."
            return
        }

        if ($PSCmdlet.ShouldProcess($DiskId, 'Snapshot, then delete')) {
            $tags = @{
                'source-disk'  = $disk.Id
                'delete-after' = (Get-Date).AddDays($KeepSnapshotDays).ToString('yyyy-MM-dd')
            }
            $config = New-AzSnapshotConfig -SourceUri $disk.Id -Location $disk.Location `
                -CreateOption Copy -Incremental -Tag $tags
            New-AzSnapshot -ResourceGroupName $resourceGroup -SnapshotName "$diskName-final" `
                -Snapshot $config | Out-Null
            Remove-AzDisk -ResourceGroupName $resourceGroup -DiskName $diskName -Force | Out-Null
            Write-Output "Deleted $diskName; snapshot $diskName-final kept until $($tags['delete-after'])."
        }
    }
}

# Review first: shows every action without changing anything
Import-Csv ./unattached-disks-approved.csv | Remove-OrphanedDisk -WhatIf

# Then run it for real (you'll be asked to confirm each disk)
Import-Csv ./unattached-disks-approved.csv | Remove-OrphanedDisk

The function checks the disk’s state again right before it acts, because a disk that was unattached when you ran the query may have been attached since. Incremental snapshots are billed for the data actually used rather than the disk’s full size, so the safety net costs far less than the disk did. The delete-after tag gives the snapshot an expiry date someone can act on.

Automation that doesn’t need perfect tags

The mistake most teams make is automating the deletion. Automate the finding and the routing, and keep a person in the loop for the decision.

Ownership rules in the query itself

You don’t need every resource tagged to route findings to the right team. Resource Graph can apply simple ownership rules as it runs the query: use the owner tag where there is one, then fall back to resource group patterns, then to the subscription. Add these lines to any query in this guide:

| extend tagOwner = coalesce(tostring(tags.Owner), tostring(tags.owner))
| extend owner = case(
    isnotempty(tagOwner), tagOwner,
    resourceGroup startswith "rg-payments", "payments",
    resourceGroup startswith "rg-data", "data-platform",
    subscriptionId == "00000000-0000-0000-0000-000000000000", "platform",
    "unowned")

Every result now has an owner, and the ones that come back unowned are the list that needs a human. This is virtual tagging in miniature: rules applied at query time instead of tags applied to every resource.

A weekly report, not a weekly purge

An Azure Automation runbook with a managed identity that has Reader at the management group can run the queries every week and post the results to each owner. Store the queries in the runbook (or a storage account) and the webhook URL as an Automation variable:

# Report-OrphanedResources.ps1: report only, never deletes
Connect-AzAccount -Identity | Out-Null
$webhook = Get-AutomationVariable -Name 'CleanupWebhookUrl'

$queries = @{
    'Unattached disks'     = Get-AutomationVariable -Name 'QueryUnattachedDisks'
    'Unassociated IPs'     = Get-AutomationVariable -Name 'QueryUnassociatedIps'
    'Empty App Svc plans'  = Get-AutomationVariable -Name 'QueryEmptyPlans'
}

$findings = foreach ($kind in $queries.Keys) {
    Search-AzGraph -Query $queries[$kind] -First 1000 |
        Select-Object @{ n = 'kind'; e = { $kind } }, name, resourceGroup, owner
}

foreach ($group in $findings | Group-Object owner) {
    $lines = $group.Group | ForEach-Object { "- $($_.kind): $($_.name) ($($_.resourceGroup))" }
    $body = @{ text = "Orphan report for $($group.Name):`n$($lines -join "`n")" } | ConvertTo-Json
    Invoke-RestMethod -Uri $webhook -Method Post -ContentType 'application/json' -Body $body
}

Each query needs the ownership lines from the previous section so that every row has an owner. Teams see only their own findings, and the unowned group goes to whoever runs the cleanup.

Claim, then stop, then delete

Use tags to put the schedule on the resources themselves:

  1. Week 0: the report flags a resource. If it has no owner, it’s tagged cleanup-candidate=<date four weeks out> and listed in the report.
  2. Week 2: a reminder goes out for everything still unclaimed.
  3. Week 4: VMs are deallocated and disks are snapshotted. Nothing is deleted yet.
  4. Week 6: a person reviews the list and runs the deletion with the checklist above.

Grant the automation Reader only. If you later automate the stop step, give it a custom role scoped to exactly those actions on exactly those resource groups, never Contributor across a subscription.

Prevention: ownership rules that stop new orphans

Cleanup is a one-off. These changes stop the next round from building up.

Make VMs clean up after themselves. In Bicep and ARM templates, set deleteOption: 'Delete' on the OS disk, data disks, network interfaces and public IP configurations, so deleting the VM deletes its parts. Add it to your module defaults, not to individual deployments.

Make the resource group the unit of ownership. One team per resource group, with an owner tag on the group. Azure Policy has built-in definitions for both halves: one requires a tag on resource groups, and another adds a tag to resources from their resource group when it’s missing. Turn on tag inheritance in Cost Management as well, so subscription and resource group tags show up on cost data.

Give every subscription an owner. Even a shared subscription needs an owner of record, usually the platform team, so that unowned resources have somewhere to go.

Put an expiry on anything temporary. A delete-after tag on proofs of concept, migration leftovers and test environments turns “is this still needed?” into a date, and your weekly report can list everything past its date.

Review the unowned list every week. The ownership rules above will never cover everything. What matters is that the leftovers are a short list someone reads, not a silent pile.

Where Cloud Sleuth fits

Every query in this guide answers one question: is this resource attached to anything? The harder question, and the one that makes cleanup slow, is who owns it and whether it should still exist.

Cloud Sleuth is being built as the ownership and context layer for cloud infrastructure, and the Cloud Sleuth CLI starts with exactly the ownership rules in this guide. It runs on your machine or in your VPC, connects to Azure subscriptions and AWS accounts with read-only access, and assigns owners by subscription, account and resource name pattern, so nothing leaves your environment. Its built-in policies flag missing owners, untagged resources and common orphans, so the weekly “who owns this?” list is produced for you instead of assembled by hand.

It’s pre-launch. Get early access to hear first when it’s ready.

Cloud Sleuth CLI

Find out who owns your AWS and Azure resources

Get early access to the free Cloud Sleuth CLI. It maps your accounts, puts an owner on every resource, tagged or not, and shows what's safe to remove. It runs on your machine or in your VPC, and nothing leaves your environment.

Related articles

technical guide

FinOps Tool Comparison: Top Cloud Cost Platforms in 2026

How IBM Cloudability, CloudHealth, CloudZero, Finout, Vantage and the native AWS and Azure tools compare on cost visibility, allocation, ownership and actionability, with published pricing and a shortlist by team size.

10 min read cost optimization