How To

How to Audit Your Azure Costs with Native Tools (Cost Management, Advisor, Azure Monitor)

Part two of the Azure cost series: a full cost audit using only Cost Management, Azure Advisor, Azure Monitor and az CLI sweeps, run one tool at a time and covering all seven leaks. It's honest about where it stops: the audit goes stale on the day you finish it and fixes nothing by itself.

12 min read
Cover Image for How to Audit Your Azure Costs with Native Tools (Cost Management, Advisor, Azure Monitor)

How to Audit Your Azure Costs with Native Tools (Cost Management, Advisor, Azure Monitor)

Azure never bills you incorrectly. It bills you precisely, to four decimal places, for the unattached Premium SSD nobody remembers creating, the staging VM that last served a request in March, and the Application Gateway in front of a service you decommissioned last quarter. An Azure cost audit is how you find those line items before they compound, and you can run one end to end with tooling Microsoft already ships: Cost Management + Billing, Azure Advisor, Azure Monitor and the az CLI. No third-party platform, no agent installs, no procurement conversation.

Part one named the seven places Azure bills leak: unattached managed disks and stale snapshots, idle or oversized VMs, reservation and savings plan coverage gaps, unused public IPs and idle load balancers or Application Gateways, over-provisioned Azure SQL, bandwidth charges, and non-production environments running 24/7. This Azure cost management tutorial works through all seven, grouped by tool rather than by leak, because that's how the work actually flows: you open one blade or one terminal and take everything it can tell you before moving on.

01Prioritize

Cost Management

Read the bill by service, resource group, tag and pricing model before touching anything.

02Shortlist

Azure Advisor

Free, pre-computed recommendations for VMs, reservations and idle gateways.

03Prove

Azure Monitor

Thirty days of metrics that confirm a resource is idle before anyone resizes it.

04Sweep

az CLI

Inventory queries for the leaks Advisor never reports: disks, snapshots, IPs, stopped VMs.

One tool at a time, harvesting everything it can tell you before moving on.

Plan for three to four focused hours on a first pass across a handful of subscriptions. One prerequisite: you need at least the Cost Management Reader role on every subscription in scope, plus Reader for the CLI inventory queries. If a subscription is missing from your view, the audit silently under-reports, so confirm scope first with az account list --output table.


Cost Management + Billing: read the bill before touching a resource

Everything in this section lives at Cost Management + Billing → Cost Management → Cost analysis (Microsoft Learn: quickstart). Resist the urge to start deleting disks. Ten minutes here decides which of the seven leaks deserves your afternoon and which is a rounding error in your tenant.

Group by service, resource group and tag

Open Cost analysis, set the range to the last three months with Monthly granularity, and work through three groupings in order:

  1. Group by: Service name. This is your priority list. If Virtual Machines, Storage and Azure SQL carry most of the bill (the usual pattern at a mid-market SaaS), VM rightsizing, disk hygiene and SQL tiers are where the hours below pay off. If Bandwidth or a data service dominates, reweight.
  2. Group by: Resource group. Sort descending and scan for names you can't explain: old project codenames, a departed engineer's initials, anything ending in -test or -poc that's somehow still accruing. Each unexplained resource group is a finding. Write it down now and chase ownership later.
  3. Group by: Tag, keyed on environment (or your equivalent). This measures leak seven directly. A weekday business schedule is roughly 60 of the week's 168 hours, so any non-production compute cost above about 36% of its always-on equivalent is money spent while nobody is working. Check the untagged slice too. If it's a double-digit percentage of spend, poor tagging is itself a finding, because it caps how precise every future audit can be.

For bandwidth (leak six), stay in Cost analysis, filter Service name = Bandwidth, then switch Group by to Meter. The meter names separate internet egress from inter-region transfer. There's no universal healthy number here, so audit the trend: bandwidth growing faster than request volume usually means chatty cross-region replication or traffic taking a paid public path that a private one would serve. Flag it for an architecture conversation rather than a quick fix.

Budgets and alerts: make the next audit interrupt-driven

Before leaving the blade, go to Cost Management → Budgets and create one budget per subscription (Microsoft Learn: create budgets). Set the amount to last month's actuals plus expected growth, with alerts at 80% and 100% of Actual cost and one at 100% of Forecasted cost. Budgets fix nothing. They're tripwires. But the forecasted alert turns "we found the overrun at invoice time" into an email mid-month, while the spend is still stoppable.

Reservation and savings plan coverage

Leak three hides in plain sight in Cost analysis. Change Group by to Pricing model and switch the metric to Amortized cost, so reservation purchases spread across their term instead of distorting the purchase month. The chart now splits spend into On Demand, Reservation, Savings Plan and Spot.

The number to interrogate is the on-demand share of your stable baseline: compute that runs around the clock regardless of load. A healthy target is roughly 70 to 85% commitment coverage of that baseline, with the rest on demand for elasticity and churn. Well below that, you're paying list price for capacity whose usage you could predict, and Microsoft's published reservation discounts reach 72% for three-year terms (Microsoft Learn: reservations), so the gap is rarely small money. Don't chase 100%. Every point of coverage above your true baseline becomes a commitment you have to feed.

Coverage is half the ledger. Open Cost Management + Billing → Reservations and check each reservation's utilization (Microsoft Learn: reservation utilization) and its expiry date. Utilization persistently below the mid-90s means an earlier commitment outlived its workload. Instance size flexibility within the VM family may absorb it, and exchanging the reservation beats buying more coverage on top of a leak. Read coverage and utilization together before any purchase. Buying coverage while existing reservations sit half-used just moves the waste.


Azure Advisor: recommendations you already have, read correctly

Advisor's Cost category is the closest thing to a free pre-run audit: generated continuously, zero setup, already waiting at Advisor → Cost (Microsoft Learn: Advisor cost recommendations). The CLI equivalent:

az advisor recommendation list --category Cost --output table

Here's how Advisor recommendations map onto the leaks from part one:

Advisor recommendationLeak it covers
Right-size or shutdown underutilized virtual machinesIdle and oversized VMs
Buy reserved virtual machine instancesReservation coverage gaps
Buy an Azure savings plan for computeSavings plan coverage gaps
Delete or reconfigure idle virtual network gatewaysUnused network infrastructure
Delete unprovisioned ExpressRoute circuitsUnused network infrastructure

Read two properties of this list before you treat it as your findings document.

The savings estimates are directional. Each recommendation carries an estimated annual saving computed from retail rates and recent usage. It doesn't know your negotiated discounts, your amortization, or the batch job that runs on the 1st of the month. Use the figures to rank findings (the ordering holds up even when the dollars don't), and never paste them into a business case as committed savings.

The VM check is conservative by default. The default low-CPU threshold is 5%, so a VM idling at 12% is still mostly wasted and Advisor stays quiet about it. Widen the net:

az advisor configuration update --low-cpu-threshold 20

Recommendations regenerate against the new threshold within about a day. Even then, treat every flagged VM as a candidate. A short lookback can't see a monthly close or a quarterly report job, which is what the Azure Monitor pass below is for.

Look at what the table doesn't contain: unattached disks, aging snapshots, orphaned public IPs, over-provisioned Azure SQL, bandwidth and non-prod scheduling are absent or only partly covered. Roughly half of part one's leaks never show up in Advisor, which is why the CLI section exists.

For the findings document, use Download as CSV on the Advisor page (or redirect the CLI output to a file) and put the export date in the filename. You'll care about staleness later.


Azure Monitor: prove a resource is actually idle

Every resize or shutdown in this audit should survive 30 days of real metrics before anyone acts on it. Azure Monitor is that evidence layer, and az monitor metrics list is the fastest way to pull it (Microsoft Learn: az monitor metrics).

Grab the resource ID of a suspect VM:

az vm show --resource-group my-rg --name my-vm --query id --output tsv

Then pull a month of CPU at hourly resolution:

az monitor metrics list \
  --resource "/subscriptions/<subscription-id>/resourceGroups/<rg>/providers/Microsoft.Compute/virtualMachines/<vm-name>" \
  --metric "Percentage CPU" \
  --start-time 2026-09-01T00:00:00Z \
  --end-time 2026-10-01T00:00:00Z \
  --interval PT1H \
  --aggregation Average Maximum \
  --output table

You get one row per hour with that hour's average and maximum CPU. Three shapes cover almost every VM you'll look at:

01Avg under 10%, max under 40%

Oversized

Real headroom. One size step down in the same family is a low-drama change.

02Low avg, max above 80%

Bursty

Downsizing hurts at the worst moment. Look at the B-series instead.

03Flat near zero

Abandoned

Not a sizing question. Find the owner or the half-finished teardown ticket.

Read the maximum column as well as the average. A nightly batch job only shows up there.

Guest-level memory metrics need the Azure Monitor agent. If you haven't deployed it, CPU plus network is your honest signal. Say so in the findings doc instead of guessing at memory pressure.

The same command audits leak five. Point --resource at an Azure SQL database ID (/subscriptions/.../providers/Microsoft.Sql/servers/<server>/databases/<db>) and swap the metric: cpu_percent for vCore databases, dtu_consumption_percent for DTU ones. A database that never pushes past 40% for a month is provisioned at least one tier too high, and because Azure SQL tier changes are online operations, these findings carry the least remediation risk in the audit. Check the maximums too: a nightly index rebuild lives in the max column.


az CLI sweeps: the inventory Advisor never runs

The remaining leaks (orphaned disks, stale snapshots, unassociated IPs, questionable load balancers and Application Gateways) don't appear in any portal recommendation. Finding them is plain az CLI cost analysis: list resources in a known-wasteful state and turn each row into a ticket. The JMESPath filters below make the output an action list, not raw JSON to grep.

Unattached managed disks (leak one). Deleting a VM doesn't delete its disks unless deletion was explicitly configured, and an unattached disk bills at its full provisioned rate (Microsoft Learn: az disk):

az disk list \
  --query "[?diskState=='Unattached'].{Name:name, RG:resourceGroup, SizeGB:diskSizeGb, Sku:sku.name}" \
  --output table

Look at the SKU column first: an unattached Premium SSD is the most expensive kind of nothing Azure sells. Snapshot each disk, then delete it. The snapshot costs a fraction of the disk and keeps your rollback story.

Snapshots older than your retention policy (also leak one). List them oldest first:

az snapshot list \
  --query "sort_by([], &timeCreated)[].{Name:name, RG:resourceGroup, SizeGB:diskSizeGb, Created:timeCreated}" \
  --output table

Snapshots are individually cheap and collectively not. Anything older than your retention cutoff (90 days is a common default) with no documented reason gets deleted. Anything with a reason gets that reason written down.

Unassociated public IPs (leak four). A Standard public IP bills whether or not anything answers on it:

az network public-ip list \
  --query "[?ipConfiguration==null].{Name:name, RG:resourceGroup, Sku:sku.name}" \
  --output table

The per-IP cost is small. The finding matters because each orphaned IP marks a teardown that stopped halfway, so expect its siblings (disks, NICs, NSGs) in the same resource group.

VM power state (leaks two and seven, with an Azure-specific trap). A VM stopped from inside the guest OS reports VM stopped and keeps billing for compute. Only VM deallocated releases the hardware and stops the meter:

az vm list -d \
  --query "[?powerState=='VM stopped'].{Name:name, RG:resourceGroup, Size:hardwareProfile.vmSize}" \
  --output table

Every row here pays full compute price to do nothing. Run az vm deallocate on it or delete it, and remember that a deallocated VM still pays for its disks and static IPs, so the earlier sweeps apply to it too. While you're in this dataset, swap the filter to 'VM running' and cross-reference your environment tag: every non-production VM running at 2 a.m. is leak seven with a name and a resource group.

Load balancers and Application Gateways (leak four). There's no idle flag to query, so enumerate first:

az network lb list \
  --query "[].{Name:name, RG:resourceGroup, Sku:sku.name}" --output table

az network application-gateway list \
  --query "[].{Name:name, RG:resourceGroup, State:operationalState, Tier:sku.tier}" \
  --output table

Then verify suspects with the Azure Monitor pattern above: --metric "ByteCount" for a load balancer, --metric "TotalRequests" for an Application Gateway, over 14 days. Application Gateways come first. They carry a meaningful fixed hourly charge, so one idle gateway outcosts a dozen orphaned IPs.

Run everything per subscription. All of the above is scoped to the active subscription, and waste concentrates in exactly the subscriptions nobody looks at. Loop the sweep:

for sub in $(az account list --query "[?state=='Enabled'].id" --output tsv); do
  az account set --subscription "$sub"
  echo "=== $(az account show --query name --output tsv) ==="
  az disk list --query "[?diskState=='Unattached'].name" --output tsv
done

Add the other queries inside the loop. The dusty sandbox subscription from 2023 is usually your best hunting ground.


What the native audit covers, and what it doesn't

Native auditWhat it leaves to you
Finding the leaksAll seven, across four toolsRunning the four tools in the right order
Proving a resource is idle30 days of Azure Monitor metricsReading the max column and the calendar
ContextTags, if you have themKnowing it's the quarterly batch job
CadenceWhenever someone runs itRemembering to, every month
FixesNone. Every tool reports or listsTickets, owners, change windows

The deeper difference is between a snapshot and a process. Every tool in this article answers "what's wasteful right now?" accurately. None of them keeps asking, and none of them acts on the answer.

01Freshness

Expires on finish

Wednesday's deploys start recreating the waste you audited on Tuesday.

02Cost

Payroll, monthly

An afternoon every month from an engineer whose alternative was shipping.

03Evidence

Rots in the queue

A resize ticket built on June's metrics lands in an August sprint.

04Action

Remediates nothing

Every tool here reports, recommends or lists. Every fix is still a ticket.

The native stack is enough to find the money. It isn't built to keep finding it.

It expires the moment you finish. You audited a Tuesday. Wednesday's deploys start recreating unattached disks, untagged resources and idle VMs, because each of the seven leaks is a byproduct of normal engineering work, not a one-time mess.

It has a payroll cost that compounds. The first pass takes an afternoon. Keeping it monthly takes an afternoon every month, and that trade gets renegotiated every sprint. The audit usually loses.

The evidence rots faster than the tickets move. A rightsizing finding built on June's metrics describes June. If the ticket lands in an August sprint, you're resizing on a two-month-old picture of the workload. Sometimes fine, sometimes a paged on-call engineer.

Nothing above remediates anything. Cost Management reports, Advisor recommends, Monitor measures and the CLI lists. None of them deletes a disk, deallocates a VM or buys a reservation. The gap between audited savings and realized savings is exactly the set of tickets that never found an owner.

What comes next

The fix for all four is the same: stop treating the audit as an event. Part three runs these exact checks continuously with read-only Azure access, investigates each finding against the context the native tools lack, and puts every fix behind your approval.

Related reading

Or see it against your own subscriptions today: CloudThinker's free tier includes 100 premium credits, no card required.

azurecostoptimizationfinops