Check Savings Plans Coverage and Utilization

A calculator resting on printed financial charts on a wooden desk

Photo by Jakub Żerdzicki on Unsplash

A Savings Plans coverage report comes from two Cost Explorer calls: GetSavingsPlansCoverage shows how much of your eligible spend a plan covered and how much ran at On-Demand rates, and GetSavingsPlansUtilization shows how much of the commitment you actually used. The script below runs both, groups uncovered spend by service and can add AWS’s purchase recommendation.

Savings Plans fail in two opposite ways. Buy too little and eligible usage keeps running at On-Demand prices; buy too much and you pay for commitment nothing uses. This example is for engineers and FinOps practitioners who want a Savings Plans coverage report they can run from a terminal every month, without clicking through the Cost Explorer console.

You get a read-only TypeScript script for the AWS SDK for JavaScript v3. It belongs with the other AWS SDK v3 cost examples, and pairs with the script to find EC2 Reserved Instances about to expire, which covers the older commitment type.

Coverage vs utilization: what’s the difference?

Coverage Utilization
Question How much of my eligible spend did a plan pay for? How much of the commitment I bought did I use?
API GetSavingsPlansCoverage GetSavingsPlansUtilization
Key fields SpendCoveredBySavingsPlans, OnDemandCost, TotalCost, CoveragePercentage TotalCommitment, UsedCommitment, UnusedCommitment, UtilizationPercentage, NetSavings
Too low means Eligible usage is paying On-Demand prices You’re paying for commitment nothing uses
Group or filter by Group by SERVICE, REGION or INSTANCE_FAMILY No grouping; filter by plan ARN, type, Region and more

Coverage only counts usage a Savings Plan could have covered. AWS offers four types: Compute Savings Plans (EC2, Fargate and Lambda, up to 66% off On-Demand), EC2 Instance Savings Plans (one instance family in one Region, up to 72%), SageMaker AI Savings Plans (up to 64%) and Database Savings Plans (Aurora, RDS, DynamoDB, ElastiCache and other database services, up to 35%). Spend on S3 or data transfer never appears in the report.

The two numbers pull against each other. Pushing coverage toward 100% means buying for your peak hours, and those plans sit partly unused at night. The FinOps Foundation’s rate optimization capability treats this as a trade-off to manage, not a number to maximize.

What does the report cost to run?

Cost Explorer API requests are billed. As of September 2026, the AWS Cost Explorer pricing page lists $0.01 per request against your primary billing view. The script makes at least 3 requests (monthly coverage, coverage by service, utilization), 4 with --recommend, plus one per extra page of services: about $0.04 a run, or $0.48 a year if you run it monthly. It counts its requests and prints the total, so a scheduled job never surprises you.

What does the script do?

  1. Monthly coverageGetSavingsPlansCoverage with Granularity: "MONTHLY" for the last N months (default 3). End is exclusive and must be before the current date, so the script stops at yesterday.
  2. Uncovered spend by serviceA second coverage call grouped by SERVICE for the last 30 days, sorted by OnDemandCost. Granularity can’t be set together with GroupBy, so this is one total per service.
  3. Monthly utilizationGetSavingsPlansUtilization per month plus the total, including unused commitment and net savings. Accounts with no plans get a DataUnavailableException, which the script reports instead of failing.
  4. Optional recommendationWith --recommend COMPUTE_SP (or EC2_INSTANCE_SP, SAGEMAKER_SP, DATABASE_SP), GetSavingsPlansPurchaseRecommendation for a 1-year, no-upfront plan with a 30-day lookback, and the time it was generated.
  5. Reports onlyNothing is purchased. The script prints tables and the number of billed requests.

Prerequisites

Which IAM permissions does it need?

Three Cost Explorer read actions. Drop the last one if you never pass --recommend. ReadOnlyAccess alone may not include Cost Explorer, so grant these explicitly.

savings-plans-report-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadSavingsPlansReports",
      "Effect": "Allow",
      "Action": [
        "ce:GetSavingsPlansCoverage",
        "ce:GetSavingsPlansUtilization",
        "ce:GetSavingsPlansPurchaseRecommendation"
      ],
      "Resource": "*"
    }
  ]
}

If a call is denied, the steps to troubleshoot AWS IAM access denied errors apply; in an organization, also check that the management account hasn’t restricted Cost Explorer access for members.

The full script for a Savings Plans coverage report

savings-plans-report.ts

// savings-plans-report.ts
// Savings Plans coverage and utilization report from Cost Explorer: monthly coverage, uncovered On-Demand spend
// by service, monthly utilization, and (with --recommend) AWS's purchase recommendation summary.
// Read-only. Every Cost Explorer API request is billed ($0.01 each); the script prints how many it made.
// Usage: npx tsx savings-plans-report.ts [--months 3] [--recommend COMPUTE_SP|EC2_INSTANCE_SP|SAGEMAKER_SP|DATABASE_SP]
import {
  CostExplorerClient,
  DataUnavailableException,
  GetSavingsPlansCoverageCommand,
  GetSavingsPlansPurchaseRecommendationCommand,
  GetSavingsPlansUtilizationCommand,
  type SupportedSavingsPlansType,
} from "@aws-sdk/client-cost-explorer";

const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
  const i = args.indexOf(name);
  return i >= 0 ? args[i + 1] : undefined;
};
const months = Number(flag("--months") ?? 3);
const recommend = flag("--recommend") as SupportedSavingsPlansType | undefined;

const ce = new CostExplorerClient({ region: "us-east-1" }); // Cost Explorer's API endpoint is in us-east-1
let requests = 0;
async function call<T>(p: Promise<T>): Promise<T> {
  requests++;
  return p;
}

const day = (d: Date) => d.toISOString().slice(0, 10);
// End is exclusive and must be before the current date, so the report runs up to yesterday.
const endDate = new Date(Date.now() - 86_400_000);
const lastDay = new Date(endDate.getTime() - 86_400_000); // last full day included
const end = day(endDate);
const start = day(new Date(Date.UTC(lastDay.getUTCFullYear(), lastDay.getUTCMonth() - (months - 1), 1)));
const last30 = day(new Date(endDate.getTime() - 30 * 86_400_000));
const usd = (s?: string) => `$${Number(s ?? 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const pct = (s?: string) => `${Number(s ?? 0).toFixed(1)}%`;

async function coverageByMonth(): Promise<void> {
  const res = await call(ce.send(new GetSavingsPlansCoverageCommand({
    TimePeriod: { Start: start, End: end },
    Granularity: "MONTHLY",
    Metrics: ["SpendCoveredBySavingsPlans"],
  })));
  console.log("\nCoverage by month (Savings Plans-eligible spend only)");
  console.table((res.SavingsPlansCoverages ?? []).map((c) => ({
    Month: c.TimePeriod?.Start?.slice(0, 7),
    Covered: usd(c.Coverage?.SpendCoveredBySavingsPlans),
    OnDemand: usd(c.Coverage?.OnDemandCost),
    Total: usd(c.Coverage?.TotalCost),
    Coverage: pct(c.Coverage?.CoveragePercentage),
  })));
}

// Granularity can't be combined with GroupBy, so this is one total per service for the last 30 days.
async function uncoveredByService(): Promise<void> {
  const rows: { Service: string; OnDemand: number; Coverage: string }[] = [];
  let NextToken: string | undefined;
  do {
    const res = await call(ce.send(new GetSavingsPlansCoverageCommand({
      TimePeriod: { Start: last30, End: end },
      GroupBy: [{ Type: "DIMENSION", Key: "SERVICE" }],
      Metrics: ["SpendCoveredBySavingsPlans"],
      SortBy: { Key: "OnDemandCost", SortOrder: "DESCENDING" },
      MaxResults: 100,
      NextToken,
    })));
    for (const c of res.SavingsPlansCoverages ?? []) {
      rows.push({
        Service: Object.values(c.Attributes ?? {}).join(" "), // the grouped SERVICE value
        OnDemand: Number(c.Coverage?.OnDemandCost ?? 0),
        Coverage: pct(c.Coverage?.CoveragePercentage),
      });
    }
    NextToken = res.NextToken;
  } while (NextToken);
  console.log(`\nOn-Demand spend not covered, last 30 days (${last30} to ${end}), by service`);
  console.table(rows.filter((r) => r.OnDemand > 0).map((r) => ({ ...r, OnDemand: usd(String(r.OnDemand)) })));
}

async function utilizationByMonth(): Promise<void> {
  try {
    const res = await call(ce.send(new GetSavingsPlansUtilizationCommand({
      TimePeriod: { Start: start, End: end },
      Granularity: "MONTHLY",
    })));
    console.log("\nUtilization by month");
    console.table((res.SavingsPlansUtilizationsByTime ?? []).map((u) => ({
      Month: u.TimePeriod?.Start?.slice(0, 7),
      Commitment: usd(u.Utilization?.TotalCommitment),
      Used: usd(u.Utilization?.UsedCommitment),
      Unused: usd(u.Utilization?.UnusedCommitment),
      Utilization: pct(u.Utilization?.UtilizationPercentage),
      NetSavings: usd(u.Savings?.NetSavings),
    })));
    const t = res.Total;
    console.log(`Total: ${pct(t?.Utilization?.UtilizationPercentage)} used, ${usd(t?.Utilization?.UnusedCommitment)} unused, ` +
      `${usd(t?.Savings?.NetSavings)} net savings vs ${usd(t?.Savings?.OnDemandCostEquivalent)} On-Demand equivalent`);
  } catch (err) {
    if (err instanceof DataUnavailableException) console.log("\nUtilization: no Savings Plans data for this period.");
    else throw err;
  }
}

async function recommendation(type: SupportedSavingsPlansType): Promise<void> {
  const res = await call(ce.send(new GetSavingsPlansPurchaseRecommendationCommand({
    SavingsPlansType: type,
    TermInYears: "ONE_YEAR",
    PaymentOption: "NO_UPFRONT",
    LookbackPeriodInDays: "THIRTY_DAYS",
  })));
  const s = res.SavingsPlansPurchaseRecommendation?.SavingsPlansPurchaseRecommendationSummary;
  console.log(`\nRecommendation: ${type}, 1 year, no upfront, 30-day lookback (generated ${res.Metadata?.GenerationTimestamp ?? "n/a"})`);
  if (!s) {
    console.log("No recommendation: not enough eligible On-Demand usage in the lookback period.");
    return;
  }
  console.log(`Commit ${usd(s.HourlyCommitmentToPurchase)}/hour; estimated savings ${usd(s.EstimatedMonthlySavingsAmount)}/month ` +
    `(${pct(s.EstimatedSavingsPercentage)}) on current On-Demand spend of ${usd(s.CurrentOnDemandSpend)}`);
}

async function main(): Promise<void> {
  await coverageByMonth();
  await uncoveredByService();
  await utilizationByMonth();
  if (recommend) await recommendation(recommend);
  console.log(`\n${requests} Cost Explorer API requests made (≈ $${(requests * 0.01).toFixed(2)}). Report only: nothing was purchased.`);
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

How do you run it?

Terminal

npm install @aws-sdk/client-cost-explorer
npm install --save-dev tsx typescript

# Coverage and utilization for the last 3 months
AWS_PROFILE=billing-readonly npx tsx savings-plans-report.ts

# Add AWS's Compute Savings Plans recommendation
AWS_PROFILE=billing-readonly npx tsx savings-plans-report.ts --recommend COMPUTE_SP

# A longer view: the last 12 months (Start must be within 13 months)
AWS_PROFILE=billing-readonly npx tsx savings-plans-report.ts --months 12

Sample output

Output

Coverage by month (Savings Plans-eligible spend only)
┌─────────┬───────────┬─────────────┬─────────────┬─────────────┬──────────┐
│ (index) │ Month     │ Covered     │ OnDemand    │ Total       │ Coverage │
├─────────┼───────────┼─────────────┼─────────────┼─────────────┼──────────┤
│ 0       │ '2026-07' │ '$6,420.00' │ '$2,580.00' │ '$9,000.00' │ '71.3%'  │
│ 1       │ '2026-08' │ '$6,390.00' │ '$3,310.00' │ '$9,700.00' │ '65.9%'  │
│ 2       │ '2026-09' │ '$5,880.00' │ '$3,020.00' │ '$8,900.00' │ '66.1%'  │
└─────────┴───────────┴─────────────┴─────────────┴─────────────┴──────────┘

On-Demand spend not covered, last 30 days (2026-08-27 to 2026-09-26), by service
┌─────────┬──────────────────────────────────────────┬─────────────┬──────────┐
│ (index) │ Service                                  │ OnDemand    │ Coverage │
├─────────┼──────────────────────────────────────────┼─────────────┼──────────┤
│ 0       │ 'Amazon Elastic Compute Cloud - Compute' │ '$2,140.00' │ '69.8%'  │
│ 1       │ 'AWS Lambda'                             │ '$610.00'   │ '12.4%'  │
│ 2       │ 'Amazon Elastic Container Service'       │ '$270.00'   │ '0.0%'   │
└─────────┴──────────────────────────────────────────┴─────────────┴──────────┘

Utilization by month
┌─────────┬───────────┬─────────────┬─────────────┬───────────┬─────────────┬─────────────┐
│ (index) │ Month     │ Commitment  │ Used        │ Unused    │ Utilization │ NetSavings  │
├─────────┼───────────┼─────────────┼─────────────┼───────────┼─────────────┼─────────────┤
│ 0       │ '2026-07' │ '$3,720.00' │ '$3,720.00' │ '$0.00'   │ '100.0%'    │ '$2,700.00' │
│ 1       │ '2026-08' │ '$3,720.00' │ '$3,690.24' │ '$29.76'  │ '99.2%'     │ '$2,699.76' │
│ 2       │ '2026-09' │ '$3,240.00' │ '$2,991.60' │ '$248.40' │ '92.3%'     │ '$2,640.00' │
└─────────┴───────────┴─────────────┴─────────────┴───────────┴─────────────┴─────────────┘
Total: 97.4% used, $278.16 unused, $8,039.76 net savings vs $18,719.76 On-Demand equivalent

Recommendation: COMPUTE_SP, 1 year, no upfront, 30-day lookback (generated 2026-09-26T08:14:52Z)
Commit $1.20/hour; estimated savings $251.40/month (24.8%) on current On-Demand spend of $1,013.50

4 Cost Explorer API requests made (≈ $0.04). Report only: nothing was purchased.

This is the output of the second command, and the figures are illustrative. September is a partial month, so compare full months when you judge a trend.

How do you read the report?

Take the numbers above. Coverage fell from 71.3% to around 66% while utilization stayed above 92%: usage grew, the plans kept up, and the new usage runs On-Demand. The service table shows where. EC2 is the biggest uncovered line, while ECS on Fargate is 0% covered even though a Compute Savings Plan would apply to it. Interruptible EC2 work may fit Spot better than a commitment; the script to check EC2 Spot price history by Availability Zone shows what it would cost.

The recommendation turns that into a number. A $1.20 hourly commitment is $1.20 × 730 = $876 a month for a year. AWS estimates it saves $251.40 a month on $1,013.50 of current On-Demand spend, which is a 24.8% saving. That estimate assumes the last 30 days repeat for 12 months, so check it against your roadmap first. The recommendation is AWS’s most recently generated set, and its generation time is printed so you know how old it is.

Before buying, remove waste so you don’t commit to it. Instances that could shrink or stop show up with the script to detect underutilized EC2 instances by CPU, and moving off old types first with the example to find previous-generation EC2 instances to upgrade keeps an EC2 Instance Savings Plan from locking you into a family you’re about to leave. For databases, the scripts to find idle RDS instances with no connections and find idle ElastiCache clusters with no reads or writes do the same before a Database Savings Plan.

Troubleshooting

  • DataUnavailableException on utilization. There were no active Savings Plans in the period. Coverage still works and shows 0% covered.
  • An error about the time period. Start must be within the last 13 months, so keep --months at 13 or less.
  • LimitExceededException. Too many Cost Explorer calls in a short time, often from several scripts sharing one account. Wait and rerun; the guide to configure retry and timeout settings in AWS SDK for JavaScript v3 shows how to add retries.
  • Member account sees only its own usage. Expected. Run from the management account for the whole organization.

Ask ChatWithCloud instead

For a quick check, ask ChatWithCloud “What was my Savings Plans coverage and utilization last month?” It writes AWS SDK for JavaScript v2 code, runs it on your machine and explains the result; Cost Explorer questions need a profile with ce: permissions, as the guide to connect ChatWithCloud to your AWS account explains. Each question can trigger several billed Cost Explorer requests, and generated code runs without a confirmation step. For a monthly report with a fixed request count, keep the script. For wider questions, see how to ask AI why your AWS bill increased, or get last month’s AWS cost broken down by service with another Cost Explorer script.

Frequently asked questions

What is a good Savings Plans coverage percentage?

There is no universal target. Steady workloads can carry high coverage; spiky or shrinking ones should stay lower so utilization stays near 100%. Watch both numbers together, month over month.

Why is my Savings Plans utilization below 100%?

In some hours your eligible usage was smaller than the hourly commitment, so part of it went unused. Common causes are workloads that were shut down, moved to ineligible services or shifted to hours with less usage.

Is there a coverage report for Reserved Instances too?

Yes. Cost Explorer has separate GetReservationCoverage and GetReservationUtilization calls that return the same kind of numbers for Reserved Instances. Each call is billed like the Savings Plans ones.

How often should I run a Savings Plans coverage report?

Monthly is enough for most teams, plus before any purchase and when a plan is about to expire. At $0.01 per request, a monthly run costs a few cents.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud