Tracking Savings Plan Coverage and Utilization

A Savings Plan bought last quarter can quietly turn into waste the moment a workload scales down, and this page builds the Python monitor that reads coverage and utilization from Cost Explorer and fires an alert the day either one drifts out of band.

Back to: Reserved Capacity & Commitment Optimization

Two numbers tell you whether a commitment is healthy, and they measure opposite risks. Coverage is the fraction of eligible on-demand-equivalent spend that a Savings Plan or Reserved Instance actually absorbed — low coverage means eligible usage is leaking onto full-price on-demand and you are under-committed. Utilization is the fraction of committed dollars that were actually consumed — low utilization means you are paying for a commitment no workload is matching against, which is pure, often unrecoverable, waste. A commitment can fail on either axis independently: a fleet that grew leaves you under-covered, a fleet that shrank leaves you under-utilized. This page reads both straight from Cost Explorer, computes the percentages, and routes an alert when either breaches its target. It is the operational counterpart to deciding whether to buy in the first place — the pre-purchase break-even math lives in calculating RDS Reserved Instance break-even, while this page assumes the commitment already exists and asks whether it is still earning its keep.

The two metrics are simple ratios, but they read against different denominators. Utilization divides used commitment by total commitment; coverage divides covered spend by all eligible spend:

utilization=used_commitmenttotal_commitment,coverage=covered_spendcovered_spend+on_demand_spend\text{utilization} = \frac{\text{used\_commitment}}{\text{total\_commitment}}, \qquad \text{coverage} = \frac{\text{covered\_spend}}{\text{covered\_spend} + \text{on\_demand\_spend}}

A healthy commitment holds utilization near 100% (you are consuming what you bought) and coverage at your target band (you committed to the right share of the stable base). The two are not redundant: you can be 100% utilized yet poorly covered if you deliberately under-committed, and you can be well covered yet under-utilized right after a workload shrinks. Both are read separately because both failure modes need different responses.

Prerequisites

  • IAM permissions: the monitor needs read-only access to the Cost Explorer Savings Plans and reservation coverage/utilization actions. Keep the policy scoped to exactly these read actions — the reasoning behind tight credentials for cost tooling is in security and access control for cost data.

    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Sid": "SavingsPlanCoverageReadOnly",
          "Effect": "Allow",
          "Action": [
            "ce:GetSavingsPlansCoverage",
            "ce:GetSavingsPlansUtilization",
            "ce:GetReservationCoverage"
          ],
          "Resource": "*"
        }
      ]
    }
    
  • Python: 3.10 or newer.

  • Libraries: the AWS SDK plus requests for webhook delivery and tenacity for backoff.

    pip install "boto3>=1.34" "requests>=2.31" "tenacity>=8.2"
    
  • Environment: export SLACK_WEBHOOK_URL (or your alerting endpoint) so no secret is hard-coded.

Step-by-Step Implementation

The monitor pulls utilization, pulls coverage, evaluates both against targets, and emits an alert only when a threshold breaks. Each Cost Explorer call is wrapped in backoff because these endpoints throttle under a multi-account sweep.

Step 1 — Read Savings Plan utilization

get_savings_plans_utilization returns the used, unused, and total committed dollars over a period. Utilization percentage is used / total; the unused figure is the dollar waste you are looking for.

import os
import boto3
from decimal import Decimal
from tenacity import retry, wait_exponential, stop_after_attempt

ce = boto3.client("ce", region_name=os.environ.get("AWS_REGION", "us-east-1"))


@retry(wait=wait_exponential(min=2, max=30), stop=stop_after_attempt(5), reraise=True)
def savings_plan_utilization(start: str, end: str) -> dict:
    """Return utilization % and unused-commitment dollars for the period."""
    resp = ce.get_savings_plans_utilization(
        TimePeriod={"Start": start, "End": end},   # dates are YYYY-MM-DD, End exclusive
        Granularity="MONTHLY",
    )
    agg = resp["Total"]["Utilization"]
    used = Decimal(agg["UsedCommitment"])
    total = Decimal(agg["TotalCommitment"])
    unused = Decimal(agg["UnusedCommitment"])
    pct = (used / total) if total else Decimal("0")
    return {
        "utilization_pct": round(pct, 4),
        "unused_commitment": round(unused, 2),
        "total_commitment": round(total, 2),
    }


if __name__ == "__main__":
    print(savings_plan_utilization("2026-06-01", "2026-07-01"))

Expected output for a lightly over-committed plan:

{'utilization_pct': Decimal('0.9420'), 'unused_commitment': Decimal('148.30'), 'total_commitment': Decimal('2557.00')}

Step 2 — Read Savings Plan coverage

get_savings_plans_coverage returns, per period, the spend a plan covered and the on-demand-equivalent spend it did not. Coverage percentage comes straight from the CoveragePercentage field, but recomputing it from the dollar components guards against a provider-side rounding surprise.

@retry(wait=wait_exponential(min=2, max=30), stop=stop_after_attempt(5), reraise=True)
def savings_plan_coverage(start: str, end: str) -> dict:
    """Return coverage % and the on-demand dollars leaking outside the plan."""
    resp = ce.get_savings_plans_coverage(
        TimePeriod={"Start": start, "End": end},
        Granularity="MONTHLY",
    )
    period = resp["SavingsPlansCoverages"][0]["Coverage"]
    covered = Decimal(period["SpendCoveredBySavingsPlans"])
    on_demand = Decimal(period["OnDemandCost"])
    total = covered + on_demand
    pct = (covered / total) if total else Decimal("0")
    return {
        "coverage_pct": round(pct, 4),
        "on_demand_leak": round(on_demand, 2),
        "eligible_spend": round(total, 2),
    }


if __name__ == "__main__":
    print(savings_plan_coverage("2026-06-01", "2026-07-01"))

Expected output for an under-covered account:

{'coverage_pct': Decimal('0.7350'), 'on_demand_leak': Decimal('812.44'), 'eligible_spend': Decimal('3065.80')}

Step 3 — Evaluate thresholds and route an alert

Fold both metrics into one health check. Utilization below its floor is the more urgent signal — that money is already spent — so it takes priority. The alert payload carries the dollar impact, not just the percentage, so an on-call engineer can triage by cost. Delivery is intentionally thin here; the routing, deduplication, and escalation logic belongs to the dedicated alert routing and escalation layer, which this monitor hands off to.

The evaluate-then-route flow is shown below.

Savings Plan health monitor: read coverage and utilization, evaluate against thresholds, then alert on breach or record when healthyCost Explorer supplies utilization and coverage figures to an evaluation step, which tests whether utilization is below its floor or coverage is below its target. A breach builds a dollar-weighted alert handed to the alert routing layer; otherwise the monitor records the healthy figures and exits.breachhealthyCost Explorercoverage + utilComputepercentagesBelowthreshold?RoutealertRecord& exit
import json
import requests

COVERAGE_TARGET = Decimal("0.80")     # want >=80% of eligible spend covered
UTILIZATION_FLOOR = Decimal("0.90")   # tolerate <=10% committed waste


def evaluate_commitment(start: str, end: str) -> dict | None:
    """Return an alert payload if a threshold is breached, else None."""
    util = savings_plan_utilization(start, end)
    cov = savings_plan_coverage(start, end)

    breaches = []
    if util["utilization_pct"] < UTILIZATION_FLOOR:
        breaches.append(
            f"utilization {util['utilization_pct']:.1%} < {UTILIZATION_FLOOR:.0%} "
            f"(${util['unused_commitment']} committed but unused)"
        )
    if cov["coverage_pct"] < COVERAGE_TARGET:
        breaches.append(
            f"coverage {cov['coverage_pct']:.1%} < {COVERAGE_TARGET:.0%} "
            f"(${cov['on_demand_leak']} leaking to on-demand)"
        )
    if not breaches:
        return None

    return {
        "period": f"{start}..{end}",
        "severity": "warning" if util["utilization_pct"] >= UTILIZATION_FLOOR else "critical",
        "breaches": breaches,
        "unused_commitment": str(util["unused_commitment"]),
        "on_demand_leak": str(cov["on_demand_leak"]),
    }


def send_alert(payload: dict) -> None:
    """Hand the alert to the delivery webhook; routing/dedup lives downstream."""
    webhook = os.environ["SLACK_WEBHOOK_URL"]
    text = f":money_with_wings: Savings Plan drift for {payload['period']}\n" + \
           "\n".join(f"- {b}" for b in payload["breaches"])
    resp = requests.post(webhook, data=json.dumps({"text": text}),
                         headers={"Content-Type": "application/json"}, timeout=10)
    resp.raise_for_status()


if __name__ == "__main__":
    alert = evaluate_commitment("2026-06-01", "2026-07-01")
    if alert:
        send_alert(alert)
        print("alerted:", alert["severity"])
    else:
        print("commitment healthy")

Expected output when both metrics are below target:

alerted: critical

Verification

Confirm the monitor reports the same figures the console shows before trusting its alerts.

  1. Cross-check the console. In Cost Explorer > Savings Plans > Utilization report and Coverage report for the same month, the percentages must match the monitor’s output within rounding. A mismatch usually means the period boundary differs — Cost Explorer’s End date is exclusive.

  2. Assert the ratio identity. Coverage recomputed from dollars must equal the reported percentage:

    cov = savings_plan_coverage("2026-06-01", "2026-07-01")
    # coverage_pct * eligible_spend should equal covered spend
    implied_covered = cov["coverage_pct"] * cov["eligible_spend"]
    assert cov["eligible_spend"] - cov["on_demand_leak"] - implied_covered < Decimal("0.5")
    

The health record you persist per run should look like:

{'period': '2026-06-01..2026-07-01', 'coverage_pct': 0.735, 'utilization_pct': 0.942,
 'on_demand_leak': 812.44, 'unused_commitment': 148.30, 'severity': 'critical'}

Gotchas & Edge Cases

  • Coverage and utilization measure opposite risks. High utilization with low coverage means you under-committed and should evaluate a top-up; low utilization means you over-committed and should hold further purchases. Alerting on only one axis hides half the failure surface.
  • End date is exclusive. Cost Explorer treats the TimePeriod.End as exclusive, so a full June pull is Start=2026-06-01, End=2026-07-01. Using 2026-06-30 silently drops the last day and understates both metrics.
  • Recommendations lag reality. Cost and usage data can settle for 24–48 hours after the fact. Evaluate closed periods, not the current day, or a mid-day dip will trigger a false alert.
  • A single dip is not a trend. A nightly batch job or a maintenance window can push utilization down for one period. Require a sustained breach across two or more periods before escalating, and lean on the dedup logic in deduplicating and escalating cost alerts so the same drift does not page twice.
  • Mixed commitment types. An account may hold both Savings Plans and Reserved Instances covering overlapping usage. GetSavingsPlansCoverage sees only the Savings Plan side; pull GetReservationCoverage too and sum eligible spend, or coverage reads artificially low.
  • Throttling under multi-account sweeps. Iterating coverage across dozens of linked accounts trips ThrottlingException. Bound concurrency and back off the same way you would for any rate-limited metric pull.

Frequently Asked Questions

What coverage target should I set?

Target coverage against your stable base, not total spend — commonly 70–85%. Committing to 100% coverage means locking in your peak, which strands capacity every time load recedes. Leave the volatile top of the load on on-demand or a shallower commitment and cover only the floor that is present nearly all the time.

Is low utilization always waste?

Effectively yes for the current period — an unused committed dollar cannot be reclaimed. But a one-off dip from a decommission or migration is expected; the concern is sustained under-utilization, which means the committed base no longer matches reality and future purchases for that family should pause until it restabilizes.

How do coverage and utilization relate to the break-even I calculated at purchase?

Break-even assumed a utilization level. If realized utilization runs below that assumption, the actual payback month slips later than modeled and may exceed the term. Tracking utilization is how you find out early that a break-even projection is no longer holding.

Should the monitor auto-buy or auto-cancel commitments?

No. It should surface a dollar-weighted recommendation and route it for human approval. Buying or modifying a one- or three-year commitment is a financially significant, hard-to-reverse action that belongs behind a person, not an automated threshold.

How often should this run?

Daily against the most recent closed period is a good cadence for catching drift early, with a monthly rollup for renewal planning. Running it hourly adds noise without signal, since the underlying cost data only settles once a day.

Back to: Reserved Capacity & Commitment Optimization