Your Three Cloud Bills Don't Mean the Same Thing

Multi-cloud cost isn't an aggregation problem. AWS, Azure and Google Cloud don't just send different bills, they disagree about what an account is, what a row is, and when a number is finished. Here's where that quietly makes a dashboard wrong.

Imagine the dashboard is finally done.

Three clouds, one chart, one currency. You post the screenshot and somebody replies:

"Great. So now we can see everything in one place."

And they're right. You can see it.

What nobody in that thread can tell you is whether the number is correct.

Because a multi-cloud dashboard is three different languages rendered in the same font. Putting the bills side by side makes them look comparable. It doesn't make them comparable.

AWS, Azure and Google Cloud don't just give you different bills. They give you different ways of thinking about cost.

That's the problem with treating aggregation as the goal. Stacking three totals in one chart is the easy half of the work, and it's the half that hides the other one.

Normalization should work differently. Before three numbers can sit in the same column, something has to decide what each of them actually means: which slice of spend it identifies, whether it's already counted, and whether the day it belongs to is even finished yet.

This article builds on our previous guide to Cloud Cost Allocation by Tag. There, tagging answered the question:

"Who owns this spend?"

Here we go one layer underneath it:

"Is this number the same kind of thing as the one next to it?"

They don't agree on what an account is

You can merge two teams' spend into a single number and never notice, because the merged number looks perfectly reasonable.

That happens because there's no shared idea of what an account even is.

Azure gives you a subscription containing resource groups. AWS gives you an account with resources spread across regions. Google Cloud gives you a project, and that project's billing rows sit in a shared table alongside every other project paid for by the same billing account, separated only by a filter you have to get right.

None of these map onto each other. A resource group is not a region. A project is not a subscription.

Any tool that pretends there's one hierarchy has picked a favourite cloud and is bending the other two to fit it.

The approach that holds up is to stop trying. Store a provider-agnostic row (provider, account, service, resource, location, date, cost, quantity) and let each cloud's adapter decide how to fill it in.

Resource group, region and project all land in the same column, because at read time you don't care which cloud coined the term. You care which slice of spend it identifies.

They don't agree on what a row is

This is the failure mode worth worrying about, because it's invisible.

You can receive the same cost twice, from the same provider, in the same response.

Handle those repeated rows the wrong way and your dashboard doesn't break. No error. No gap in the chart. No alert.

It's simply wrong by some percentage that nobody discovers until finance checks it against the invoice.

We've repeatedly seen Azure return the same resource, service and date combination more than once for Virtual Machine Scale Set members. Microsoft doesn't document this, which is part of the point: you find it by reconciling against an invoice, not by reading a spec.

Three obvious things to do with those duplicates, and only one is correct:

  • Take the first row. You drop half the spend.
  • Take the highest row. You overstate the spend.
  • Sum them. Correct.

We've seen repeated rows around retried, paginated Cost Explorer responses on AWS too. We've never proven whether AWS resent them or our own retry replayed a page, and for ingestion purposes it doesn't matter. Either way the write path has to be idempotent.

Now the harder version of the same question, and the one that costs you money in the opposite direction.

A single virtual machine can appear several times on the same day carrying different service names: compute on one row, the OS license on another, networking on a third. Same resource ID, same date, three genuine charges.

The dedup rule that fixed the scale set problem will destroy this one. Key on resource and date alone and those three rows collapse into one, and you've just deleted the license and the network charge from your bill.

So the service name has to be part of the identity, not a mutable field you overwrite. The key that survives both cases is resource plus service plus date: scale set members merge, multi-service resources stay separate. Getting that wrong in either direction produces a chart that looks completely normal.

Then there are rows that describe no resource at all.

Commitment purchases, support fees, management charges and security add-ons arrive with nothing to attribute them to. They're genuine spend, so you can't drop them, but you can't honestly assign them to a team either.

Separately, all three providers return rows that are aggregates rather than individual resources. Keep those alongside the underlying resource rows and you count the same spend twice.

So nothing gets written until it's been through a per-provider normalizer: validate, merge repeated cost rows according to that provider's semantics, discard the aggregate rows, and preserve the unattributable charges as their own rows rather than smearing them across teams who didn't incur them. They arrive with no resource identity, so they're stored with none, and the chargeback path bills them to an explicit unassigned line where somebody can argue about them on purpose.

Every sync logs what that pass did, broken out by reason: how many rows merged, how many dropped for an invalid date, how many dropped as aggregates, how many dropped for missing fields. A normalizer that silently changes behaviour is worse than not having one, and a single "cleaned 400 rows" line tells you nothing when the number moves.

They don't agree on what "cost" means

Buy a three year reservation on a Tuesday and your Tuesday looks catastrophic.

Both numbers are true. Unamortized shows you the cash leaving on the day. Amortized spreads the commitment across the term it actually covers.

They answer different questions.

The trouble starts when one cloud in your dashboard reports one and another reports the other. Now your month over month trend is comparing two accounting conventions and describing the gap as a change in behaviour.

CostOptix reads the amortized figure wherever a provider exposes one, on every path: service totals, resource detail, and tag breakdowns all use the same convention, so a commitment lands across the term it covers rather than on the day it was bought. Commitment purchases stop registering as anomalies. A savings plan bought in March stops making March look like a bad month.

Credits are the other half of this, and here the three clouds diverge more sharply than anywhere else.

AWS will net credits into your totals unless you ask for the split, so we run a parallel query grouped by record type and separate gross usage from credits, refunds, tax and discounts.

That gap matters more than it sounds. A startup running on a large promotional credit has a net spend near zero and a gross spend that becomes extremely real on the day the credit expires. You want to know that number months before you need it.

Azure handles credits somewhere else entirely. They attach to a billing profile and are applied when the invoice is generated. Microsoft explicitly separates that billing process from Cost Management, and says credits aren't included in Cost Analysis.

Depending on the agreement, that means your billing data can show credits reducing the invoice while the subscription-level cost data you're actually reading says nothing about them. Refunds carry a charge type we can still see, so that becomes the signal, and it is a strictly smaller one.

Google Cloud, for once, is the well behaved case. The billing export carries credits as explicit line items with their own type, so a sustained use discount, a committed use discount and a promotional credit are separable without a second query or an inference. It is the only one of the three where you can answer "which discount produced this" directly from the data you already have.

One question, three answers, and one of them is genuinely better than the other two. Any tool that reports a single "credits" figure across all three has flattened that difference and told you less than it knows.

They don't agree on what metadata is

Tagging is where the divergence gets almost funny.

Azure can group by tag key on its side, returning one row per value plus an untagged group covering everything without that tag, including resources that can't carry tags at all. That untagged number arrives alongside the tagged ones in the same response, and it's usually the most useful figure on the screen, because it tells you how much of your bill nobody owns.

AWS requires tags to be activated as Cost Allocation Tags in a billing console before they appear in Cost Explorer data. A meticulously tagged AWS estate can return absolutely nothing there. That isn't the same failure as a broken connection, and treating it as one sends someone hunting for a problem in entirely the wrong place. It needs its own reason code and its own message, pointing at the console toggle rather than at the credentials.

Google Cloud calls them labels, and surfaces them through the billing export rather than the cost API.

Kubernetes has none of the above. It has labels, namespaces, owner references, and a pod that outlived the deployment that created it.

Four systems, four vocabularies, one question underneath all of them.

Which team is this?

They don't agree on when the data is true

Google doesn't promise a fixed delivery time for billing exports. Its own documentation says there are no delivery or latency guarantees, and that services report usage at varying intervals.

Azure and AWS tend to run closer to current, and Azure's data generally settles within three to four days.

So "yesterday" is a different date boundary depending on which cloud you ask, and on Google Cloud it isn't a boundary you can hard-code.

Ignore this and two things break at once.

Your most recent day looks like Google Cloud spend collapsed to nothing.

And your anomaly detection agrees with you, because a service dropping from its normal daily cost to zero is precisely the shape of a real incident.

The fix isn't clever, it's just honest, and it happens in three places rather than one.

Today is flagged partial and excluded from every average, baseline and peak, because the current day is incomplete on every cloud and including it drags the average down for reasons that have nothing to do with spend.

Resource-level reads on Google Cloud refuse date ranges that fall inside the export lag window instead of returning a confidently empty answer. An error naming the lag is more useful than a chart with a hole in it.

And trailing zero days on an otherwise active account get trimmed rather than charted, because a zero on the most recent day is far more likely to be undelivered data than an idle estate.

Detection stops at the last settled day rather than running against today, and it runs against a rolling baseline per service rather than one account-wide number, so a lagging provider can't drown out a genuine spike somewhere else. Recent days stay open for reconciliation as late data lands, so an anomaly raised against half-delivered data doesn't harden into a permanent record that somebody has to explain a week later.

History depth diverges too.

Resource-level detail on AWS only reaches back two weeks. Azure will go further but has to be asked in chunks, because a single resource-level query over a wide date range is both slow and a good way to spend your entire rate budget in one call. Google Cloud depends on when the detailed export was enabled and how it was configured, since a multi-region dataset can backfill the current and previous month while a regional one gets nothing from before you switched it on.

That rate budget is its own constraint, and it shapes the product more than it should. Cost APIs are metered per subscription or per account, shared across every feature that reads them, and a background sync competing with a user clicking around a dashboard will throttle both. Some of the work is finding the API surface that isn't metered against the same pool. Azure Resource Graph, for instance, answers "which resources carry this tag value" without touching the Cost Management quota, which is the difference between a drill-down that always works and one that stops working on the days you most want it.

"Show me last quarter, by resource" is a different promise on each cloud. Pretending otherwise produces a chart with holes in it that nobody can explain.

And Kubernetes doesn't send a bill at all

This is the case that breaks the aggregation model completely.

A cluster has no invoice.

It has nodes, and those nodes appear on somebody else's bill as EC2 instances, or scale set members, or Compute Engine VMs. Everything inside the cluster, every namespace and workload and all the idle capacity you provisioned and didn't use, is an allocation problem wearing a billing problem's clothes.

So the reconciliation has to run backwards.

The agent reports each node's provider ID, which is the cloud instance identity Kubernetes already carries for its own purposes. Those arrive in different shapes depending on the cloud, and the same instances appear on the bill in yet other shapes: a bare instance ID, a full ARN, a resource path. Both sides get reduced to the same canonical key, so matching them is a lookup instead of a guess.

Daily membership is recorded as it happens, which matters more than it sounds. A node that autoscaled away on the 12th still counts for the days it actually ran. A tag snapshot can't do that, because tag data is rewritten on each sync and terminated nodes simply vanish from it, taking their cost with them.

From there, each team's line starts as an estimate at list price and gets scaled by what the cloud actually charged, so reservations, savings plans, spot and negotiated pricing are never modelled as a second layer of discounts on top of a number that already reflects them.

That single factor, and the invariant that keeps it honest, is a topic of its own: Kubernetes Chargeback That Sums to the Cloud Invoice →

The actual work

Remember the reply from the beginning?

"Great. So now we can see everything in one place."

Seeing everything in one place was never the hard part. The chart is the easy half.

Underneath it sits a normalizer that knows which cloud repeats which rows and why, and which rows only look like repeats. A schema that treats resource group, region and project as the same kind of fact. A tag layer that absorbs the difference between a console toggle and a billing export. A read path that knows which days are finished on which cloud. A reconciliation path that can point at one line on your cloud invoice and name the Kubernetes team that caused it.

None of that shows up in a screenshot.

All of it shows up the first time someone in finance checks your number against the invoice.

Multi-cloud cost visibility isn't an aggregation problem. It's a translation problem, and translation is where the accuracy is won or lost.


If you've ever been asked why the dashboard doesn't match the bill, this is the problem underneath that question.

CostOptix normalizes cost data across AWS, Azure, Google Cloud and Kubernetes into one consistent view, so a service total means the same thing no matter which cloud produced it. Pair it with Tag Explorer to give every dollar an owner, and Budgets to catch the increases before the invoice does.

See it running on live multi-cloud data at demo.costoptix.com, read-only and no signup required.

Start free, no credit card required.