Estimating Infrastructure Cost in Python IaC Pipelines

A diff that adds a db.r6g.4xlarge read replica looks, on a review screen, exactly like a diff that adds an S3 bucket: three lines of Python and a green plus sign. The difference shows up six weeks later on an invoice nobody reads line by line. This guide, part of IaC cost and governance within Python IaC fundamentals and strategy, builds a typed Python estimator that reads the machine-readable plan a tool emits before the apply, prices the resources it is about to change, and prints a per-change delta the reviewer can act on.

From plan document to cost delta From plan document to cost delta: export plan then normalise steps then attach rate then emit delta then post to review export plan preview --json normalise steps typed dataclass attach rate price book emit delta per change post to review artifact
The estimator is a pure function over two files: the plan document and the price book.

Context

Both Pulumi and CDKTF will hand you the planned change set as structured data without touching a single mutating cloud API. That artifact is the whole input to an estimator. The estimator itself is a pure function — plan document plus price data in, annotated deltas out — which means it runs in a few hundred milliseconds, needs no write credentials, and produces the same answer twice for the same plan. Those three properties are what make it usable on every pull request rather than as a quarterly finance exercise.

Scope matters more than precision here. A plan document describes declared capacity: how many instances, of what size, with how much provisioned storage, in which region. It says nothing about how many requests those instances will serve or how many terabytes will cross a NAT gateway. So the honest product is a fixed-cost delta — the part of the bill that changes the moment the apply succeeds, independent of traffic. Presenting that number as "the cost of this change" is the single most common way these tools lose credibility with the team using them.

The output should also be a delta, not a total. Nobody reviewing a pull request can do anything useful with "this stack costs $41,882/month". They can act on "this change adds $612/month, of which $540 is one RDS instance class bump".

Prerequisites

  • Python 3.9+ with pulumi>=3.0 (or cdktf with Terraform 1.5+) available on the runner
  • Read access to the stack or workspace — pulumi stack select must succeed, or the Terraform backend must be initialised
  • A decision on where prices come from, covered in step three below
  • Familiarity with typed resource definitions, as described in Python typing for cloud resource definitions
# CLI: confirm the toolchain can produce a plan document at all
pulumi version
pulumi stack select prod --non-interactive
python3 -c "import json, dataclasses; print('parser deps ok')"

Implementation

Step 1 — Export the plan as a document

Pulumi's preview --json emits a preview digest: a single JSON object containing every step the engine intends to take. Redirect it to a file rather than piping it, so the estimator and any later gate read the identical bytes.

# CLI: write the preview digest to disk for downstream tooling
pulumi preview --stack prod --json --non-interactive > preview.json
# State implication: preview never mutates state, but it DOES refresh provider
# metadata in memory, so run it against the same stack the apply will use.

The digest's shape is stable enough to parse directly. Each entry in steps carries the operation, the resource URN, and the before/after resource states:

{
  "steps": [
    {
      "op": "update",
      "urn": "urn:pulumi:prod::billing::aws:rds/instance:Instance::orders-primary",
      "oldState": {
        "type": "aws:rds/instance:Instance",
        "inputs": {"instanceClass": "db.r6g.large", "allocatedStorage": 200, "multiAz": false}
      },
      "newState": {
        "type": "aws:rds/instance:Instance",
        "inputs": {"instanceClass": "db.r6g.4xlarge", "allocatedStorage": 200, "multiAz": true}
      }
    }
  ],
  "changeSummary": {"same": 41, "update": 1}
}

For CDKTF the equivalent is two commands, because cdktf diff prints for humans. Synthesise, then ask Terraform for the plan in JSON.

# CLI: produce the same information from a CDKTF project
cdktf synth
cd cdktf.out/stacks/billing && terraform init -input=false
terraform plan -out=tfplan -input=false && terraform show -json tfplan > ../../../plan.json
# Provider note: `terraform show -json` requires the providers to be installed,
# so `init` has to run first even though nothing is being applied.

The Terraform document exposes resource_changes[], each with address, type, and a change object holding actions, before, and after. The two formats differ in spelling but not in content, which is why the estimator below normalises both into one dataclass before pricing anything.

Step 2 — Normalise the graph into priced dimensions

A resource type on its own is not enough to price. aws:ec2/instance:Instance needs the instance type; aws:rds/instance:Instance needs the instance class, the provisioned storage, and whether Multi-AZ is on; a NAT gateway is priced purely per hour and needs nothing at all. So the parser's job is to walk the steps and pull out a small, typed set of dimensions per resource.

Priced dimensions by resource type Priced dimensions by resource type: comparison across Dimension read, Rate basis. Resource type Dimension read Rate basis EC2 instance instanceType instance-hour RDS instance instanceClass, multiAz instance-hour x AZ EBS volume size, type gb-month NAT gateway none gateway-hour S3 bucket none usage only
A resource type alone cannot be priced; each one needs a small, explicit set of sizing inputs.
# plan_model.py — one normalised change record for either toolchain
# CLI: python3 -m plan_model preview.json
from __future__ import annotations

import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional

# Pulumi substitutes this sentinel for any value it cannot know until apply time.
UNKNOWN = "04da6b54-80e4-46f7-96ec-b56ff0331ba9"


@dataclass(frozen=True)
class ResourceShape:
    """The priced dimensions of one resource, before or after the change."""
    resource_type: str
    dimensions: Dict[str, Any] = field(default_factory=dict)


@dataclass(frozen=True)
class PlannedChange:
    address: str                      # URN or Terraform address
    op: str                           # create | update | replace | delete | same
    before: Optional[ResourceShape]
    after: Optional[ResourceShape]
    unknowns: List[str] = field(default_factory=list)


def _shape(state: Optional[Dict[str, Any]]) -> Optional[ResourceShape]:
    if not state:
        return None
    inputs: Dict[str, Any] = state.get("inputs", {}) or {}
    return ResourceShape(resource_type=state.get("type", ""), dimensions=inputs)


def load_pulumi_preview(path: Path) -> List[PlannedChange]:
    digest: Dict[str, Any] = json.loads(path.read_text())
    changes: List[PlannedChange] = []
    for step in digest.get("steps", []):
        op = step.get("op", "same")
        if op in ("same", "read", "refresh"):
            continue
        after = _shape(step.get("newState"))
        unknowns = [k for k, v in (after.dimensions if after else {}).items() if v == UNKNOWN]
        changes.append(PlannedChange(
            address=step.get("urn", ""),
            op=op,
            before=_shape(step.get("oldState")),
            after=after,
            unknowns=unknowns,
        ))
    return changes
# State implication: nothing here reads or writes the state file; the digest is
# a snapshot the engine already produced.

The unknowns list is not decoration. In a preview, any value derived from a resource that does not exist yet comes back as that sentinel string — a subnet ID, a generated name, sometimes a whole computed block. Terraform expresses the same idea in an after_unknown map. An estimator that treats an unknown as zero will confidently under-report; one that treats it as an error will fail on almost every greenfield stack. Record it, price what you can, and report the gap.

Step 3 — Attach prices, and decide where they live

There are two honest answers to "where do the prices come from", and they trade off along the same axis: freshness against determinism.

Choosing a price source Choosing a price source: choose among 3 options. Where do the rates comefrom? offline Checked-in table:diffable,deterministic, goes live Pricing API: alwayscurrent, needscredentials, both Table in repo,scheduled job opensa refresh pull
Determinism in the pipeline, freshness in a scheduled job that proposes rate changes.

A checked-in price table is a YAML or JSON file in the repository mapping a resource type plus its dimensions to a rate. It is reviewable, diffable, and version-controlled, so a price change shows up as a pull request rather than as a mysterious swing in yesterday's estimate. It is also wrong the moment a provider changes list prices or you sign an enterprise discount, and somebody has to own refreshing it.

A provider pricing API — for AWS, the Price List Query API via boto3.client("pricing") — is always current and covers thousands of SKUs you would never hand-enter. It also needs network access and credentials in the estimator's execution path, only answers from us-east-1 or ap-south-1, returns list price rather than your negotiated rate, and makes the estimate non-deterministic: the same plan can produce two different numbers on two different days, which is confusing in a code review.

The pragmatic build is a checked-in table as the source of truth, with a separate, scheduled job that queries the API and opens a pull request when a rate has drifted. The estimator itself stays offline and deterministic.

# pricing.py — offline price book with an explicit, auditable provenance
# CLI: python3 -m pricing --validate prices.yaml
from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal
from typing import Dict, Optional

HOURS_PER_MONTH = Decimal("730")


@dataclass(frozen=True)
class Rate:
    """One priced dimension. `unit` is what `quantity` is multiplied by."""
    unit: str                 # "instance-hour" | "gb-month" | "gateway-hour"
    usd: Decimal
    source: str               # e.g. "aws-price-list 2026-07-14"


@dataclass(frozen=True)
class PriceBook:
    region: str
    rates: Dict[str, Rate]    # key: "<resource_type>:<dimension_value>"

    def lookup(self, resource_type: str, key: str) -> Optional[Rate]:
        return self.rates.get(f"{resource_type}:{key}")


def monthly_usd(rate: Rate, quantity: Decimal) -> Decimal:
    if rate.unit.endswith("-hour"):
        return (rate.usd * HOURS_PER_MONTH * quantity).quantize(Decimal("0.01"))
    return (rate.usd * quantity).quantize(Decimal("0.01"))
# Provider note: rates are region-scoped. A PriceBook built for us-east-1 will
# silently under-price a eu-central-1 stack by 10-20% if you forget to swap it.

Use Decimal, not float. Money arithmetic that drifts by a cent per resource across four hundred resources produces a delta that does not reconcile with anything, and the first person to notice will stop trusting the tool.

Step 4 — Turn shapes into a per-change delta

The delta rule follows the operation. A create contributes its full monthly cost; a delete contributes the negative of it; an update contributes after-minus-before; a replace is priced as an update, because the resource ends up existing either way. The trap is Pulumi's replacement decomposition — the engine may emit create-replacement and delete-replaced as separate steps for one logical replacement, and naively summing them double-counts.

# estimate.py — the estimator entry point
# CLI: python3 estimate.py --plan preview.json --prices prices.yaml --region eu-central-1
from __future__ import annotations

import argparse
from dataclasses import dataclass
from decimal import Decimal
from pathlib import Path
from typing import Callable, Dict, List, Optional

from plan_model import PlannedChange, ResourceShape, load_pulumi_preview
from pricing import PriceBook, monthly_usd

Sizer = Callable[[ResourceShape, PriceBook], Optional[Decimal]]


def size_ec2(shape: ResourceShape, book: PriceBook) -> Optional[Decimal]:
    rate = book.lookup(shape.resource_type, str(shape.dimensions.get("instanceType", "")))
    return monthly_usd(rate, Decimal("1")) if rate else None


def size_rds(shape: ResourceShape, book: PriceBook) -> Optional[Decimal]:
    cls = str(shape.dimensions.get("instanceClass", ""))
    rate = book.lookup(shape.resource_type, cls)
    if rate is None:
        return None
    multiplier = Decimal("2") if shape.dimensions.get("multiAz") else Decimal("1")
    compute = monthly_usd(rate, multiplier)
    storage_rate = book.lookup(shape.resource_type, "gp3-storage")
    gb = Decimal(str(shape.dimensions.get("allocatedStorage", 0) or 0))
    storage = monthly_usd(storage_rate, gb * multiplier) if storage_rate else Decimal("0")
    return compute + storage


SIZERS: Dict[str, Sizer] = {
    "aws:ec2/instance:Instance": size_ec2,
    "aws:rds/instance:Instance": size_rds,
}

# Replacement is emitted as two steps; count the creating half only.
IGNORED_OPS = {"delete-replaced", "discard", "same"}


@dataclass
class LineItem:
    address: str
    op: str
    delta_usd: Decimal
    priced: bool
    note: str = ""


def price_shape(shape: Optional[ResourceShape], book: PriceBook) -> Optional[Decimal]:
    if shape is None:
        return Decimal("0")
    sizer = SIZERS.get(shape.resource_type)
    return sizer(shape, book) if sizer else None


def estimate(changes: List[PlannedChange], book: PriceBook) -> List[LineItem]:
    items: List[LineItem] = []
    for change in changes:
        if change.op in IGNORED_OPS:
            continue
        before = price_shape(change.before, book) if change.op != "create" else Decimal("0")
        after = price_shape(change.after, book) if change.op != "delete" else Decimal("0")
        if before is None or after is None:
            items.append(LineItem(change.address, change.op, Decimal("0"), False,
                                  "no price rule for this resource type"))
            continue
        note = f"{len(change.unknowns)} unknown input(s)" if change.unknowns else ""
        items.append(LineItem(change.address, change.op, after - before, True, note))
    return items


def main() -> int:
    parser = argparse.ArgumentParser(description="Estimate the monthly cost delta of a plan.")
    parser.add_argument("--plan", type=Path, required=True)
    parser.add_argument("--prices", type=Path, required=True)
    parser.add_argument("--region", default="us-east-1")
    args = parser.parse_args()

    book = PriceBook(region=args.region, rates=load_rates(args.prices))
    items = estimate(load_pulumi_preview(args.plan), book)
    total = sum((i.delta_usd for i in items), Decimal("0"))
    unpriced = [i for i in items if not i.priced]

    for item in sorted(items, key=lambda i: i.delta_usd, reverse=True):
        flag = "" if item.priced else "  [unpriced]"
        print(f"{item.delta_usd:>+12} USD/mo  {item.op:<8} {item.address}{flag} {item.note}")
    print(f"\nmonthly delta: {total:+} USD   unpriced resources: {len(unpriced)}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

load_rates is left to the reader's config layer — a twenty-line YAML loader building Rate objects. What matters is that unpriced resources are counted and shown, not silently dropped. A delta of +0.00 USD/mo with sixty unpriced resources is a very different message from a real zero.

Verification

Run the estimator against a plan whose answer you already know: take a live stack, bump one instance class in a branch, and confirm the delta matches the difference between the two published rates.

Verifying an estimate end to end Verifying an estimate end to end: Pipeline → Pulumi CLI → Estimator. Pipeline Pulumi CLI Estimator preview --json digest file plan + prices delta + unpriced count
A known single-resource change is the cheapest way to prove the arithmetic.
# CLI: end-to-end check on a single-resource change
pulumi preview --stack prod --json --non-interactive > preview.json
python3 estimate.py --plan preview.json --prices prices.yaml --region eu-central-1
# Expect one line: the RDS URN with a positive delta, and unpriced resources: 0

Three assertions make the check meaningful. First, a no-op plan must produce exactly 0.00 with zero line items — if same steps leak through, the arithmetic is wrong. Second, deleting a priced resource must produce a negative number of the same magnitude that creating it produced. Third, the unpriced count on your main stack should be small and known; if it climbs after a refactor, somebody added a resource type nobody wrote a sizer for.

Gotchas & Edge Cases

Outside the reach of a static estimate Outside the reach of a static estimate: Declared capacity only with 4 facets. Declared capacity only Request volume invocations and queries are unknown Data transfer egress depends on traffic shape Autoscaling declared min, not observed average Spot pricing market rate moves hourly
The plan describes capacity you declare, never the usage that drives the rest of the bill.

Unknown values are the normal case, not the exception. On a brand-new stack most newState.inputs are sentinels because nothing has been created yet. Report the count prominently; an estimate over a plan that is 60% unknown is a rough signal, not a number.

Replacements double-count. Pulumi may emit create-replacement plus delete-replaced; Terraform expresses the same thing as "actions": ["delete", "create"] on one entry. Handle both explicitly or a routine AMI rotation will report a large phantom increase.

Region is part of the price key. Estimating a eu-central-1 stack against us-east-1 rates is the most common silent error, and it always under-reports. Make region a required argument, never a default that quietly applies.

Committed spend and discounts are invisible. Reserved instances, savings plans, and enterprise agreements mean list price is an upper bound. If the organisation has a blended discount, apply it as an explicit, documented multiplier rather than editing individual rates.

Some resources have no fixed cost at all. A Lambda function, an SQS queue, or an S3 bucket costs nothing until it is used. Marking them priced=True with a delta of zero is correct and honest; marking them unpriced adds noise.

Operational Notes

Store the estimate as a build artifact alongside the plan document, keyed by commit. When someone asks in three months why the bill moved, the plan plus the estimate plus the price table at that commit reconstruct the whole reasoning chain — which is exactly the audit trail that idempotency and drift detection work depends on.

Refresh the price table on a schedule, not on demand. A monthly job that queries the pricing API, diffs against the checked-in table, and opens a pull request keeps the data honest without making the estimator itself depend on the network. When that job fails — An error occurred (AccessDeniedException) when calling the GetProducts operation is the usual one, since pricing:GetProducts is easy to forget in a CI role — the estimator keeps working on the last known-good table.

Keep the sizer registry small and deliberate. Ten resource types typically account for well over ninety percent of a cloud bill: compute instances, managed databases, load balancers, NAT gateways, provisioned block storage, managed Kubernetes control planes, and a handful of caches. Pricing those ten well beats pricing two hundred badly.

Finally, treat the estimator as code and test it as code. A small fixture directory of saved plan documents, each with an expected delta, catches regressions the moment somebody edits a sizer — the same pytest discipline covered in testing Python IaC.

FAQ

Can I estimate cost without cloud credentials?

Partly. Producing the plan document requires read access to the stack and its state backend, so those credentials are needed. The estimator itself — parsing, pricing, arithmetic — needs nothing beyond the two files, which is why it is worth keeping the two stages separate.

How accurate should I expect the number to be?

For the fixed, capacity-shaped portion of a bill, within a few percent if the price table is current and the region is right. For anything usage-driven it is not an estimate at all; the tool should say so rather than guess.

Does pulumi preview --json cost anything or change state?

No. It runs the program, talks to providers for read-only diffing, and writes nothing. It does count against provider API rate limits on large stacks, which is worth knowing if the pipeline previews several stacks in parallel.

What about Azure and GCP?

The same structure applies — only the sizer functions and the price keys change. azure-native:compute:VirtualMachine needs hardwareProfile.vmSize; gcp:compute/instance:Instance needs machineType. Both providers publish list prices you can transcribe into the same table format.

Should the estimate include tax and support fees?

No. Keep the estimator on infrastructure list price and let finance apply the organisation-wide uplift once. Folding a support percentage into individual rates makes the table impossible to reconcile against a published price list later.