
Photo by panumas nikhomkhai on Pexels
Unused EC2 Capacity Reservations are active reservations whose AvailableInstanceCount is above zero: slots you pay for at the On-Demand rate with no instance in them. Find them with DescribeCapacityReservations, confirm the pattern with the InstanceUtilization metric in the AWS/EC2CapacityReservations CloudWatch namespace, then shrink, re-target or cancel them.
On-Demand Capacity Reservations are easy to create for a launch, a migration or a game day and just as easy to forget. Each empty slot keeps billing until someone cancels it. This example is for platform and FinOps engineers who want a list of unused EC2 capacity reservations across Regions, ranked by what the empty slots cost each month.
The script lists active reservations, reads 14 days of utilization, looks up the unused-reservation rate with the AWS Price List API and prints a table. It changes nothing unless you name reservations with --cancel and add --apply, and even then it refuses anything that’s in use, still inside a commitment, or a Capacity Block.
What does an unused EC2 Capacity Reservation cost?
The Capacity Reservation pricing and billing page says reservations are charged at the equivalent On-Demand rate whether instances run in them or not. A running instance that matches the reservation is billed as a normal instance and the reservation adds nothing; an empty slot shows up on the bill as unused reservation. Billing is per second with a 60-second minimum, from the moment the reservation is provisioned until it’s cancelled or expires.
As of September 2026, the AWS Price List for Amazon EC2 (published 25 September 2026) shows these unused-reservation rates for Linux in US East (N. Virginia), the same as the On-Demand rates for those types:
| Instance type | Unused reservation, per hour | Per empty slot, 730-hour month |
|---|---|---|
m5.large |
$0.096 | $70.08 |
m7i.large |
$0.1008 | $73.58 |
r6i.large |
$0.126 | $91.98 |
c6i.xlarge |
$0.17 | $124.10 |
Worked example: a reservation for 20 m5.large Linux instances with 15 running leaves 5 empty slots, and 5 × $0.096 × 730 hours = $350.40 a month for capacity nobody uses. Savings Plans and Regional Reserved Instances do apply to Capacity Reservations with matching attributes, but AWS applies them to running instances first, so the unused slots are often the part left at full price. Zonal Reserved Instances don’t apply to Capacity Reservations at all. The script to check Savings Plans coverage for EC2 shows how much of your usage those discounts already absorb.
How do you spot unused EC2 capacity reservations?
DescribeCapacityReservations returns everything needed to judge a reservation. These fields drive the report:
| Field | Why it matters |
|---|---|
TotalInstanceCount / AvailableInstanceCount |
Reserved slots and the slots still free. Used = total − available. |
InstanceMatchCriteria |
open accepts any instance with matching type, platform and Availability Zone; targeted accepts only instances that explicitly target it. A targeted reservation sits empty when launch templates stop pointing at it. |
EndDateType / EndDate |
unlimited reservations run until someone cancels them; limited ones expire on their own. |
CommitmentInfo |
Future-dated reservations can carry a commitment. Cancelling during it needs a cancellation quote, and the charges continue for the wind-down period. |
ReservationType |
capacity-block marks Capacity Blocks for ML, which can’t be modified or cancelled. |
A single snapshot can mislead: a reservation might be empty only because an Auto Scaling group scaled in overnight. That’s why the script also reads the InstanceUtilization metric (percent of reserved instances in use) from the AWS/EC2CapacityReservations namespace, which Capacity Reservations send to CloudWatch every five minutes, keyed by the CapacityReservationId dimension. A reservation with a 0% maximum over 14 days never had a single instance in it.
What does the script do?
- Lists active reservations per Region
paginateDescribeCapacityReservationswith astate = activefilter, keeping those with at least one free slot. - Reads utilizationOne
GetMetricDatacall per 250 reservations, with the dailyAverageandMaximumofInstanceUtilizationover--days(default 14). - Prices the empty slots
GetProductson the Price List API withcapacitystatus = UnusedCapacityReservation, the instance type, Region, operating system and tenancy. SQL Server platforms are left asunknownbecause their rate depends on the edition. - ReportsSorts by monthly cost of the free slots (available × hourly rate × 730) and counts the reservations that were never used in the window.
- Cancels only on requestWith
--cancelit checks each named ID and prints what it would do; with--applyas well it callsCancelCapacityReservation.
Prerequisites
- Node.js 18 or later with
tsx, plus@aws-sdk/client-ec2,@aws-sdk/client-cloudwatchand@aws-sdk/client-pricing. The Price List API is called inus-east-1whatever Regions you scan. - A read-only profile for the report; setting up AWS profiles, SSO and roles covers the options. Use a separate profile for
--apply. - An inventory of what’s running helps when you investigate a reservation; the report of EC2 instances by type and Region is a good companion.
Which IAM permissions does it need?
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadReservationsMetricsAndPrices",
"Effect": "Allow",
"Action": [
"ec2:DescribeCapacityReservations",
"cloudwatch:GetMetricData",
"pricing:GetProducts"
],
"Resource": "*"
},
{
"Sid": "CancelOnlyWithApply",
"Effect": "Allow",
"Action": "ec2:CancelCapacityReservation",
"Resource": "arn:aws:ec2:*:123456789012:capacity-reservation/*"
}
]
}
Drop the second statement for a report-only role. The describe, metric and pricing actions don’t support resource-level restrictions, so they need "Resource": "*". If you extend the script, paste it into the IAM policy generator for TypeScript SDK code to list the new actions.
The script to find unused EC2 capacity reservations
// find-unused-ec2-capacity-reservations.ts
// Lists active On-Demand Capacity Reservations with unused slots, reads 14 days of InstanceUtilization
// from CloudWatch, prices the unused slots with the AWS Price List API and ranks them by monthly cost.
// Report only by default. --apply --cancel cr-1,cr-2 cancels only the named reservations, and only when
// they are active, completely empty right now, not a Capacity Block and outside any commitment period.
// Usage: npx tsx find-unused-ec2-capacity-reservations.ts [--regions us-east-1,eu-west-1] [--days 14]
// [--max-util 100] [--apply --cancel cr-0123456789abcdef0]
import {
EC2Client,
CancelCapacityReservationCommand,
paginateDescribeCapacityReservations,
type CapacityReservation,
} from "@aws-sdk/client-ec2";
import { CloudWatchClient, GetMetricDataCommand, type MetricDataQuery } from "@aws-sdk/client-cloudwatch";
import { PricingClient, GetProductsCommand, type Filter } from "@aws-sdk/client-pricing";
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 list = (v: string | undefined): string[] => (v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
const regions = list(flag("--regions") ?? process.env.AWS_REGION ?? "us-east-1");
const days = Number(flag("--days") ?? 14);
const maxUtil = Number(flag("--max-util") ?? 100);
const apply = args.includes("--apply");
const toCancel = new Set(list(flag("--cancel")));
const HOURS_PER_MONTH = 730;
interface Row {
Region: string;
Reservation: string;
Type: string;
AZ: string;
Platform: string;
Match: string;
Used: string;
AvgUtil: string;
MaxUtil: string;
Ends: string;
UnusedPerMonth: number | null;
cr: CapacityReservation;
}
const errorText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));
// ---- Price List API: hourly On-Demand rate for an unused reservation slot ----
const pricing = new PricingClient({ region: "us-east-1" });
const priceCache = new Map<string, number | null>();
// Capacity Reservation platform -> Price List operatingSystem (SQL Server platforms: look the rate up by hand)
const OS: Record<string, string> = {
"Linux/UNIX": "Linux",
"Windows": "Windows",
"Red Hat Enterprise Linux": "RHEL",
"SUSE Linux": "SUSE",
"Ubuntu Pro": "Ubuntu Pro",
};
async function unusedHourlyRate(region: string, cr: CapacityReservation): Promise<number | null> {
const os = OS[cr.InstancePlatform ?? ""];
if (!os || !cr.InstanceType) return null;
const key = [region, cr.InstanceType, cr.InstancePlatform, cr.Tenancy].join("|");
if (priceCache.has(key)) return priceCache.get(key) ?? null;
const term = (Field: string, Value: string): Filter => ({ Type: "TERM_MATCH", Field, Value });
let rate: number | null = null;
try {
const out = await pricing.send(new GetProductsCommand({
ServiceCode: "AmazonEC2",
Filters: [
term("regionCode", region),
term("instanceType", cr.InstanceType),
term("operatingSystem", os),
term("licenseModel", "No License required"),
term("preInstalledSw", "NA"),
term("tenancy", cr.Tenancy === "dedicated" ? "Dedicated" : "Shared"),
term("capacitystatus", "UnusedCapacityReservation"),
],
MaxResults: 10,
}));
for (const item of out.PriceList ?? []) {
const product = JSON.parse(String(item)) as {
terms?: { OnDemand?: Record<string, { priceDimensions: Record<string, { pricePerUnit: { USD?: string } }> }> };
};
for (const offer of Object.values(product.terms?.OnDemand ?? {})) {
for (const dim of Object.values(offer.priceDimensions)) {
const usd = Number(dim.pricePerUnit.USD);
if (usd > 0) rate = usd;
}
}
}
} catch (err) {
console.error(`price lookup for ${cr.InstanceType} in ${region}: ${errorText(err)}`);
}
priceCache.set(key, rate);
return rate;
}
// ---- CloudWatch: average and maximum InstanceUtilization per reservation ----
async function utilization(region: string, ids: string[]): Promise<Map<string, { avg?: number; max?: number }>> {
const cw = new CloudWatchClient({ region });
const result = new Map<string, { avg?: number; max?: number }>();
const end = new Date();
const start = new Date(end.getTime() - days * 86_400_000);
for (let i = 0; i < ids.length; i += 250) { // 2 queries per reservation, 500 queries per call
const queries: MetricDataQuery[] = ids.slice(i, i + 250).flatMap((id, n) =>
(["Average", "Maximum"] as const).map((stat) => ({
Id: `${stat === "Average" ? "a" : "m"}${i + n}`,
Label: `${id} ${stat}`,
MetricStat: {
Metric: {
Namespace: "AWS/EC2CapacityReservations",
MetricName: "InstanceUtilization",
Dimensions: [{ Name: "CapacityReservationId", Value: id }],
},
Period: 86_400,
Stat: stat,
},
})));
let NextToken: string | undefined;
do {
const out = await cw.send(new GetMetricDataCommand({ MetricDataQueries: queries, StartTime: start, EndTime: end, NextToken }));
for (const r of out.MetricDataResults ?? []) {
const [id, stat] = (r.Label ?? "").split(" ");
const values = r.Values ?? [];
if (!id || values.length === 0) continue;
const entry = result.get(id) ?? {};
if (stat === "Average") entry.avg = values.reduce((s, v) => s + v, 0) / values.length;
else entry.max = Math.max(...values);
result.set(id, entry);
}
NextToken = out.NextToken;
} while (NextToken);
}
return result;
}
async function scanRegion(region: string): Promise<Row[]> {
const ec2 = new EC2Client({ region });
const reservations: CapacityReservation[] = [];
const pages = paginateDescribeCapacityReservations({ client: ec2 }, { Filters: [{ Name: "state", Values: ["active"] }] });
for await (const page of pages) reservations.push(...(page.CapacityReservations ?? []));
const unused = reservations.filter((cr) => (cr.AvailableInstanceCount ?? 0) > 0);
const util = await utilization(region, unused.map((cr) => cr.CapacityReservationId ?? "").filter(Boolean));
const rows: Row[] = [];
for (const cr of unused) {
const id = cr.CapacityReservationId ?? "";
const u = util.get(id);
if (u?.avg !== undefined && u.avg > maxUtil && (cr.AvailableInstanceCount ?? 0) < (cr.TotalInstanceCount ?? 0)) continue;
const rate = await unusedHourlyRate(region, cr);
const available = cr.AvailableInstanceCount ?? 0;
rows.push({
Region: region,
Reservation: id,
Type: cr.ReservationType === "capacity-block" ? `${cr.InstanceType} (block)` : cr.InstanceType ?? "",
AZ: cr.AvailabilityZone ?? "",
Platform: cr.InstancePlatform ?? "",
Match: cr.InstanceMatchCriteria ?? "",
Used: `${(cr.TotalInstanceCount ?? 0) - available}/${cr.TotalInstanceCount ?? 0}`,
AvgUtil: u?.avg !== undefined ? `${u.avg.toFixed(0)}%` : "no data",
MaxUtil: u?.max !== undefined ? `${u.max.toFixed(0)}%` : "no data",
Ends: cr.EndDateType === "limited" && cr.EndDate ? cr.EndDate.toISOString().slice(0, 10) : "never",
UnusedPerMonth: rate === null ? null : Math.round(available * rate * HOURS_PER_MONTH * 100) / 100,
cr,
});
}
return rows;
}
/** Why a reservation must not be cancelled by this script, or undefined when it's safe. */
function cancelBlocker(cr: CapacityReservation): string | undefined {
if (cr.State !== "active") return `state is ${cr.State}`;
if (cr.ReservationType === "capacity-block") return "Capacity Blocks can't be cancelled";
if ((cr.AvailableInstanceCount ?? 0) !== (cr.TotalInstanceCount ?? 0)) return "instances are running in it";
const commitmentEnd = cr.CommitmentInfo?.CommitmentEndDate;
if (commitmentEnd && commitmentEnd > new Date()) return `commitment runs until ${commitmentEnd.toISOString().slice(0, 10)}`;
return undefined;
}
async function main(): Promise<void> {
const rows: Row[] = [];
for (const region of regions) {
try {
rows.push(...(await scanRegion(region)));
} catch (err) {
console.error(`${region}: ${errorText(err)}`);
}
}
if (rows.length === 0) {
console.log(`No active Capacity Reservations with unused capacity in ${regions.join(", ")}.`);
return;
}
rows.sort((a, b) => (b.UnusedPerMonth ?? 0) - (a.UnusedPerMonth ?? 0));
console.table(rows.map(({ cr, UnusedPerMonth, ...shown }) => ({ ...shown, "$/month unused": UnusedPerMonth ?? "unknown" })));
const total = rows.reduce((sum, r) => sum + (r.UnusedPerMonth ?? 0), 0);
const idle = rows.filter((r) => r.MaxUtil === "0%").length;
console.log(`${rows.length} reservations have unused slots, about $${total.toFixed(2)} a month at On-Demand rates ` +
`before Savings Plans or Regional RI discounts. ${idle} had 0% utilization for the whole ${days}-day window.`);
if (toCancel.size === 0) return;
for (const id of toCancel) {
const row = rows.find((r) => r.Reservation === id);
if (!row) {
console.log(`skip ${id}: not in this report (wrong Region, fully used, or not active)`);
continue;
}
const blocker = cancelBlocker(row.cr);
if (blocker) {
console.log(`skip ${id}: ${blocker}`);
continue;
}
if (!apply) {
console.log(`would cancel ${id} in ${row.Region} (re-run with --apply)`);
continue;
}
try {
await new EC2Client({ region: row.Region }).send(new CancelCapacityReservationCommand({ CapacityReservationId: id }));
console.log(`cancelled ${id} in ${row.Region}`);
} catch (err) {
console.error(`cancel ${id}: ${errorText(err)}`);
}
}
}
main().catch((err) => {
console.error(errorText(err));
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-ec2 @aws-sdk/client-cloudwatch @aws-sdk/client-pricing
npm install --save-dev tsx typescript @types/node
# Report for two Regions over the default 14 days
AWS_PROFILE=readonly npx tsx find-unused-ec2-capacity-reservations.ts --regions us-east-1,eu-west-1
# Only reservations averaging 25% utilization or less over 30 days
AWS_PROFILE=readonly npx tsx find-unused-ec2-capacity-reservations.ts --days 30 --max-util 25
# Preview, then cancel one reservation
AWS_PROFILE=readonly npx tsx find-unused-ec2-capacity-reservations.ts --cancel cr-0a1b2c3d4e5f60002
AWS_PROFILE=ops-admin npx tsx find-unused-ec2-capacity-reservations.ts --cancel cr-0a1b2c3d4e5f60002 --apply
Sample output
┌─────────┬─────────────┬────────────────────────┬──────────────┬──────────────┬────────────────────────────────────┬────────────┬─────────┬─────────┬─────────┬──────────────┬────────────────┐
│ (index) │ Region │ Reservation │ Type │ AZ │ Platform │ Match │ Used │ AvgUtil │ MaxUtil │ Ends │ $/month unused │
├─────────┼─────────────┼────────────────────────┼──────────────┼──────────────┼────────────────────────────────────┼────────────┼─────────┼─────────┼─────────┼──────────────┼────────────────┤
│ 0 │ 'us-east-1' │ 'cr-0a1b2c3d4e5f60002' │ 'c6i.xlarge' │ 'us-east-1b' │ 'Linux/UNIX' │ 'targeted' │ '0/8' │ '0%' │ '0%' │ 'never' │ 992.8 │
│ 1 │ 'us-east-1' │ 'cr-0a1b2c3d4e5f60001' │ 'm5.large' │ 'us-east-1a' │ 'Linux/UNIX' │ 'open' │ '15/20' │ '75%' │ '80%' │ 'never' │ 350.4 │
│ 2 │ 'us-east-1' │ 'cr-0a1b2c3d4e5f60003' │ 'm7i.large' │ 'us-east-1c' │ 'Linux/UNIX' │ 'open' │ '0/4' │ '0%' │ '0%' │ '2027-09-30' │ 294.34 │
│ 3 │ 'us-east-1' │ 'cr-0a1b2c3d4e5f60005' │ 'r6i.large' │ 'us-east-1a' │ 'Linux/UNIX' │ 'open' │ '9/10' │ '90%' │ '100%' │ 'never' │ 91.98 │
│ 4 │ 'us-east-1' │ 'cr-0a1b2c3d4e5f60004' │ 'm5.large' │ 'us-east-1a' │ 'Windows with SQL Server Standard' │ 'open' │ '1/2' │ '50%' │ '50%' │ 'never' │ 'unknown' │
└─────────┴─────────────┴────────────────────────┴──────────────┴──────────────┴────────────────────────────────────┴────────────┴─────────┴─────────┴─────────┴──────────────┴────────────────┘
5 reservations have unused slots, about $1729.52 a month at On-Demand rates before Savings Plans or Regional RI discounts. 2 had 0% utilization for the whole 14-day window.
would cancel cr-0a1b2c3d4e5f60002 in us-east-1 (re-run with --apply)
skip cr-0a1b2c3d4e5f60003: commitment runs until 2027-06-30
The run used mocked EC2 and CloudWatch responses with the real Price List rates above, so IDs are illustrative. The targeted c6i.xlarge reservation never held an instance: most likely the launch template stopped targeting it, and it’s the one to cancel. The m7i.large reservation is also empty but still inside its commitment, so the script refuses it. The m5.large reservation is 75% used; reducing InstanceCount from 20 to 16 keeps a buffer and saves most of the $350.40.
What should you do with each unused reservation?
- Empty and
targeted. Check which launch templates or Auto Scaling groups used to target it. If the workload moved, cancel; if it should still land there, fix the targeting. - Empty and
open, with instances running elsewhere. Instances only fill anopenreservation when the instance type, platform and Availability Zone all match. A fleet that moved fromm5tom7i, or to another zone, leaves the old reservation empty while new instances run beside it. - Partly used.
ModifyCapacityReservationcan lowerInstanceCountor set an end date on an active reservation, except that during a commitment you can’t decrease the count or end it inside the commitment. Type, platform, zone and tenancy can’t be modified; AWS recommends cancelling and creating a new reservation for those. - Needed only for an event. Switch
EndDateTypetolimitedwith an end date so it expires without anyone remembering. - Covered by commitments. Before cancelling capacity, compare it with your commitments; the script to find Reserved Instances about to expire shows what’s ending soon.
Cancelling releases the reserved capacity. Instances already running in it keep running, but stopped instances that target the reservation can’t start again until you change their targeting. Empty slots are often a symptom of over-sized or idle fleets as well, so pair this report with Compute Optimizer right-sizing recommendations for EC2 and the script to detect underutilized EC2 instances by CPU. Dedicated Hosts have the same problem at the size of a whole server; the script to find idle EC2 Dedicated Hosts checks them.
Troubleshooting
AvgUtilshowsno data. Metrics aren’t sent for reservations active for less than five minutes, and a new reservation has fewer days of data than--days. Check the Region too: the metric lives in the reservation’s Region.$/month unusedshowsunknown. The platform is a SQL Server variant, or the Price List filter found no match; look the rate up on the EC2 On-Demand pricing page.- A reservation shared with you is missing. The script only reports
activereservations visible in the account it runs in; run it in the owning account, which is also where the unused slots are billed unless billing was assigned elsewhere. UnauthorizedOperationor an access-denied error. EC2 reports missing permissions asUnauthorizedOperation; CloudWatch and the Price List API return their own access-denied errors. Check the policy above, then work through troubleshooting AWS IAM access denied errors.- The bill still shows unused capacity after cancelling. Future-dated reservations cancelled during their commitment move to
cancellingand keep charging for the wind-down period. Asking AI why your AWS bill changed helps confirm when the line item stops.
Ask ChatWithCloud instead
For a quick check, ask ChatWithCloud “Which Capacity Reservations in this Region have unused capacity, and how many slots are free?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and summarizes the result; how ChatWithCloud answers AWS questions locally explains the loop. It uses one profile and Region per session, can be wrong, and runs changes without asking for confirmation, so use a read-only profile and keep cancellations in the script above. Other cost reports live in the AWS practical examples library.
Frequently asked questions
Do you pay for unused EC2 Capacity Reservations?
Yes. Every slot is billed at the equivalent On-Demand rate while the reservation is provisioned. When a matching instance runs in a slot, you pay for the instance instead, and the slot adds nothing.
Do Savings Plans cover Capacity Reservations?
Savings Plans and Regional Reserved Instances apply to reservations with matching attributes, but AWS applies them to instance usage before unused reservation slots. Zonal Reserved Instances don’t apply to Capacity Reservations.
Why is my open Capacity Reservation empty when matching instances are running?
An instance only uses an open reservation when its instance type, platform and Availability Zone match and its own Capacity Reservation preference is open. A different zone or a Windows instance against a Linux reservation won’t fill it.
Can I cancel a Capacity Block?
No. AWS doesn’t allow Capacity Blocks for ML to be modified or cancelled, which is why the script skips reservations of type capacity-block.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud