Wes Ellis./ a personal notebook
Technology. Stories. Side projects.
A few things worth writing down.
← Back to Script Library

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.

AT A GLANCEGet-AzCostByResourceGroup.ps1
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.)

Get-AzCostByResourceGroup.ps1Download
<#
.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

ParameterTypeDefaultWhat it's for
-SubscriptionIdstring—Which subscription to report on. Leave it off and the script uses whatever subscription your Az session is pointed at.
-Daysint30How far back to look, from 1 to 365 days.
-CostColumnstringPreTaxCostUse PreTaxCost for pay-as-you-go and EA subscriptions, Cost for Microsoft Customer Agreement accounts.
-CsvPathstring—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 7

A 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.csv

On a Microsoft Customer Agreement? Swap the cost column.

.\Get-AzCostByResourceGroup.ps1 -CostColumn Cost

Just the big spenders. The script returns real objects, so you can filter them like anything else.

.\Get-AzCostByResourceGroup.ps1 | Where-Object Cost -gt 100

What you'll see

Example outputvalues are illustrative
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.

  1. Sign in, if needed. If you've already run Connect-AzAccount in this session, the script reuses it. No surprise login prompts in the middle of a scheduled task.
  2. 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.
  3. 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 nextLink until it has every row.
  4. 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 ResourceGroupName to ServiceName, ResourceType, or MeterCategory to see what kind of thing is costing money instead of where it lives.
  • Watch it over time. Set granularity to Daily and 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.