Cleaning Up Unused Azure Resources
Unused resources are a silent drain on Azure spend. Managed disks attached to deleted VMs, unattached public IP addresses, idle App Service Plans, and forgotten load balancers accumulate over time and collectively add up to a significant and entirely avoidable monthly bill.
Why unused resources accumulate
The most common cause is incomplete deletion. When a developer deletes a VM through the portal, they often delete only the VM resource itself. The associated managed disk, public IP, network interface, and network security group are left behind as orphaned resources. Each of these continues to appear on the monthly invoice.
Other common sources of waste include App Service Plans with no apps deployed, load balancers that were set up for a test environment and never torn down, old disk snapshots taken before a migration that was completed months ago, and resource groups created by experimentation that were never cleaned up.
The problem compounds in organisations where multiple teams deploy independently and there is no regular review process. A quarterly cleanup exercise in a mid-sized Azure environment typically finds hundreds of orphaned resources representing thousands of dollars per month.
Resource types that commonly go unused
| Resource type | Monthly cost when idle | How it gets orphaned |
|---|---|---|
| Managed disk (128 GiB Premium SSD) | ~$20 | VM deleted but disk not deleted |
| Public IP address (Static) | ~$3.65 | NIC or load balancer deleted, IP left unassigned |
| App Service Plan (B1) | ~$13 | App deleted, plan left running |
| Standard Load Balancer | ~$18 base + data processing | Test environment deleted, LB not removed |
| Disk snapshot (128 GiB) | ~$5–7 | Pre-migration snapshot never deleted |
| Virtual network gateway | ~$27+ (VpnGw1) | VPN connection closed, gateway not deleted |
| Stopped VM (not deallocated) | Full compute rate | OS shutdown, not deallocated in Azure |
A static public IP address costs approximately $0.005/hour whether or not it is attached to anything. At scale, dozens of unattached static IPs add meaningful cost. Reserved (static) IPs have a higher unattached rate than dynamic IPs.
Finding orphaned resources with the CLI
The Azure CLI makes it straightforward to query for specific orphaned resource types across a subscription. The following commands identify the most common categories.
# Find all unattached managed disks
az disk list \
--query "[?diskState=='Unattached'].{Name:name, ResourceGroup:resourceGroup, SizeGB:diskSizeGb, SKU:sku.name}" \
--output table
# Find all public IPs not associated with any resource
az network public-ip list \
--query "[?ipConfiguration==null].{Name:name, ResourceGroup:resourceGroup, AllocationMethod:publicIpAllocationMethod}" \
--output table
# Find App Service Plans with zero apps
az appservice plan list \
--query "[?numberOfSites==\`0\`].{Name:name, ResourceGroup:resourceGroup, SKU:sku.name, NumberOfSites:numberOfSites}" \
--output table
# Find disk snapshots older than 90 days (replace date as needed)
az snapshot list \
--query "[?timeCreated<'2025-12-19'].{Name:name, ResourceGroup:resourceGroup, SizeGB:diskSizeGb, Created:timeCreated}" \
--output table
# Find load balancers with no backend pool members
az network lb list \
--query "[?backendAddressPools[0].backendIPConfigurations==null].{Name:name, ResourceGroup:resourceGroup}" \
--output tableRun these queries in a read-only context first to audit scope before taking any deletion action. Pipe results to a file for review if the list is long.
Azure Advisor cost recommendations
Azure Advisor actively identifies waste and surfaces it in the Cost section of the Advisor portal. It flags idle VMs (running below 5% CPU for 7 days), unattached public IPs, empty App Service Plans, and underused ExpressRoute circuits.
# Pull all Advisor cost recommendations
az advisor recommendation list \
--category Cost \
--output table
# Filter for specific resource types
az advisor recommendation list \
--category Cost \
--query "[?resourceMetadata.resourceType=='microsoft.compute/disks']" \
--output tableAdvisor recommendations include an estimated annual saving for each item. Sorting by impact (high/medium/low) lets you prioritise the highest-value cleanup targets first. Advisor does not catch everything — it focuses on resources it has direct visibility into — so combine it with the manual CLI queries above for full coverage.
Using Cost Analysis to identify waste by resource type
The Azure Cost Analysis portal lets you filter spend by resource type and group by resource name. To find high-cost resource types that might contain idle resources, open Cost Analysis, set the scope to your subscription, change the grouping to Resource Type, and sort by cost descending. Resource types with unexpectedly high spend relative to their business value are worth investigating further.
Filter by tag to identify resources that lack an owner tag — untagged resources are often orphaned. A department or team tag missing from a compute resource is a strong signal it was created informally and may no longer be needed.
Using Azure Policy to enforce lifecycle tagging
The most durable cleanup strategy is preventing orphaned resources in the first place. Azure Policy can require specific tags on resource creation, making it impossible to deploy a VM, disk, or public IP without attaching an owner and expiry-date tag.
# Assign a built-in policy requiring a specific tag on all resources
az policy assignment create \
--name "require-owner-tag" \
--policy "871b6d14-10aa-478d-b590-94f262ecfa99" \
--params '{"tagName": {"value": "owner"}}' \
--scope /subscriptions/{subscription-id}With an expiry-date tag in place, you can write a cleanup script or Logic App that queries for resources with an expiry date in the past and either deletes them automatically or sends a notification to the resource owner to re-validate.
# Example: list all VMs with expiry-date tag before today's date
az vm list \
--query "[?tags.\"expiry-date\" < '2026-03-19'].{Name:name, RG:resourceGroup, Expiry:tags.\"expiry-date\"}" \
--output tableUse a 90-day re-validation cadence. Any resource that has not been explicitly re-tagged within 90 days becomes a deletion candidate. This shifts the default from “assume it is needed” to “prove it is needed.”
Automated cleanup scripts
For teams managing many subscriptions, manual CLI queries do not scale. An automated script run as an Azure Automation Runbook or a scheduled Logic App can regularly audit and report — or in mature environments, delete — orphaned resources.
A safe automated cleanup pattern for orphaned disks:
#!/bin/bash
# Identify unattached disks and delete them after confirmation
UNATTACHED=$(az disk list \
--query "[?diskState=='Unattached'].id" \
--output tsv)
for DISK_ID in $UNATTACHED; do
DISK_NAME=$(az disk show --ids "$DISK_ID" --query name -o tsv)
echo "Deleting orphaned disk: $DISK_NAME"
az disk delete --ids "$DISK_ID" --yes --no-wait
doneRun deletion scripts with —yes only after a review period. In production, log all deletions to a storage account for audit purposes. Deleted disks cannot be recovered unless you have a snapshot.
Common mistakes
- Deleting the VM but not its disk, NIC, and IP. The portal lets you select associated resources during VM deletion — always check those boxes. Otherwise the orphaned components continue billing indefinitely.
- Stopping a VM at the OS level and assuming billing stops. An OS-level shutdown still bills for compute at the full rate. Only an Azure-level deallocate stops compute charges.
- Deleting a snapshot without verifying no disk depends on it. If a managed disk was created from a snapshot and the snapshot is later deleted, the disk is unaffected. But if the snapshot is being used as a restore point in a backup policy, removing it breaks the backup chain.
- Not checking resource locks before bulk deletes. Resources with a
CanNotDeleteorReadOnlylock will silently fail to delete. Always check for locks before running batch cleanup scripts.
Summary
- Orphaned disks, unattached public IPs, empty App Service Plans, and idle load balancers are the most common sources of unused resource spend in Azure.
- The az CLI provides targeted queries to identify each resource type; combine them with Azure Advisor recommendations for full coverage.
- Azure Policy with required lifecycle tags prevents future orphaning by ensuring every resource has an owner and an expiry date from the moment it is created.
- Automated cleanup scripts should log all deletions and run with a review period before deleting automatically in production environments.
Frequently asked questions
Do stopped VMs still incur charges in Azure?
A stopped VM (OS-level shutdown) still incurs charges for compute and for attached managed disks. Only a deallocated VM stops compute charges, but the managed disk charge continues regardless. You must delete the disk to stop paying for it entirely.
How do I find all unattached managed disks across my subscription?
Use: az disk list --query "[?diskState=='Unattached']" --output table. This lists every managed disk in the subscription that is not currently attached to a VM.
What is the safest way to clean up resources at scale?
Tag resources with a last-validated date. Resources not re-validated within 90 days are candidates for deletion. Use Azure Policy to enforce the tag on creation, and a runbook or Logic App to flag aged resources automatically.