SCRIPT LIBRARY · POWERSHELL
Monitoring Azure Resource Costs with PowerShell
Find out which resource groups are eating your Azure budget, straight from PowerShell, no portal clicking required.
- What it does
- Totals actual Azure spend by resource group for the last N days, sorts it biggest-first, and can save the results to CSV.
- Requires
- PowerShell 7.2+ (Windows PowerShell 5.1 works too)
- Az.Accounts module
- Permissions
- Cost Management Reader (or Reader) on the subscription
- Runs on
- Windows, macOS, Linux, Azure Cloud Shell
- Tested
- Parse-checked and dry-run with a mocked Invoke-AzRestMethod in PowerShell 7.4
Part 7 of the thread PowerShell craft
Azure bills have a way of sneaking up on you. Somebody spins up a test VM on a Friday, forgets about it, and a month later there's a line item nobody can explain.
The portal's Cost analysis blade will tell you where the money went, but it's a lot of clicking when all you want is a quick answer: which resource groups cost the most this month? This script gets you that answer in one command, and it's easy to drop into a scheduled job or a weekly report.
It talks to the Cost Management API through Invoke-AzRestMethod, which means the only module you need is Az.Accounts. (The original version of this post used Get-AzConsumptionUsageDetail. It still works, but Microsoft steers people toward the Cost Management APIs now, and the dedicated Az.CostManagement cmdlets are still labeled preview.)
<#
.SYNOPSIS
Shows what each resource group in an Azure subscription has cost over a date range.
.DESCRIPTION
Calls the Azure Cost Management Query API through Invoke-AzRestMethod and totals
actual cost by resource group, biggest spender first. Optionally exports to CSV.
.PARAMETER SubscriptionId
The subscription to report on. Defaults to the subscription in your current Az context.
.PARAMETER Days
How many days back to look. Default: 30.
.PARAMETER CostColumn
PreTaxCost for most pay-as-you-go and EA subscriptions; Cost for Microsoft Customer Agreement.
.PARAMETER CsvPath
Optional path to save the results as a CSV file.
.EXAMPLE
.\Get-AzCostByResourceGroup.ps1 -Days 7
.EXAMPLE
.\Get-AzCostByResourceGroup.ps1 -SubscriptionId 00000000-0000-0000-0000-000000000000 -CsvPath .\costs.csv
#>
[CmdletBinding()]
param(
[string]$SubscriptionId,
[ValidateRange(1, 365)][int]$Days = 30,
[ValidateSet('PreTaxCost', 'Cost')][string]$CostColumn = 'PreTaxCost',
[string]$CsvPath
)
$ErrorActionPreference = 'Stop'
# Sign in only if there's no existing session.
if (-not (Get-AzContext)) { Connect-AzAccount | Out-Null }
if (-not $SubscriptionId) { $SubscriptionId = (Get-AzContext).Subscription.Id }
$to = (Get-Date).Date
$from = $to.AddDays(-$Days)
# One query: actual cost, summed, grouped by resource group.
$body = @{
type = 'ActualCost'
timeframe = 'Custom'
timePeriod = @{ from = $from.ToString('yyyy-MM-dd'); to = $to.ToString('yyyy-MM-dd') }
dataset = @{
granularity = 'None'
aggregation = @{ totalCost = @{ name = $CostColumn; function = 'Sum' } }
grouping = @(@{ type = 'Dimension'; name = 'ResourceGroupName' })
}
} | ConvertTo-Json -Depth 6
$uri = "https://management.azure.com/subscriptions/$SubscriptionId/providers/Microsoft.CostManagement/query?api-version=2023-11-01"
$rows = @()
do {
# Cost Management throttles hard. Back off and retry a few times on HTTP 429.
for ($attempt = 1; $attempt -le 4; $attempt++) {
$response = Invoke-AzRestMethod -Uri $uri -Method POST -Payload $body
if ($response.StatusCode -ne 429) { break }
Write-Warning "Cost Management is throttling requests. Waiting 30 seconds (attempt $attempt of 4)..."
Start-Sleep -Seconds 30
}
if ($response.StatusCode -ge 400) {
throw "Cost query failed ($($response.StatusCode)): $($response.Content)"
}
$result = $response.Content | ConvertFrom-Json
$columns = @($result.properties.columns.name)
$rows += $result.properties.rows
$uri = $result.properties.nextLink
} while ($uri)
$costIndex = [array]::IndexOf($columns, $CostColumn)
$groupIndex = [array]::IndexOf($columns, 'ResourceGroupName')
$currIndex = [array]::IndexOf($columns, 'Currency')
$report = foreach ($row in $rows) {
[pscustomobject]@{
ResourceGroup = if ($row[$groupIndex]) { $row[$groupIndex] } else { '(no resource group)' }
Cost = [math]::Round([double]$row[$costIndex], 2)
Currency = $row[$currIndex]
}
}
$report = @($report | Sort-Object Cost -Descending)
$total = ($report | Measure-Object -Property Cost -Sum).Sum
Write-Host ("Total for the last {0} days: {1:N2} {2}" -f $Days, $total, $report[0].Currency)
if ($CsvPath) {
$report | Export-Csv -Path $CsvPath -NoTypeInformation
Write-Host "Saved to $CsvPath"
}
# Return real objects, so you can pipe them: | Where-Object Cost -gt 100
$report
Parameters
| Parameter | Type | Default | What it's for |
|---|---|---|---|
-SubscriptionId | string | — | Which subscription to report on. Leave it off and the script uses whatever subscription your Az session is pointed at. |
-Days | int | 30 | How far back to look, from 1 to 365 days. |
-CostColumn | string | PreTaxCost | Use PreTaxCost for pay-as-you-go and EA subscriptions, Cost for Microsoft Customer Agreement accounts. |
-CsvPath | string | — | Save the results to a CSV file as well as printing them. |
Run it
The last week, for whichever subscription you're signed in to.
.\Get-AzCostByResourceGroup.ps1 -Days 7A full month for a specific subscription, saved to CSV for the monthly report.
.\Get-AzCostByResourceGroup.ps1 -SubscriptionId 00000000-0000-0000-0000-000000000000 -CsvPath .\azure-costs.csvOn a Microsoft Customer Agreement? Swap the cost column.
.\Get-AzCostByResourceGroup.ps1 -CostColumn CostJust the big spenders. The script returns real objects, so you can filter them like anything else.
.\Get-AzCostByResourceGroup.ps1 | Where-Object Cost -gt 100What you'll see
Total for the last 30 days: 799.62 USD
ResourceGroup Cost Currency
------------- ---- --------
rg-prod-web 412.87 USD
rg-data-warehouse 298.10 USD
rg-shared-network 61.45 USD
rg-dev-sandbox 23.02 USD
(no resource group) 4.18 USD
How it works
The whole thing is one API call, plus some cleanup.
- Sign in, if needed. If you've already run
Connect-AzAccountin this session, the script reuses it. No surprise login prompts in the middle of a scheduled task. - Build the query. It asks Cost Management for actual cost over your date range, summed up and grouped by
ResourceGroupName. Asking for totals instead of daily rows keeps the response small and fast. - Handle throttling and paging. Cost Management is stingy with requests. If it answers with a 429, the script waits 30 seconds and tries again. If the result spans more than one page, it follows
nextLinkuntil it has every row. - Turn rows into objects. The API returns bare arrays, so the script looks up which column is which by name, rather than trusting their position. What comes back are real PowerShell objects, not a pre-formatted table, so you can sort, filter, or pipe them wherever you like.
Take it further
- Group by something else. Change
ResourceGroupNametoServiceName,ResourceType, orMeterCategoryto see what kind of thing is costing money instead of where it lives. - Watch it over time. Set
granularitytoDailyand you'll get one row per day per group, which is perfect for spotting the day something started running away. - Put it on a schedule. Run it in an Azure Automation runbook with a managed identity, then email the CSV or post it to Teams every Monday morning.
Things that'll trip you up
- Yesterday's costs aren't in yet. Azure cost data usually lags by 8 to 24 hours, so today and yesterday will look cheaper than they really are. For a clean comparison, end your window a day or two back.
- A 400 error about the cost column. That almost always means your billing account type wants the other column name. Try -CostColumn Cost.
- 401 or 403 errors. Your account needs Cost Management Reader (or plain Reader) on the subscription. Owners and Contributors already have it.
- The "(no resource group)" row. Some charges, like certain marketplace purchases and reservations, aren't tied to a resource group. They're real money, so the script keeps them instead of hiding them.