Calculating Savings Plan Coverage and Utilization

The Savings Plans console renders exactly two numbers — an organization-wide coverage percentage and an organization-wide utilization percentage — for whatever date range you pick in the UI. Neither is per-account, neither is time-bucketed below a daily rollup, and neither is exportable as structured data. A FinOps team trying to right-size a commitment before a renewal needs both metrics broken out by linked account and by day, because coverage and utilization measure different failure modes and a single blended number hides which one is actually broken. This page builds the boto3 pipeline that pulls GetSavingsPlansCoverage and GetSavingsPlansUtilization directly, resolves an API asymmetry between them — coverage supports grouping by linked account in a single call, utilization does not — and computes weighted, not averaged, percentages across the resulting time buckets. It plugs into the account-level assignment already produced by Commitment Discount Optimization.

Coverage and utilization answer different questions, and confusing them is the single most common Savings Plans reporting error:

  • Coverage = SpendCoveredBySavingsPlans / (SpendCoveredBySavingsPlans + OnDemandCost). It answers “of my eligible compute spend, how much sits on a commitment?” Low coverage means you are still paying On-Demand rates for usage a Savings Plan could have discounted — the fix is buying more commitment.
  • Utilization = UsedCommitment / TotalCommitment. It answers “of the commitment I already bought, how much am I burning every hour?” Low utilization means you are paying for hourly commitment that goes unused — the fix is buying less, or reallocating the excess to another account.

A team that reads only the blended console number can end up doing both wrong at once: buying more commitment to fix a coverage gap while an unrelated utilization problem in a different account quietly eats the new purchase.

Root Cause & Failure Modes

The two AWS Cost Explorer endpoints that back these metrics are not symmetric, and that asymmetry is what forces a custom pipeline instead of a console export:

  • GetSavingsPlansCoverage supports GroupBy on LINKED_ACCOUNT. One paginated call, with Granularity set to DAILY, returns a coverage row per account per day. This is the easy half.
  • GetSavingsPlansUtilization accepts no GroupBy parameter at all. It only returns an aggregate figure per time bucket across the entire payer hierarchy. To get a per-account utilization figure, you must issue one filtered call per linked account, using Filter with a LINKED_ACCOUNT dimension expression, and multiply your request count by the number of accounts in scope. On a fifty-account organization queried daily over a 90-day window, that is fifty separate paginated call sequences instead of one.
  • Both endpoints share the general Cost Explorer throttle, roughly 5 transactions per second per caller identity, with paginated responses billed per request beyond the included allowance. The per-account utilization loop above is exactly the pattern that exhausts that budget fastest — it is bursty, sequential by default, and easy to write without backoff.
  • Percentages do not average. CoveragePercentage and UtilizationPercentage are already ratios; taking the arithmetic mean of daily percentages across a month, or across accounts, silently reweights every bucket to count equally regardless of dollar volume. An account with 50 USD of spend and 40% coverage should not move the blended figure as much as an account with 500,000 USD of spend and 92% coverage — but a naive mean() over the percentage column does exactly that. The correct aggregate sums the raw numerators and denominators first (covered_spend, on_demand_spend, used_commitment, total_commitment) and only divides once, at the end.
  • Time-bucket boundaries hide reallocation. A Savings Plan’s hourly commitment is fungible across accounts and instance families within its scope; a day where utilization looks fine in the organization aggregate can still contain hours where one account’s usage crowded out another’s, which only shows up once you keep the per-account, per-day granularity intact instead of collapsing it immediately.

Production Pipeline Architecture

The pipeline runs as four stages, each idempotent so a retry replaces a window rather than double-counting it:

  1. Coverage pull. Paginate GetSavingsPlansCoverage once, grouped by LINKED_ACCOUNT, at DAILY granularity, over the requested window. This single call sequence covers every account in scope.
  2. Utilization pull. For each linked account discovered in step 1, paginate GetSavingsPlansUtilization with a LINKED_ACCOUNT filter and the same DAILY granularity and window. Each account’s request sequence retries independently on throttling.
  3. Weighted aggregation. Sum the raw dollar and commitment-hour fields per account across the full window, then divide once to produce a single weighted coverage percentage and weighted utilization percentage per account, plus an organization-level total computed the same way.
  4. Structured emit. Serialize the per-account and organization rows to JSON, preserving both the weighted totals and the day-level detail so downstream tooling can re-slice without re-querying the API.

This selection layer sits downstream of the commitment-assignment logic in Commitment Discount Optimization, which decides which Savings Plan absorbs which usage-hour before these two metrics are even computable. It produces a materially different number than Reserved Instance Coverage vs Utilization Metrics, which reconciles the equivalent pair of endpoints for Reserved Instances — the two commitment types use separate Cost Explorer APIs and separate precedence rules, so a mixed compute estate needs both pipelines run side by side, never merged into one query. Once coverage and utilization are computed here, the amortized cost basis behind them is the same one spread across billing periods in Amortizing Reserved Instance Upfront Fees Monthly.

Weighted aggregation versus naive percentage averaging across three accounts Three account rows feed into two competing aggregation paths. Account A has fifty dollars of spend at forty percent coverage. Account B has five thousand dollars of spend at sixty percent coverage. Account C has five hundred thousand dollars of spend at ninety-two percent coverage. The naive path takes the arithmetic mean of the three percentages, forty plus sixty plus ninety-two divided by three, producing sixty-four percent, which is wrong because it ignores dollar volume. The weighted path sums covered spend and total spend across all three accounts first, then divides once, producing a result close to ninety-one point six percent, which matches reality because Account C dominates total spend. A banner states divide once, after summing the raw numerators and denominators, never average an already-computed percentage. Sum the raw numerators and denominators first — divide once. Never average an already-computed percentage. Account A $50 spend · 40% coverage Account B $5,000 spend · 60% coverage Account C $500,000 spend · 92% coverage mean(40, 60, 92) = 64% blended wrong — ignores that Account C is 99% of total dollar spend Σcovered / Σtotal = 91.6% weighted correct — reflects that the org's real spend is well covered
Averaging per-account coverage percentages directly ignores dollar volume; summing raw covered and total spend before dividing once weights each account correctly.

Step-by-Step Python Implementation

The module below paginates both endpoints, retries transient Cost Explorer throttling with tenacity, and computes weighted (not averaged) coverage and utilization per account and for the organization total. It emits a single JSON document via a custom encoder that serializes Decimal fields exactly, avoiding float drift in the underlying dollar and commitment-hour sums.

import argparse
import json
import logging
from dataclasses import dataclass
from decimal import Decimal
from typing import Dict, List

import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
from tenacity import (
    before_sleep_log, retry, retry_if_exception_type,
    stop_after_attempt, wait_exponential,
)

logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
logger = logging.getLogger("sp_coverage_utilization")

THROTTLE_CODES = {"ThrottlingException", "TooManyRequestsException"}


class ThrottledError(Exception):
    """Raised only for CE throttling so tenacity does not retry real errors."""


ce_retry = retry(
    reraise=True,
    stop=stop_after_attempt(6),
    wait=wait_exponential(multiplier=1, min=1, max=30),
    retry=retry_if_exception_type(ThrottledError),
    before_sleep=before_sleep_log(logger, logging.WARNING),
)


@dataclass
class AccountMetrics:
    account_id: str
    covered_spend: Decimal = Decimal("0")
    on_demand_spend: Decimal = Decimal("0")
    used_commitment: Decimal = Decimal("0")
    total_commitment: Decimal = Decimal("0")
    daily_buckets: int = 0

    @property
    def coverage_pct(self) -> Decimal:
        base = self.covered_spend + self.on_demand_spend
        return (self.covered_spend / base * 100) if base else Decimal("0")

    @property
    def utilization_pct(self) -> Decimal:
        return (self.used_commitment / self.total_commitment * 100) if self.total_commitment else Decimal("0")


class SavingsPlanMetricsClient:
    def __init__(self, region: str = "us-east-1"):
        self.ce = boto3.client("ce", region_name=region, config=Config(retries={"mode": "standard"}))

    @ce_retry
    def _call(self, method: str, **kwargs) -> Dict:
        try:
            return getattr(self.ce, method)(**kwargs)
        except ClientError as exc:
            code = exc.response.get("Error", {}).get("Code", "")
            if code in THROTTLE_CODES:
                raise ThrottledError(code) from exc
            raise

    def fetch_coverage_by_account(self, start: str, end: str) -> List[Dict]:
        """One paginated call: GetSavingsPlansCoverage grouped by LINKED_ACCOUNT."""
        rows, token = [], None
        while True:
            kwargs = dict(
                TimePeriod={"Start": start, "End": end},
                Granularity="DAILY",
                GroupBy=[{"Type": "DIMENSION", "Key": "LINKED_ACCOUNT"}],
            )
            if token:
                kwargs["NextToken"] = token
            resp = self._call("get_savings_plans_coverage", **kwargs)
            for block in resp.get("SavingsPlansCoverages", []):
                cov = block["Coverage"]
                rows.append({
                    "account_id": block["Attributes"]["LINKED_ACCOUNT"],
                    "covered_spend": Decimal(cov["SpendCoveredBySavingsPlans"]),
                    "on_demand_spend": Decimal(cov["OnDemandCost"]),
                })
            token = resp.get("NextToken")
            if not token:
                return rows

    def fetch_utilization_for_account(self, account_id: str, start: str, end: str) -> List[Dict]:
        """GetSavingsPlansUtilization has no GroupBy — filter per account, paginate."""
        rows, token = [], None
        while True:
            kwargs = dict(
                TimePeriod={"Start": start, "End": end},
                Granularity="DAILY",
                Filter={"Dimensions": {"Key": "LINKED_ACCOUNT", "Values": [account_id]}},
            )
            if token:
                kwargs["NextToken"] = token
            resp = self._call("get_savings_plans_utilization", **kwargs)
            for bucket in resp.get("SavingsPlansUtilizationsByTime", []):
                util = bucket["Utilization"]
                rows.append({
                    "used_commitment": Decimal(util["UsedCommitment"]),
                    "total_commitment": Decimal(util["TotalCommitment"]),
                })
            token = resp.get("NextToken")
            if not token:
                return rows


def build_report(client: SavingsPlanMetricsClient, start: str, end: str) -> Dict[str, AccountMetrics]:
    """Fetch both feeds and sum raw dollars/hours per account before dividing once."""
    accounts: Dict[str, AccountMetrics] = {}
    for row in client.fetch_coverage_by_account(start, end):
        m = accounts.setdefault(row["account_id"], AccountMetrics(account_id=row["account_id"]))
        m.covered_spend += row["covered_spend"]
        m.on_demand_spend += row["on_demand_spend"]
        m.daily_buckets += 1

    for account_id in list(accounts):
        util_rows = client.fetch_utilization_for_account(account_id, start, end)
        m = accounts[account_id]
        for r in util_rows:
            m.used_commitment += r["used_commitment"]
            m.total_commitment += r["total_commitment"]

    org = AccountMetrics(account_id="ORG_TOTAL")
    for m in accounts.values():
        org.covered_spend += m.covered_spend
        org.on_demand_spend += m.on_demand_spend
        org.used_commitment += m.used_commitment
        org.total_commitment += m.total_commitment
    accounts["ORG_TOTAL"] = org
    return accounts


def to_json(accounts: Dict[str, AccountMetrics]) -> str:
    payload = {
        aid: {
            "coverage_pct": str(m.coverage_pct.quantize(Decimal("0.01"))),
            "utilization_pct": str(m.utilization_pct.quantize(Decimal("0.01"))),
            "covered_spend": str(m.covered_spend),
            "on_demand_spend": str(m.on_demand_spend),
            "used_commitment": str(m.used_commitment),
            "total_commitment": str(m.total_commitment),
        }
        for aid, m in accounts.items()
    }
    return json.dumps(payload, indent=2, sort_keys=True)


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--start", required=True, help="YYYY-MM-DD")
    parser.add_argument("--end", required=True, help="YYYY-MM-DD")
    args = parser.parse_args()

    client = SavingsPlanMetricsClient()
    logger.info("Pulling Savings Plans coverage/utilization for %s -> %s", args.start, args.end)
    report = build_report(client, args.start, args.end)
    print(to_json(report))

Verification & Testing

  • Weighted-vs-naive divergence check. Alongside coverage_pct, compute mean(daily CoveragePercentage) for the same account and assert the two differ by more than a rounding tolerance whenever daily spend is uneven — if they always match, the naive average is coincidentally safe only because spend is nearly flat, not because it is correct in general.
  • Reconciliation against the console total. Sum covered_spend and on_demand_spend across every account and recompute organization coverage; it must match the console’s blended percentage for the same window within 0.1 percentage points. A larger gap means an account is missing from the GroupBy result, usually because it had zero eligible usage and AWS omitted the row entirely.
  • Utilization call-count assertion. Log the number of get_savings_plans_utilization calls issued and assert it equals the number of distinct accounts returned by the coverage pull — a mismatch means the per-account loop skipped or duplicated an account.
  • Idempotent re-run. Run build_report twice against the same finalized window and diff the JSON output byte-for-byte; because both endpoints report finalized historical data deterministically, the two runs must be identical.

Common Pitfalls Checklist

  • Grouping utilization by account like coverage. GetSavingsPlansUtilization silently ignores an unsupported GroupBy in some SDK versions rather than erroring — verify by checking that a filtered, per-account call actually returns a different TotalCommitment than the unfiltered organization call.
  • Averaging CoveragePercentage across days or accounts. Sum covered_spend and on_demand_spend first, divide once — see the weighted-vs-naive check above.
  • Treating a missing account row as zero coverage. AWS omits accounts with no eligible usage from the coverage response entirely; a missing row means “not applicable,” not “0%” — do not default it into your weighted sum as a zero-spend account with 0% coverage, since that is already the mathematically correct no-op.
  • Ignoring the coverage/utilization API asymmetry. Writing one shared pagination helper for both endpoints and assuming both accept GroupBy breaks silently — keep the per-account filter loop explicit for utilization, as the code above does.
  • Not backing off on the per-account utilization loop. Fifty sequential unthrottled calls against a ~5 TPS ceiling triggers cascading ThrottlingException errors — the tenacity decorator above must wrap every individual call, not just the outer loop.

Frequently Asked Questions

Why can’t I get per-account utilization in one API call like coverage?

GetSavingsPlansCoverage accepts a GroupBy parameter that includes LINKED_ACCOUNT, so one paginated call returns every account’s coverage. GetSavingsPlansUtilization accepts no GroupBy at all — it only returns an aggregate across the entire payer hierarchy for each time bucket. Per-account utilization requires one filtered call per account using a LINKED_ACCOUNT dimension expression in Filter, which is why the pipeline above loops rather than groups.

Is it ever correct to average coverage or utilization percentages?

Only when every bucket being averaged has identical underlying spend or commitment volume, which is rare in practice. The safe approach is always to sum the raw numerator (covered_spend or used_commitment) and denominator (covered_spend + on_demand_spend or total_commitment) across every bucket first, then divide once. Averaging the percentages directly overweights small accounts and underweights large ones.

What does it mean if coverage is high but utilization is low?

You have enough commitment to cover most eligible spend, but a meaningful share of that commitment goes unused — you likely overbought relative to actual usage, or usage shifted to a family or region the commitment cannot flex into. This is the opposite failure mode from low coverage with high utilization, which means you are fully burning a commitment that is too small for your eligible spend.

How far back can I query these two endpoints?

Cost Explorer generally retains Savings Plans coverage and utilization data for a rolling window measured in months, and figures for the trailing 24–72 hours are provisional and can shift as billing finalizes. Pin a DAILY granularity, re-query the trailing few days on every run, and treat only older partitions as settled before using them for a renewal decision.

Up: Commitment Discount Optimization · Home: Cloud Cost Optimization & FinOps Automation