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:
GetSavingsPlansCoveragesupportsGroupByonLINKED_ACCOUNT. One paginated call, withGranularityset toDAILY, returns a coverage row per account per day. This is the easy half.GetSavingsPlansUtilizationaccepts noGroupByparameter 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, usingFilterwith aLINKED_ACCOUNTdimension 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.
CoveragePercentageandUtilizationPercentageare 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 naivemean()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:
- Coverage pull. Paginate
GetSavingsPlansCoverageonce, grouped byLINKED_ACCOUNT, atDAILYgranularity, over the requested window. This single call sequence covers every account in scope. - Utilization pull. For each linked account discovered in step 1, paginate
GetSavingsPlansUtilizationwith aLINKED_ACCOUNTfilter and the sameDAILYgranularity and window. Each account’s request sequence retries independently on throttling. - 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.
- 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.
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, computemean(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_spendandon_demand_spendacross 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 theGroupByresult, usually because it had zero eligible usage and AWS omitted the row entirely. - Utilization call-count assertion. Log the number of
get_savings_plans_utilizationcalls 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_reporttwice 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.
GetSavingsPlansUtilizationsilently ignores an unsupportedGroupByin some SDK versions rather than erroring — verify by checking that a filtered, per-account call actually returns a differentTotalCommitmentthan the unfiltered organization call. - Averaging
CoveragePercentageacross days or accounts. Sumcovered_spendandon_demand_spendfirst, 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
GroupBybreaks 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
ThrottlingExceptionerrors — thetenacitydecorator 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.
Related
- Commitment Discount Optimization — the parent decision layer this coverage/utilization pipeline reports into.
- Reserved Instance Coverage vs Utilization Metrics — the equivalent reconciliation for Reserved Instances, run alongside this pipeline on a mixed compute estate rather than merged with it.
- Amortizing Reserved Instance Upfront Fees Monthly — how the amortized cost basis behind these commitment metrics is spread across billing periods.
- Reserved Instance Mapping Logic — the upstream engine that assigns usage-hours to commitments before coverage and utilization become computable.
- Handling Billing API Rate Limits & Retries — the backoff patterns behind the
tenacityretry decorator used throughout this module.
Up: Commitment Discount Optimization · Home: Cloud Cost Optimization & FinOps Automation