Automate Cleanup of Unused Azure Resources (AVD Included)
Automated Python script to find and clean up unused Azure resources: unattached disks, idle VMs, orphaned NICs, old snapshots, and the zombie session hosts AVD leaves behind.
To automate cleanup of unused Azure resources, including AVD leftovers: scan every subscription for unattached managed disks, VMs deallocated for 30 days or more, orphaned NICs, and old snapshots, then review and delete on a schedule. The Python script below does the scan; the AVD section maps it to session hosts and host pools.
๐ธ Stop Paying for Unused Resources
Cloud waste is one of the biggest challenges in FinOps. This Python automation script scans your Azure subscriptions to find resources that are costing you money but providing no value.
Identify and clean up unused Azure resources to reduce your monthly cloud bill. This playbook helps you find unattached disks, stopped VMs, orphaned network interfaces, and more.
๐ What We Find
Unattached Managed Disks
Disks not attached to any VM, often left behind after VM deletion
Stopped VMs
VMs deallocated for more than 30 days but still incurring storage costs
Orphaned NICs
Network interfaces not attached to any VMs
Old Snapshots
Snapshots older than 90 days that may no longer be needed
๐ง AVD: The Zombie Desktop Problem
Azure Virtual Desktop estates produce a special breed of waste. A session host is just a VM with a disk and a NIC, registered to a host pool. Every time a pool scales down, a golden image gets replaced, or someone leaves the company, there is a chance the VM stops earning its keep while its parts keep billing.
The good news: because session hosts are ordinary VMs underneath, the script below already catches most AVD leftovers. The mapping:
- โขUnattached disks are frequently the remains of deleted session hosts, especially after image-based host replacement.
- โขVMs deallocated 30+ days are often session hosts drained for maintenance and never brought back or deleted. A deallocated host stops billing compute, but its disks bill on.
- โขOrphaned NICs show up when hosts get deleted in the wrong order.
Three AVD-specific checks the generic sweep cannot see, worth adding to your routine:
- โขStale host pool registrations. Deleting the VM does not remove its session host entry from the host pool. Clean those up with
Remove-AzWvdSessionHostoraz desktopvirtualization sessionhost delete. - โขPersonal desktops assigned to nobody. In personal host pools, a session host stays assigned to its user after they stop signing in, or leave entirely. That is the true zombie desktop: powered, patched, and pointless. Cross-check assignments against sign-in activity.
- โขFSLogix profiles of departed users. Profile containers live on your storage share and outlive their owners. Review the share as part of every user offboarding.
๐ฐ Potential Savings
First sweeps routinely turn up disks, snapshots, and forgotten session hosts nobody remembers creating. How much that is worth depends on the size of your estate. The script prints your number instead of promising one, and regular automation keeps it near zero.
๐ Prerequisites
- โขPython 3.8 or higher
- โขAzure CLI (
az) installed and configured - โขAzure SDK for Python:
pip install azure-identity azure-mgmt-compute azure-mgmt-network - โขContributor or Reader access to target subscriptions
๐ Quick Start
# Install required Python packages pip install azure-identity azure-mgmt-compute azure-mgmt-network azure-mgmt-resource # Login to Azure az login # Set your subscription (optional) az account set --subscription "your-subscription-id"
โ๏ธ The Complete Script
This script uses the Azure Python SDK to scan your subscriptions and identify unused resources. It generates a detailed CSV report with cost estimates for each unused resource.
#!/usr/bin/env python3
"""
Azure Unused Resources Finder
Identifies unused Azure resources across subscriptions
"""
from azure.identity import DefaultAzureCredential
from azure.mgmt.compute import ComputeManagementClient
from azure.mgmt.network import NetworkManagementClient
from azure.mgmt.resource import SubscriptionClient
from datetime import datetime, timedelta
import csv
def get_subscriptions(credential):
"""Get all Azure subscriptions"""
subscription_client = SubscriptionClient(credential)
return [sub.subscription_id for sub in subscription_client.subscriptions.list()]
def find_unattached_disks(compute_client):
"""Find all unattached managed disks"""
unattached = []
for disk in compute_client.disks.list():
if disk.disk_state == 'Unattached':
cost_estimate = disk.disk_size_gb * 0.05 # Rough estimate: $0.05/GB/month
unattached.append({
'type': 'Disk',
'name': disk.name,
'resource_group': disk.id.split('/')[4],
'size_gb': disk.disk_size_gb,
'estimated_monthly_cost': f'${cost_estimate:.2f}',
'location': disk.location
})
return unattached
def find_orphaned_nics(network_client):
"""Find network interfaces not attached to VMs"""
orphaned = []
for nic in network_client.network_interfaces.list_all():
if not nic.virtual_machine:
orphaned.append({
'type': 'NIC',
'name': nic.name,
'resource_group': nic.id.split('/')[4],
'location': nic.location,
'estimated_monthly_cost': '$5.00'
})
return orphaned
def find_unused_public_ips(network_client):
"""Find public IPs not associated with any resource"""
unused = []
for ip in network_client.public_ip_addresses.list_all():
if not ip.ip_configuration:
unused.append({
'type': 'PublicIP',
'name': ip.name,
'resource_group': ip.id.split('/')[4],
'ip_address': ip.ip_address or 'Not assigned',
'estimated_monthly_cost': '$3.50',
'location': ip.location
})
return unused
def main():
print("๐ Azure Unused Resources Finder")
print("=" * 50)
credential = DefaultAzureCredential()
subscriptions = get_subscriptions(credential)
all_unused = []
total_estimated_cost = 0
for sub_id in subscriptions:
print(f"\n๐ Scanning subscription: {sub_id}")
compute_client = ComputeManagementClient(credential, sub_id)
network_client = NetworkManagementClient(credential, sub_id)
# Find unused resources
disks = find_unattached_disks(compute_client)
nics = find_orphaned_nics(network_client)
ips = find_unused_public_ips(network_client)
all_unused.extend(disks + nics + ips)
print(f" Found: {len(disks)} disks, {len(nics)} NICs, {len(ips)} IPs")
# Write to CSV
if all_unused:
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f'azure_unused_resources_{timestamp}.csv'
with open(filename, 'w', newline='') as f:
writer = csv.DictWriter(f, fieldnames=all_unused[0].keys())
writer.writeheader()
writer.writerows(all_unused)
print(f"\nโ
Report saved: {filename}")
print(f"๐ Total unused resources: {len(all_unused)}")
else:
print("\nโจ No unused resources found!")
if __name__ == "__main__":
main()๐ How to Use
- 1.Run the script:
python azure_cleanup_finder.py - 2.Review the CSV report that gets generated with all findings
- 3.Verify resources before deletion - some may be intentionally unattached
- 4.Clean up confirmed waste using the Azure Portal or CLI commands below
๐งน Cleanup Commands
Once you've identified resources to delete, use these Azure CLI commands:
# Delete an unattached disk az disk delete --name <disk-name> --resource-group <rg-name> --yes # Delete an orphaned NIC az network nic delete --name <nic-name> --resource-group <rg-name> # Delete an unused public IP az network public-ip delete --name <ip-name> --resource-group <rg-name> # Delete old snapshots az snapshot delete --name <snapshot-name> --resource-group <rg-name>
โ ๏ธ Safety First
Always review resources in the CSV report before deletion. Verify with your team that resources are truly unused. Some unattached disks may be intentionally kept for backup or restore purposes.
๐ธ Real-World Savings Example
โ FinOps Best Practice
Run this script monthly as part of your FinOps review process. Track savings over time and celebrate wins with your engineering teams to build a cost-conscious culture.