Introduction & Problem Statement
Picture this: You're tasked with auditing 200 virtual machines across multiple Azure subscriptions. Your PowerShell script needs to check VM status, retrieve configurations, and gather diagnostic information. Using the traditional approach, this means making 600+ individual API calls to Azure Resource Manager (ARM) - three calls per VM.
The result? Your script crawls along for 30+ minutes, constantly hits throttling limits, and occasionally fails with timeout errors. Meanwhile, your manager is asking why the monthly audit report is always late, and you're spending more time babysitting scripts than actually analyzing the data.
What if I told you the same audit could be completed in under 3 minutes with just 30 API calls?
That's the power of Azure Resource Manager API batching. A lesser-known but incredibly powerful Azure capability that can reduce your API calls by up to 80%. In this guide, we'll explore two PowerShell functions that make this possible: New-AzureBatchRequest and Invoke-AzureBatchRequest hosted in the AzureCommonStuff module.
Understanding the Challenge
Why Individual API Calls Don't Scale
Azure Resource Manager implements rate limiting to protect the service infrastructure. The current limits are:
Read operations: 12,000 requests per hour per subscription
Write operations: 1,200 requests per hour per subscription
While these numbers might seem generous, they disappear quickly in automation scenarios. Consider a simple VM inventory script:
Every individual API call carries overhead:
Network latency: 50-200ms per request, depending on your location
Authentication processing: Token validation for each request
Connection overhead: TCP handshake and SSL negotiation
JSON processing: Multiple small payloads instead of one optimized batch
Common Workarounds and Their Limitations
Most PowerShell developers try these approaches:
Sequential processing with delays - Slow and still hits limits
Parallel processing with ForEach-Object -Parallel - Actually makes throttling worse
Custom retry logic - Adds complexity but doesn't solve the root problem
These approaches treat the symptoms, not the cause. We need a fundamentally different approach.
The Hidden Solution
Azure Resource Manager supports an undocumented batch API endpoint that combines up to 20 individual requests into a single HTTP call. This isn't in the official documentation, but it's the same mechanism the Azure portal uses behind the scenes (as shown in the image below 👇). It's stable, reliable, and incredibly powerful once you know how to use it.

Solution Overview
How Batching Works
Instead of making individual API calls, we package multiple requests into a single batch operation. Azure processes these requests concurrently on the server side and returns all results in one response.
$vmData = @()
foreach ($vmName in $vmNames) {
$vm = Get-AzVM -ResourceGroupName $rgName -Name $vmName
$vmData += $vm
}
$batchRequests = New-AzureBatchRequest -url "/subscriptions/$subscriptionId/resourceGroups/$rgName/providers/Microsoft.Compute/virtualMachines/<placeholder>?api-version=2023-07-01" -placeholder $vmNames
$vmData = Invoke-AzureBatchRequest -batchRequest $batchRequests
Key Concepts
Batch Request Objects: Each request contains a method (GET, POST, etc.), URL, and optional headers. The batch system processes these concurrently.
Placeholder Pattern: When you need similar requests with different IDs, the placeholder system generates requests automatically without manual loops.
Error Isolation: If one request in a batch fails, the others continue processing. Each response includes its own status code and error details.
How It Works
New-AzureBatchRequest
Function New-AzureBatchRequest creates standardized request objects that can be batched together:
URL Flexibility: You can use absolute URLs (https://management.azure.com/...) or relative URLs (/subscriptions/...). The function handles both seamlessly.
Placeholder Magic: The real power comes from the placeholder parameter. Instead of writing loops, you specify a URL template with <placeholder> and provide an array of values:
$requests = @()
foreach ($subscriptionId in $subscriptionIds) {
$requests += @{
Name = "sub_$subscriptionId"
HttpMethod = "GET"
URL = "/subscriptions/$subscriptionId/providers/Microsoft.Compute/virtualMachines?api-version=2023-07-01"
}
}
$requests = New-AzureBatchRequest -url "/subscriptions/<placeholder>/providers/Microsoft.Compute/virtualMachines?api-version=2023-07-01" -placeholder $subscriptionIds
Invoke-AzureBatchRequest
Function Invoke-AzureBatchRequest handles the complex orchestration of sending batches and processing responses:
Automatic Chunking: The function automatically splits your requests into chunks of 20 (Azure's limit) and processes them sequentially.
Response Beautification: By default, the function extracts just the data you need from Azure's verbose response format, making results much easier to work with.
Step-by-Step Walkthrough
Let's trace through a complete batching operation:
Step 1: Create Batch Requests
$resourceGroups = @('rg-web-servers', 'rg-database-servers', 'rg-cache-servers')
$subscriptionId = (Get-AzContext).Subscription.Id
$requests = New-AzureBatchRequest -url "/subscriptions/$subscriptionId/resourceGroups/<placeholder>/providers/Microsoft.Compute/virtualMachines?api-version=2023-07-01" -placeholder $resourceGroups
Behind the scenes, this creates three request objects:
@(
@{ Name = "123456"; HttpMethod = "GET"; URL = "/subscriptions/abc.../resourceGroups/rg-web-servers/providers/Microsoft.Compute/virtualMachines?api-version=2023-07-01" },
@{ Name = "789012"; HttpMethod = "GET"; URL = "/subscriptions/abc.../resourceGroups/rg-database-servers/providers/Microsoft.Compute/virtualMachines?api-version=2023-07-01" },
@{ Name = "345678"; HttpMethod = "GET"; URL = "/subscriptions/abc.../resourceGroups/rg-cache-servers/providers/Microsoft.Compute/virtualMachines?api-version=2023-07-01" }
)
Step 2: Execute the Batch
$allVMs = Invoke-AzureBatchRequest -batchRequest $requests
The function:
Validates all URLs and request objects
Chunks requests into groups of 20
Sends each chunk as a single HTTP POST to Azure's batch endpoint
Processes responses and handles errors
Beautifies results by extracting just the VM data
Step 3: Work with Results
$windowsVMs = $allVMs | Where-Object { $_.storageProfile.osDisk.osType -eq 'Windows' }
$runningVMs = $allVMs | Where-Object { $_.powerState -eq 'VM running' }
Write-Host "Found $($allVMs.Count) total VMs, $($windowsVMs.Count) Windows VMs, $($runningVMs.Count) running"
Practical Examples
💡
The
AzurePIMStuff module includes functions that utilize batching under the hood; feel free to take inspiration from them.
Real-World Scenario: Security Compliance Audit
Here's a more complex example that demonstrates the power of batching for compliance scenarios:
$subscriptionIds = (Get-AzSubscription | Where-Object State -eq 'Enabled').Id
$auditRequests = @()
$auditRequests += New-AzureBatchRequest -url "/subscriptions/<placeholder>/providers/Microsoft.Network/networkSecurityGroups?api-version=2024-03-01" -placeholder $subscriptionIds -name "NSGs"
$auditRequests += New-AzureBatchRequest -url "/subscriptions/<placeholder>/providers/Microsoft.Storage/storageAccounts?api-version=2023-05-01" -placeholder $subscriptionIds -name "Storage"
$auditRequests += New-AzureBatchRequest -url "/subscriptions/<placeholder>/providers/Microsoft.KeyVault/vaults?api-version=2024-11-01" -placeholder $subscriptionIds -name "KeyVaults"
Write-Host "Starting audit of $($subscriptionIds.Count) subscriptions..."
$auditResults = Invoke-AzureBatchRequest -batchRequest $auditRequests
$nsgs = $auditResults | Where-Object { $_.RequestName -like "NSGs_*" }
$storageAccounts = $auditResults | Where-Object { $_.RequestName -like "Storage_*" }
$keyVaults = $auditResults | Where-Object { $_.RequestName -like "KeyVaults_*" }
Write-Host "Audit Summary:"
Write-Host "- Network Security Groups: $($nsgs.Count)"
Write-Host "- Storage Accounts: $($storageAccounts.Count)"
Write-Host "- Key Vaults: $($keyVaults.Count)"
$publicStorageAccounts = $storageAccounts | Where-Object { $_.properties.allowBlobPublicAccess -eq $true }
if ($publicStorageAccounts) {
Write-Warning "Found $($publicStorageAccounts.Count) storage accounts with public blob access enabled"
}
Performance comparison for this scenario:
Traditional approach: 600+ API calls, 15-20 minutes
Batching approach: 30 API calls, 2-3 minutes
Combining different HTTP Methods
While most scenarios use GET requests, you can batch other operations too:
$mixedRequests = @()
$mixedRequests += New-AzureBatchRequest -url "/subscriptions/$subscriptionId/resourceGroups/<placeholder>/providers/Microsoft.Compute/virtualMachines?api-version=2023-07-01" -placeholder $resourceGroups -name "GetVMs"
$vmIds = @('vm1', 'vm2', 'vm3')
$mixedRequests += New-AzureBatchRequest -url "/subscriptions/$subscriptionId/resourceGroups/$rgName/providers/Microsoft.Compute/virtualMachines/<placeholder>/restart?api-version=2024-11-01" -placeholder $vmIds -method "POST" -name "RestartVMs"
$results = Invoke-AzureBatchRequest -batchRequest $mixedRequests
$getResults = $results | Where-Object { $_.RequestName -like "GetVMs_*" }
$restartResults = $results | Where-Object { $_.RequestName -like "RestartVMs_*" }
Multiple KQL queries
[System.Collections.Generic.List[object]] $batchRequest = @()
$queryUrl = "https://management.azure.com/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01"
$diskQuery = @"
ExtensibilityResources
| where type =~ "microsoft.azurestackhci/virtualmachineinstances"
"@
$batchRequest.add((New-AzureBatchRequest -method POST -url $queryUrl -content @{ query = $diskQuery } -name "diskInfo" ))
$nicQuery = @"
resources
| where type =~ "Microsoft.AzureStackHCI/networkinterfaces" and
properties.provisioningState =~ "succeeded"
"@
$batchRequest.add((New-AzureBatchRequest -method POST -url $queryUrl -content @{ query = $nicQuery } -name "nicInfo"))
$batchResult = Invoke-AzureBatchRequest -batchRequest $batchRequest
$vmListDiskInfo = $batchResult | ? RequestName -EQ "diskInfo"
$vmListNicInfo = $batchResult | ? RequestName -EQ "nicInfo"
Troubleshooting Tips
When things don't work as expected, try these approaches:
$VerbosePreference = 'Continue'
$results = Invoke-AzureBatchRequest -batchRequest $requests -Verbose
Invoke-AzureBatchRequest -batchRequest $requests -dontBeautifyResult
$testUrl = "/subscriptions/$subscriptionId/resourceGroups/$rgName/providers/Microsoft.Compute/virtualMachines?api-version=2023-07-01"
try {
$testResult = Invoke-AzRestMethod -Uri "https://management.azure.com$testUrl" -Method GET
Write-Host "Test successful: $($testResult.StatusCode)"
} catch {
Write-Error "URL test failed: $_"
}
Tips and Considerations
Use Resource Graph Query: If possible, gather your data from the Resource Graph Table, which will always be faster than api calls. Nice example of getting IAM assignments.
Monitor Your Quotas: Even with batching, be mindful of overall API limits across your entire environment. Use Get-AzConsumptionUsageDetail to monitor usage patterns.
Common Mistakes to Avoid
URL Template Errors: Make sure your placeholder strings don't conflict with actual URL parameters:
$badUrl = "/virtualMachines?$filter=name eq '<placeholder>'"
$goodUrl = "/virtualMachines/<placeholder>"
Authentication Scope Issues: Ensure your PowerShell session has permissions for all resources you're querying. Cross-subscription batching requires appropriate access.
Api version errors: Ensure you append a valid api version (api-version=2023-07-01 for example) to each URL request!
".../resourceGroups/$rgName/providers/Microsoft.Compute/virtualMachines?api-version=2023-07-01"
If you are unsure what api to use:
Check official documentation.
Use a random one, and when the request fails with a 400 error, check the error message for the list of correct api versions.
Use the official corresponding Az cmdlet with -debug parameter (Get-AzStorageAccount -debug) and check the 'Absolute uri' output.
Use developer tools (F12) in your browser when using the Azure Portal and check the request URL there.
Getting Started
These functions are part of the AzureCommonStuff module on PowerShell Gallery. Here's how to install it:
Install-Module AzureCommonStuff
Import-Module AzureCommonStuff
Get-Command New-AzureBatchRequest, Invoke-AzureBatchRequest
Get-Help New-AzureBatchRequest -Examples
Get-Help Invoke-AzureBatchRequest -Examples
Prerequisites
Before using these functions, make sure you have:
PowerShell 5.1 or PowerShell 7+
Az PowerShell module installed (Install-Module Az)
Authenticated Azure session (Connect-AzAccount)
Appropriate permissions for the resources you want to access
Summary
Azure Resource Manager API batching transforms the way you interact with Azure at scale. By reducing API calls and execution times, these PowerShell functions unlock new possibilities for automation, reporting, and compliance scenarios.
The functions New-AzureBatchRequest and Invoke-AzureBatchRequest leverage an undocumented but stable Azure API that's used by the Azure portal itself. While it's not officially documented, it's proven reliable across multiple Azure updates and represents a significant competitive advantage for anyone doing serious Azure automation work.
Start experimenting with small batches, measure the performance improvements, and gradually adopt batching across your Azure automation portfolio. Your scripts will run faster, your users will be happier, and you'll spend less time waiting for operations to complete and more time solving interesting problems.