Governance

    How Do Empty Azure Resource Groups Affect Governance?

    Identify and clean up empty Azure resource groups that retain stale RBAC assignments, policy configurations, and deployment history, reducing audit noise and governance overhead.

    Spotto
    February 2026
    20 min read

    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:

    Empty resource groups have no direct cost but retain RBAC assignments, policy configurations, resource locks, and deployment history that create governance overhead
    Azure enforces a hard limit of 980 resource groups per subscription -- empty groups consume that quota just like populated ones
    Azure Resource Graph can identify empty resource groups across all subscriptions in a single query, making detection fast even at scale
    CI/CD pipelines and IaC templates that reference deleted resource groups will fail -- always validate external dependencies before removing a group
    Automated cleanup using Azure Automation runbooks with a dry-run validation step prevents accidental deletion and keeps environments clean on an ongoing basis

    What Problem Do Empty Resource Groups Create?

    Governance Overhead That Compounds Silently

    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:

    Stale RBAC assignments. When a resource group is emptied but not deleted, its role assignments persist. Contributors, readers, and custom roles that were granted for specific workloads remain in place. These stale permissions widen the attack surface and create noise during access reviews. Auditors cannot distinguish between intentional access and forgotten assignments without manual investigation.
    Policy evaluation overhead. Azure Policy evaluates every resource group in scope, whether empty or populated. Compliance dashboards include empty groups in their denominator, which can skew compliance percentages. Policy remediation tasks attempt to evaluate empty groups, consuming evaluation cycles that provide no governance value.
    Audit noise and clutter. Activity logs, Azure Advisor recommendations, and security assessments all include empty resource groups. When a security team reviews role assignments across a subscription, every empty group with stale permissions appears as a finding. The signal-to-noise ratio drops as empty groups accumulate.
    Accidental deployment targets. Empty resource groups with familiar naming conventions can become accidental targets for new deployments. A developer or CI/CD pipeline deploying to "rg-app-prod" might not realize it was supposed to be decommissioned, leading to resources in an unmanaged or poorly governed location.
    Subscription quota consumption. Azure enforces a hard limit of 980 resource groups per subscription. Each empty group consumes one unit of that quota. Subscriptions that accumulate empty groups over months or years of project churn can approach this limit, preventing new resource group creation for legitimate workloads.

    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.

    How It Works

    Step 1: Detect Empty Resource Groups

    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 asc

    CLI 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 table

    Note: 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.

    Step 2: Validate Before Deletion

    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:

    Activity Log review. Check the Activity Log for recent operations. If a resource group had resources deleted within the last 30 days, it may be in the process of being reprovisioned. Use: az monitor activity-log list --resource-group <rg-name> --start-time <30-days-ago> --query "[].{'Op':operationName.localizedValue, 'Time':eventTimestamp, 'Caller':caller}" -o table
    IaC template references. Search your Terraform state files, Bicep templates, ARM templates, and Pulumi stacks for references to the resource group name. A resource group that exists in an IaC template will be recreated if deleted, but the deletion itself may trigger a failed pipeline run.
    DR and reserved tags. Check for tags like dr: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.
    Resource locks. A resource group with a CanNotDelete or ReadOnly lock was intentionally protected. The lock must be removed before deletion, which is an explicit signal to pause and verify the intent. Use: az lock list --resource-group <rg-name> -o table

    Step 3: Export Governance Artifacts Before Deletion

    Before 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 -AutoSize

    Export 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 -AutoSize

    Export 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 -AutoSize

    Step 4: Delete Empty Resource Groups

    Once 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 deleted

    Azure 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.

    Automation: Recurring Cleanup Runbook

    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.

    Common Questions

    1. Do empty resource groups cost anything?

    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.

    2. What is the resource group limit per subscription?

    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.

    3. What happens to RBAC assignments when I delete a resource group?

    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.

    4. Will deleting an empty resource group break my Terraform or Bicep deployments?

    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.

    5. How do I exclude specific resource groups from automated cleanup?

    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.

    6. Should I delete resource groups that were created by Azure services automatically?

    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.

    7. How do I identify who created the empty resource group?

    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.

    8. Can I recover a deleted resource group?

    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.

    9. How often should I run the cleanup?

    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.

    10. Does Azure Policy help prevent empty resource group accumulation?

    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.

    11. How does Spotto help with empty resource group cleanup?

    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.

    12. Can I clean up empty resource groups across multiple tenants?

    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.

    Use-Case and Scenario Coverage

    Scenario 1: MSP Quarterly Tenant Review

    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.

    30% reduction in quarterly audit review time

    Scenario 2: MSP Customer Offboarding

    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.

    Clean offboarding -- zero residual permissions

    Scenario 3: Enterprise Post-Migration Cleanup

    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.

    Audit compliance finding resolved

    Scenario 4: SaaS Dev/Test Sprawl Approaching Subscription Limit

    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.

    Subscription headroom restored -- 280 groups freed

    Implementation Guidance

    Preconditions

    Azure CLI or PowerShell Az module installed and authenticated
    Contributor or Owner role on the target subscriptions (required for resource group deletion)
    Azure Resource Graph Reader access for cross-subscription queries
    User Access Administrator role to export and review RBAC assignments
    Change management approval for production subscription modifications
    For MSPs: Azure Lighthouse delegation configured for customer subscriptions

    Rollout Approach

    Step 1: Discovery -- Run the Resource Graph query to identify all empty resource groups across your subscriptions. Export the full list with subscription IDs, resource group names, locations, and tags to a spreadsheet for team review.
    Step 2: Categorize -- Classify each empty resource group as "delete," "retain," or "investigate." Use the validation checklist (Activity Log, IaC references, DR tags, locks) to make the determination. Tag groups to retain with the appropriate exclusion tag.
    Step 3: Dry run -- Execute the cleanup runbook with $DryRun = $true. Review the output with the team. Confirm that no groups marked for deletion are needed.
    Step 4: Export and delete -- Run the cleanup with artifact export enabled and $DryRun = $false. Monitor the output for errors. Verify deletion by re-running the Resource Graph query.
    Step 5: Schedule recurring runs -- Deploy the runbook as a scheduled Azure Automation job. Start with monthly cadence and adjust based on the rate of empty group accumulation in your environment.
    Step 6: Upstream prevention -- Update CI/CD pipelines to delete the resource group (not just its contents) when decommissioning environments. Add a resource group deletion step to project offboarding checklists. Implement Azure Policy to require ownership tags on new resource groups.

    Failure Modes

    CI/CD pipeline failure after deletion: If a pipeline references a deleted resource group as a deployment target, the next run will fail with a "resource group not found" error. Always search pipeline configurations and IaC repositories for resource group name references before deleting.
    Managed resource group deletion: Deleting a resource group managed by another Azure service (e.g., AKS node resource group, managed application resource group) can break the parent service. Check the managedBy property before deleting any group.
    DR target removal: Deleting a resource group designated as a disaster recovery target will prevent failover deployments. Ensure DR resource groups are tagged with an exclusion tag and protected by a resource lock.
    Insufficient permissions: The cleanup runbook requires Contributor or Owner role on the subscription. If using a managed identity, ensure the identity has the correct role assignment. Insufficient permissions will cause deletion to fail silently or with an authorization error.

    Rollback Strategy

    Resource group deletion is irreversible. The rollback strategy is based on recreating the resource group and its governance configuration from the exported artifacts:

    Recreate the resource group: Use az group create --name <rg-name> --location <location> to recreate the resource group in the same location with the same name.
    Restore RBAC assignments: Use the exported CSV to recreate role assignments. Script the restoration using New-AzRoleAssignment with the exported principal IDs, role definition names, and scope.
    Restore policy assignments: Reapply policy assignments using the exported policy definition IDs and scope. Use New-AzPolicyAssignment with the exported parameters.
    Restore locks and tags: Reapply resource locks and tags using the exported data. Deployment history cannot be restored -- it is permanently lost on deletion.

    Cost Impact Summary

    RecommendationImpactComplexity
    Delete empty resource groups with no exclusion tags, locks, or recent activityRemoves stale RBAC, reduces audit scope, frees subscription quotaLow
    Implement automated recurring cleanup with exclusion tag supportPrevents re-accumulation, continuous governance hygieneMedium
    Update CI/CD pipelines to delete resource groups on environment teardownPrevents empty group accumulation at the sourceLow

    What to Do Next

    Step 1: Run the Resource Graph query -- Use the KQL query provided above to identify all empty resource groups across your subscriptions. Count the total and note which subscriptions have the highest concentration. This gives you the baseline.
    Step 2: Validate and categorize -- Review each empty group against the validation checklist: Activity Log, IaC references, DR tags, and locks. Tag groups that should be retained with an exclusion tag. Mark the rest for deletion.
    Step 3: Export artifacts and delete -- Run the cleanup runbook in dry-run mode first. Review the output. Then export governance artifacts and execute the deletion. Verify by re-running the Resource Graph query.
    Step 4: Automate and prevent -- Deploy the cleanup runbook on a recurring schedule. Update CI/CD pipelines to delete resource groups on teardown. Implement Azure Policy to enforce tagging on new resource groups. Consider Spotto for continuous, automated detection and cleanup across all subscriptions and tenants.

    Summary

    Empty resource groups are governance liabilities, not just clutter -- They retain RBAC assignments, policy evaluations, and deployment history that create audit noise, widen the attack surface, and consume subscription quota.
    The 980-group limit is a hard constraint -- Subscriptions with high project churn can approach this limit through empty group accumulation alone, blocking new workload deployments.
    Detection is fast with Resource Graph -- A single KQL query identifies all empty resource groups across all subscriptions in seconds, making this a low-effort, high-impact governance improvement.
    Validation before deletion is non-negotiable -- Check Activity Logs, IaC references, DR designations, and resource locks before deleting any resource group. The dry-run mode in the automation runbook enforces this discipline.
    Export governance artifacts before deletion -- RBAC assignments, policy configurations, and locks are permanently removed with the resource group. Exporting them preserves the audit trail and enables rollback if needed.
    Automation prevents re-accumulation -- One-off cleanups help, but without recurring automation and upstream pipeline changes, empty groups will return. Schedule the cleanup runbook and update CI/CD pipelines to delete groups on teardown.
    MSPs benefit the most from systematic cleanup -- The governance overhead of empty resource groups multiplies across customer subscriptions and tenants. Systematic detection and cleanup reduces audit time, tightens security, and demonstrates governance maturity to customers.

    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.

    Stop Carrying Governance Debt From Empty Resource Groups

    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 Trial

    Disclaimer: 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.