Identify and clean up empty Azure resource groups that retain stale RBAC assignments, policy configurations, and deployment history, reducing audit noise and governance overhead.
Empty resource groups are one of the most overlooked governance liabilities in Azure. They carry no direct compute or storage cost, which is precisely why they persist -- they never trigger a billing alert. But an empty resource group is not an inert object. It retains role-based access control (RBAC) assignments, Azure Policy evaluations, resource locks, deployment history, and activity log entries. Each of those artifacts creates governance overhead that compounds across subscriptions and tenants.
For managed service providers and enterprise platform teams, the issue is scale. A single empty resource group is trivial. Hundreds of them spread across dozens of subscriptions represent stale permissions that auditors flag, policy evaluation cycles that burn time, and a shrinking headroom toward Azure's hard limit of 980 resource groups per subscription. Cleaning them up is straightforward once you have a reliable detection method and a validation workflow that prevents breaking CI/CD pipelines or disaster recovery configurations.
Key Takeaways at a Glance:
An empty resource group does not appear on your invoice, but it is not free from a governance perspective. Every empty group carries artifacts from its original purpose, and those artifacts create ongoing overhead across multiple dimensions.
Five ways empty resource groups create governance debt:
MSP multiplier example: An MSP managing 20 customer subscriptions with an average of 15 empty resource groups per subscription has 300 empty groups across the tenant. If each group carries an average of 4 stale RBAC assignments, that is 1,200 stale role assignments that appear in every access review. At 2 minutes per assignment to investigate, a quarterly access review burns 40 hours just on empty group permissions -- before the team even looks at active workloads.
Azure Resource Graph provides the fastest way to identify empty resource groups across all subscriptions in a single query. The query compares the full list of resource groups against resource groups that contain at least one resource, and returns the difference.
Resource Graph KQL Query:
// Find all empty resource groups across all subscriptions
ResourceContainers
| where type == "microsoft.resources/subscriptions/resourcegroups"
| extend rgAndSub = strcat(resourceGroup, "--", subscriptionId)
| join kind=leftanti (
Resources
| extend rgAndSub = strcat(resourceGroup, "--", subscriptionId)
) on rgAndSub
| project subscriptionId, resourceGroup, location, tags, properties
| order by subscriptionId asc, resourceGroup ascCLI command to run the query:
# Run the Resource Graph query across all accessible subscriptions
az graph query -q "
ResourceContainers
| where type == 'microsoft.resources/subscriptions/resourcegroups'
| extend rgAndSub = strcat(resourceGroup, '--', subscriptionId)
| join kind=leftanti (
Resources
| extend rgAndSub = strcat(resourceGroup, '--', subscriptionId)
) on rgAndSub
| project subscriptionId, resourceGroup, location, tags
| order by subscriptionId asc, resourceGroup asc
" --first 1000 -o tableNote: The Resource Graph query returns results across all subscriptions your identity has access to. For large environments, use the --first and --skip parameters to paginate through results. The query checks for the absence of any resource, including hidden resources like deployment history entries.
Not every empty resource group should be deleted. Some are intentionally empty -- held for future use, referenced by infrastructure-as-code templates, or designated as disaster recovery targets. Before deleting, validate each group against a checklist.
Validation Checklist:
az monitor activity-log list --resource-group <rg-name> --start-time <30-days-ago> --query "[].{'Op':operationName.localizedValue, 'Time':eventTimestamp, 'Caller':caller}" -o tabledr:true, reserved:true, environment:staging, or similar conventions that indicate the group is intentionally held. Resource groups with these tags should be excluded from automated cleanup.az lock list --resource-group <rg-name> -o tableBefore deleting an empty resource group, export its governance artifacts for audit trail and potential rollback. Deleting a resource group permanently removes its RBAC assignments, policy assignments, and locks.
Export RBAC assignments:
# Export RBAC assignments for a resource group
$rgName = "rg-old-project"
$subscriptionId = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
$scope = "/subscriptions/$subscriptionId/resourceGroups/$rgName"
# Get all role assignments at this scope
$assignments = Get-AzRoleAssignment -Scope $scope |
Select-Object DisplayName, SignInName, RoleDefinitionName,
ObjectType, Scope, ObjectId
# Export to CSV for audit trail
$assignments | Export-Csv -Path "./rbac-export-$rgName.csv" -NoTypeInformation
# Display summary
Write-Output "RBAC assignments for $rgName :"
$assignments | Format-Table -AutoSizeExport policy assignments:
# Export policy assignments scoped to the resource group
$rgName = "rg-old-project"
$subscriptionId = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
$scope = "/subscriptions/$subscriptionId/resourceGroups/$rgName"
$policies = Get-AzPolicyAssignment -Scope $scope |
Select-Object Name, DisplayName, PolicyDefinitionId, Scope,
EnforcementMode
$policies | Export-Csv -Path "./policy-export-$rgName.csv" -NoTypeInformation
Write-Output "Policy assignments for $rgName :"
$policies | Format-Table -AutoSizeExport resource locks:
# Export resource locks for the resource group
$rgName = "rg-old-project"
$locks = Get-AzResourceLock -ResourceGroupName $rgName |
Select-Object Name, LockLevel, Notes
$locks | Export-Csv -Path "./locks-export-$rgName.csv" -NoTypeInformation
Write-Output "Resource locks for $rgName :"
$locks | Format-Table -AutoSizeOnce you have validated that the resource group is safe to remove and exported its governance artifacts, proceed with deletion.
PowerShell deletion:
# Delete a single empty resource group (with confirmation)
Remove-AzResourceGroup -Name "rg-old-project" -Force
# Verify deletion
Get-AzResourceGroup -Name "rg-old-project" -ErrorAction SilentlyContinue
# Should return nothing if successfully deletedAzure CLI deletion:
# Delete a single empty resource group
az group delete --name "rg-old-project" --yes --no-wait
# Delete multiple empty resource groups from a list
while IFS= read -r rg; do
echo "Deleting: $rg"
az group delete --name "$rg" --yes --no-wait
done < empty-rg-list.txt
# Verify deletion
az group show --name "rg-old-project" 2>/dev/null || echo "Successfully deleted"Failure mode warning: Deleting a resource group is irreversible. All RBAC assignments, policy assignments, locks, deployment history, and tags are permanently removed. If a CI/CD pipeline or IaC template references the resource group, the next deployment will fail. Always validate dependencies and export artifacts before deletion.
Deploy the following PowerShell script as an Azure Automation runbook on a recurring schedule (weekly or monthly) to continuously identify and clean up empty resource groups. The runbook includes dry-run mode, exclusion tag support, artifact export, and lock detection.
# Azure Automation Runbook: Empty Resource Group Cleanup
# Schedule: Weekly or Monthly, during maintenance window
# Requires: Az.Accounts, Az.Resources, Az.ResourceGraph modules
param(
[string[]]$SubscriptionIds = @(), # Empty = all accessible subscriptions
[string[]]$ExclusionTags = @("dr:true", "reserved:true", "doNotDelete:true"),
[int]$InactivityDays = 90, # Skip RGs with recent activity
[bool]$ExportArtifacts = $true, # Export RBAC/policy before deletion
[string]$ExportStorageAccount = "", # Storage account for exports
[string]$ExportContainer = "rg-cleanup-exports",
[bool]$DryRun = $true # Set to $false for actual deletion
)
# Authenticate using managed identity
Connect-AzAccount -Identity
# Get target subscriptions
if ($SubscriptionIds.Count -eq 0) {
$SubscriptionIds = (Get-AzSubscription).Id
}
# Query for empty resource groups using Resource Graph
$query = @"
ResourceContainers
| where type == "microsoft.resources/subscriptions/resourcegroups"
| extend rgAndSub = strcat(resourceGroup, "--", subscriptionId)
| join kind=leftanti (
Resources
| extend rgAndSub = strcat(resourceGroup, "--", subscriptionId)
) on rgAndSub
| project subscriptionId, resourceGroup, location, tags, properties
"@
$emptyGroups = Search-AzGraph -Query $query -Subscription $SubscriptionIds
Write-Output "Found $($emptyGroups.Count) empty resource groups"
$deleted = 0
$skipped = 0
$errors = 0
foreach ($rg in $emptyGroups) {
$rgName = $rg.resourceGroup
$subId = $rg.subscriptionId
$skip = $false
Set-AzContext -SubscriptionId $subId -ErrorAction SilentlyContinue
# Check exclusion tags
if ($rg.tags) {
foreach ($exclusion in $ExclusionTags) {
$parts = $exclusion -split ":"
$tagKey = $parts[0]
$tagValue = $parts[1]
if ($rg.tags.$tagKey -eq $tagValue) {
Write-Output "[SKIP - TAG] $rgName (tag: $exclusion)"
$skip = $true
$skipped++
break
}
}
}
if ($skip) { continue }
# Check for resource locks
$locks = Get-AzResourceLock -ResourceGroupName $rgName \
-ErrorAction SilentlyContinue
if ($locks) {
Write-Output "[SKIP - LOCK] $rgName (locks: $($locks.Count))"
$skipped++
continue
}
# Check Activity Log for recent operations
$cutoffDate = (Get-Date).AddDays(-$InactivityDays)
$recentActivity = Get-AzActivityLog -ResourceGroupName $rgName \
-StartTime $cutoffDate -MaxRecord 1 -ErrorAction SilentlyContinue
if ($recentActivity) {
Write-Output "[SKIP - ACTIVE] $rgName (activity within $InactivityDays days)"
$skipped++
continue
}
# Export governance artifacts
if ($ExportArtifacts) {
$scope = "/subscriptions/$subId/resourceGroups/$rgName"
$rbac = Get-AzRoleAssignment -Scope $scope -ErrorAction SilentlyContinue
$policies = Get-AzPolicyAssignment -Scope $scope \
-ErrorAction SilentlyContinue
$export = @{
ResourceGroup = $rgName
SubscriptionId = $subId
ExportDate = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
RBACAssignments = $rbac | Select-Object DisplayName,
SignInName, RoleDefinitionName, ObjectType
PolicyAssignments = $policies | Select-Object Name,
DisplayName, PolicyDefinitionId
}
$exportJson = $export | ConvertTo-Json -Depth 5
Write-Output "[EXPORT] $rgName - RBAC: $($rbac.Count), \
Policies: $($policies.Count)"
# Optionally upload to storage account
if ($ExportStorageAccount) {
$blobName = "$subId/$rgName-$(Get-Date -Format 'yyyyMMdd').json"
$tempFile = [System.IO.Path]::GetTempFileName()
$exportJson | Out-File -FilePath $tempFile
# Upload to blob storage (requires storage context)
}
}
# Delete or dry-run
if ($DryRun) {
Write-Output "[DRY RUN] Would delete: $rgName (sub: $subId)"
$deleted++
} else {
try {
Remove-AzResourceGroup -Name $rgName -Force \
-ErrorAction Stop
Write-Output "[DELETED] $rgName (sub: $subId)"
$deleted++
} catch {
Write-Output "[ERROR] Failed to delete $rgName : $_"
$errors++
}
}
}
# Summary
Write-Output ""
Write-Output "==============================="
Write-Output "Empty Resource Group Cleanup Summary"
Write-Output "==============================="
Write-Output "Total empty groups found: $($emptyGroups.Count)"
Write-Output "Deleted (or would delete): $deleted"
Write-Output "Skipped: $skipped"
Write-Output "Errors: $errors"
Write-Output "Mode: $(if ($DryRun) { 'DRY RUN' } else { 'LIVE' })"
Write-Output "==============================="Important: Always run the first execution with $DryRun = $true to review which resource groups would be deleted. Share the dry-run output with the team for review before switching to $false. The exclusion tag mechanism allows teams to protect resource groups they intend to keep by applying a tag like reserved:true.
No, empty resource groups have no direct Azure billing cost. There is no charge for the resource group object itself. However, they create indirect costs through governance overhead: stale RBAC assignments that require investigation during access reviews, policy evaluation cycles that include empty groups, and the operational time spent distinguishing between intentionally empty and abandoned groups. For MSPs conducting quarterly access reviews, the labor cost of reviewing stale permissions on empty groups can be significant.
Azure enforces a hard limit of 980 resource groups per subscription. This limit cannot be increased through a support request. Empty resource groups consume this quota identically to populated ones. Subscriptions that accumulate empty groups through project churn, failed deployments, or test cycles can approach this limit, which blocks the creation of new resource groups for legitimate workloads.
All RBAC role assignments scoped to the resource group are permanently deleted along with the group. Assignments inherited from higher scopes (subscription or management group) are not affected. This is actually a governance benefit -- deleting the empty group eliminates stale, resource-group-scoped permissions in one action. However, you should export the assignments before deletion for your audit trail.
If a Terraform state file or Bicep template references the resource group, deleting it will cause a failure on the next deployment. Terraform will report that the resource group does not exist when it tries to plan against its state. Bicep will fail because the target resource group is missing. Always search your IaC repositories for references to the resource group name before deleting. If the group is in your IaC state, remove it from state first (e.g., terraform state rm) or update the template.
The recommended approach is to apply an exclusion tag to resource groups that should be retained even when empty. Tags like reserved:true, dr:true, or doNotDelete:true can be checked by the cleanup runbook. Alternatively, use resource locks (CanNotDelete) to prevent deletion. The automation runbook provided above supports both tag-based exclusion and lock detection.
Some Azure services create resource groups automatically (e.g., AKS creates a node resource group prefixed with MC_, and Azure Site Recovery creates groups for replicated resources). These groups may appear empty during certain lifecycle states but are managed by the service. Deleting them can break the parent service. Check the resource group's managed-by property and avoid deleting groups where managedBy is set to another resource's ID.
Check the Activity Log for the resource group's creation event. Use: az monitor activity-log list --resource-group <rg-name> --query "[?operationName.localizedValue=='Create or Update Resource Group']" -o table. The caller field shows the identity that created the group. Note that Activity Log data is retained for 90 days by default. For older groups, you may need to check diagnostic logs if they were configured to forward to a Log Analytics workspace.
No. Resource group deletion is permanent and irreversible. Once deleted, the resource group, all its RBAC assignments, policy assignments, locks, deployment history, and tags are gone. There is no soft-delete or recovery mechanism for resource groups. This is why the export step is critical -- it provides the audit trail and the data needed to recreate the governance configuration if necessary.
For most organizations, a monthly cleanup cycle strikes the right balance between governance hygiene and operational safety. MSPs with high project churn may benefit from weekly runs. The key is to always use the dry-run mode first, have a review step before actual deletion, and ensure the exclusion tag mechanism is communicated to all teams. Pair the automated cleanup with onboarding documentation so new projects tag their resource groups appropriately from day one.
Azure Policy can enforce tagging requirements on resource group creation (e.g., requiring an owner or project tag), which makes it easier to track ownership and identify groups that should be cleaned up. However, Azure Policy cannot natively detect or delete empty resource groups. The detection requires a Resource Graph query, and the deletion requires an automation workflow. Policy and automation work together: policy prevents untagged groups from being created, and the automation runbook cleans up tagged groups that have been empty beyond the inactivity threshold.
Spotto continuously monitors your Azure subscriptions to detect empty resource groups with stale governance artifacts. Rather than running one-off Resource Graph queries, Spotto provides ongoing visibility into which groups are empty, how long they have been empty, what RBAC assignments they carry, and whether they are referenced by IaC templates. Recommendations are surfaced with context, so teams can act quickly without manual investigation.
Yes. MSPs using Azure Lighthouse can run Resource Graph queries across customer subscriptions from a single management tenant. The cleanup runbook can iterate over all delegated subscriptions using the Lighthouse relationship. For organizations with multiple Azure AD tenants, you would need to authenticate to each tenant separately and run the query per tenant. Spotto supports multi-tenant visibility natively, providing a consolidated view of empty resource groups across all managed tenants.
Before
87 empty RGs
Across 12 customer subscriptions, avg 7 stale RBAC assignments each
After
71 deleted, 16 retained
16 retained with DR or reserved tags, 609 stale RBAC assignments removed
Actions taken: Ran Resource Graph query across all Lighthouse-delegated subscriptions. Exported RBAC and policy artifacts for 71 groups. Validated no IaC references using repo search. Applied reserved:true tag to 16 groups designated for DR or future projects. Deleted 71 empty groups, removing 609 stale role assignments from the access review scope.
Before
45 empty RGs
Left behind after resources migrated out, customer subscription still active
After
45 deleted
All stale MSP RBAC assignments removed, subscription returned clean
Actions taken: Customer migrated workloads to a different provider but retained the Azure subscription. All 45 resource groups were emptied during migration but not deleted. Each group retained MSP-scoped RBAC assignments (Contributor, Reader, custom monitoring roles). Exported all governance artifacts, verified no remaining dependencies, and deleted all 45 groups. The customer's subscription was returned with zero stale MSP permissions.
Before
34 empty RGs
Remnants of a datacenter-to-Azure migration, vendor RBAC still assigned
After
34 deleted
Vendor service principal assignments removed, compliance finding resolved
Actions taken: A datacenter-to-Azure migration project completed 8 months prior. The migration vendor's service principals still had Contributor access on 34 empty resource groups. Internal audit flagged the stale vendor access as a compliance finding. Exported all RBAC assignments showing vendor access, removed groups, and closed the audit finding. The vendor's service principals were also reviewed and removed from subscription-level roles where appropriate.
Before
300 empty RGs
Dev/test subscription at 920 of 980 limit, new projects blocked
After
280 deleted
Subscription at 640 of 980 limit, headroom restored for new projects
Actions taken: A SaaS development team used a shared subscription for feature branch environments. Each feature branch created a resource group, but the cleanup pipeline only deleted resources, not the groups. Over 18 months, 300 empty groups accumulated, pushing the subscription to 920 of 980. Developers could not create new environments. Validated that no active branches referenced the empty groups, applied exclusion tags to 20 groups held for long-running test scenarios, and deleted 280 groups. Implemented a CI/CD pipeline step to delete the resource group (not just its contents) on branch cleanup.
$DryRun = $true. Review the output with the team. Confirm that no groups marked for deletion are needed.$DryRun = $false. Monitor the output for errors. Verify deletion by re-running the Resource Graph query.managedBy property before deleting any group.Resource group deletion is irreversible. The rollback strategy is based on recreating the resource group and its governance configuration from the exported artifacts:
az group create --name <rg-name> --location <location> to recreate the resource group in the same location with the same name.New-AzRoleAssignment with the exported principal IDs, role definition names, and scope.New-AzPolicyAssignment with the exported parameters.| Recommendation | Impact | Complexity |
|---|---|---|
| Delete empty resource groups with no exclusion tags, locks, or recent activity | Removes stale RBAC, reduces audit scope, frees subscription quota | Low |
| Implement automated recurring cleanup with exclusion tag support | Prevents re-accumulation, continuous governance hygiene | Medium |
| Update CI/CD pipelines to delete resource groups on environment teardown | Prevents empty group accumulation at the source | Low |
The challenges outlined in this article — stale RBAC assignments on empty resource groups, audit noise from abandoned infrastructure, approaching the 980-group limit — are the same ones our customers work through across their Azure environments. The difference is they use Spotto to surface these opportunities automatically, rather than running the analysis by hand.
Spotto continuously analyzes your Azure subscriptions to detect empty resource groups with stale governance artifacts, giving you clear cleanup recommendations that reduce audit noise and tighten security across every tenant.
Free TrialDisclaimer: This article is provided for informational purposes only and does not constitute professional advice. Azure pricing, features, and service behaviour may have changed since publication. Always validate recommendations against your specific environment and consult Microsoft's official documentation before making changes to production infrastructure. Spotto is not liable for any actions taken based on the content of this article.