
Photo by Przemek Leśniewski on Pexels
To find overprovisioned EBS IOPS, list your io1 and io2 volumes with DescribeVolumes, pull 14 days of 1-minute VolumeReadOps and VolumeWriteOps from CloudWatch, and divide the busiest minute by 60 to get peak IOPS. Any volume provisioned far above that peak plus headroom is paying for IOPS it never uses. The script below does the math and prices the fix.
Provisioned IOPS SSD volumes bill twice: once for storage and once for every IOPS you provision, every hour, whether the database touches them or not. Teams size them for launch day or a load test and never look again. On a large io1 volume the IOPS line is often most of the bill, so this is one of the few storage checks where a single change can save hundreds of dollars a month.
This example is for engineers and FinOps reviewers who want to find overprovisioned EBS IOPS with evidence before asking a database owner to change anything. It’s report only. It complements the script to convert gp2 volumes to gp3 safely, which handles the General Purpose side of the same bill.
What do provisioned IOPS cost?
As of September 2026, the Amazon EBS pricing page lists these us-east-1 prices:
| Volume type | Storage per GB-month | IOPS per provisioned IOPS-month |
|---|---|---|
io1 |
$0.125 | $0.065 |
io2 |
$0.125 | $0.065 up to 32,000; $0.046 from 32,001 to 64,000; a lower third tier above 64,000 |
gp3 |
$0.08 | 3,000 included, then $0.005; 125 MiB/s included, then $0.06 per MiB/s-month |
Worked example: a 500 GiB io1 volume provisioned with 20,000 IOPS costs 500 × $0.125 = $62.50 for storage plus 20,000 × $0.065 = $1,300.00 for IOPS, so $1,362.50 a month. If its busiest minute in two weeks was 4,100 IOPS, 30% headroom gives 5,330, rounded up to 5,400. Cutting to 5,400 saves (20,000 − 5,400) × $0.065 = $949.00 a month. The script doesn’t price io2 above 64,000 IOPS; it marks those n/a, so check the pricing page for that tier.
How does the script measure real IOPS?
EBS sends 1-minute metrics to CloudWatch for attached volumes. VolumeReadOps and VolumeWriteOps count operations completed in the period, so the sum for one minute divided by 60 is that minute’s average IOPS. The script also reads VolumeConsumedReadWriteOps, which Provisioned IOPS volumes publish normalized to 256 KiB units: a 1,024 KiB I/O counts as 4. It takes whichever peak is higher, because large I/Os consume more of the provisioned rate than the raw count shows.
Two limits keep the window at 14 days. CloudWatch keeps 1-minute datapoints for 15 days, then only 5-minute averages, which flatten bursts. And one volume’s 14 days of three series come to 60,480 datapoints, inside the 100,800 a single GetMetricData call returns. The peak is still a one-minute average, so sub-minute bursts are hidden: keep the headroom if the workload is spiky.
What does the script do?
- Lists io1 and io2 volumes
paginateDescribeVolumeswith avolume-typefilter, per Region. - Skips detached volumesAn
availablevolume has no metrics; the report points you to the unattached-volume check instead. - Reads 14 days of peaksOne
GetMetricDataquery per volume, with metric math for IOPS, consumed IOPS and MiB/s per minute. - Suggests a new IOPS valuePeak plus headroom (30% by default), rounded up to 100, never below 100 or above today’s value.
- Prices two optionsLower IOPS on the same volume type, or a move to
gp3at the suggested IOPS and throughput when both fit gp3’s 80,000 IOPS and 2,000 MiB/s ceilings.
Prerequisites
- Node.js 18 or later, npm and
tsx, plus@aws-sdk/client-ec2and@aws-sdk/client-cloudwatch. - A profile the SDK can resolve, ideally read-only.
- Volumes attached for most of the last 14 days; the report warns when less than half the window has data.
Which IAM permissions does it need?
Two read actions, neither of which supports resource-level permissions:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadVolumesAndMetrics",
"Effect": "Allow",
"Action": [
"ec2:DescribeVolumes",
"cloudwatch:GetMetricData"
],
"Resource": "*"
}
]
}
The IAM policy generator for TypeScript AWS SDK code produces the same list from the script if you extend it.
The script to find overprovisioned EBS IOPS
// find-overprovisioned-ebs-iops.ts
// Finds io1 and io2 volumes whose provisioned IOPS sit far above what the workload used.
// For each volume it reads 14 days of 1-minute CloudWatch data, takes the busiest minute
// (read + write ops, and consumed ops normalized to 256 KiB), adds headroom, and prices
// the provisioned IOPS you could drop, or a move to gp3. Report only: nothing is modified.
// Usage:
// npx tsx find-overprovisioned-ebs-iops.ts [--regions us-east-1,eu-west-1] [--days 14] [--headroom 0.3] [--csv iops.csv]
import { writeFileSync } from "node:fs";
import { CloudWatchClient, paginateGetMetricData, type MetricDataQuery } from "@aws-sdk/client-cloudwatch";
import { EC2Client, paginateDescribeVolumes, type Volume } from "@aws-sdk/client-ec2";
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 regions = (flag("--regions") ?? process.env.AWS_REGION ?? "us-east-1").split(",").map((r) => r.trim()).filter(Boolean);
const days = Math.min(Number(flag("--days") ?? "14"), 15); // 1-minute datapoints are kept for 15 days
const headroom = Number(flag("--headroom") ?? "0.3");
const csvPath = flag("--csv");
// us-east-1 list prices per month, checked September 2026 (aws.amazon.com/ebs/pricing/). Edit for other Regions.
const PRICE = {
pioGb: 0.125, // io1 and io2, per GB-month
io1Iops: 0.065, // per provisioned IOPS-month
io2Tiers: [
{ upTo: 32_000, rate: 0.065 },
{ upTo: 64_000, rate: 0.046 },
],
gp3Gb: 0.08,
gp3Iops: 0.005, // per IOPS-month above the included 3,000
gp3Mibps: 0.06, // per MiB/s-month above the included 125
};
const GP3_MAX_IOPS = 80_000;
const GP3_MAX_MIBPS = 2_000;
/** Monthly IOPS charge, or undefined when io2 goes past the tiers priced above. */
function iopsCost(type: string, iops: number): number | undefined {
if (type === "io1") return iops * PRICE.io1Iops;
let cost = 0;
let from = 0;
for (const tier of PRICE.io2Tiers) {
cost += Math.max(0, Math.min(iops, tier.upTo) - from) * tier.rate;
from = tier.upTo;
}
return iops > from ? undefined : cost;
}
interface Row {
Region: string;
Volume: string;
Type: string;
GiB: number;
Provisioned: number;
PeakIops: number;
PeakMiBps: number;
Suggested: number;
IopsSaving: string; // $ per month from lowering provisioned IOPS on the same type
Gp3Saving: string; // $ per month from moving to gp3 at the suggested IOPS and throughput
Note: string;
}
const money = (n: number | undefined) => (n === undefined ? "n/a" : `$${n.toFixed(2)}`);
async function peakUsage(cw: CloudWatchClient, volumeId: string, start: Date, end: Date) {
const metric = (id: string, name: string): MetricDataQuery => ({
Id: id,
MetricStat: { Metric: { Namespace: "AWS/EBS", MetricName: name, Dimensions: [{ Name: "VolumeId", Value: volumeId }] }, Period: 60, Stat: "Sum" },
ReturnData: false,
});
const queries: MetricDataQuery[] = [
metric("r", "VolumeReadOps"),
metric("w", "VolumeWriteOps"),
metric("c", "VolumeConsumedReadWriteOps"),
metric("rb", "VolumeReadBytes"),
metric("wb", "VolumeWriteBytes"),
{ Id: "ops", Expression: "(FILL(r,0) + FILL(w,0)) / 60", ReturnData: true }, // IOPS per minute
{ Id: "consumed", Expression: "FILL(c,0) / 60", ReturnData: true }, // 256 KiB-normalized IOPS
{ Id: "mibps", Expression: "(FILL(rb,0) + FILL(wb,0)) / 60 / 1048576", ReturnData: true },
];
const peak = { ops: 0, consumed: 0, mibps: 0, points: 0 };
for await (const page of paginateGetMetricData({ client: cw }, { StartTime: start, EndTime: end, MetricDataQueries: queries })) {
for (const r of page.MetricDataResults ?? []) {
const values = r.Values ?? [];
const max = values.reduce((m, v) => Math.max(m, v), 0);
if (r.Id === "ops") {
peak.ops = Math.max(peak.ops, max);
peak.points += values.length;
} else if (r.Id === "consumed") peak.consumed = Math.max(peak.consumed, max);
else if (r.Id === "mibps") peak.mibps = Math.max(peak.mibps, max);
}
}
return peak;
}
async function scanRegion(region: string): Promise<Row[]> {
const ec2 = new EC2Client({ region });
const cw = new CloudWatchClient({ region });
const end = new Date(Math.floor(Date.now() / 60_000) * 60_000);
const start = new Date(end.getTime() - days * 86_400_000);
const volumes: Volume[] = [];
for await (const page of paginateDescribeVolumes({ client: ec2 }, { Filters: [{ Name: "volume-type", Values: ["io1", "io2"] }] })) {
volumes.push(...(page.Volumes ?? []));
}
const rows: Row[] = [];
for (const v of volumes) {
const type = v.VolumeType ?? "";
const gib = v.Size ?? 0;
const provisioned = v.Iops ?? 0;
const row: Row = {
Region: region, Volume: v.VolumeId ?? "", Type: type, GiB: gib, Provisioned: provisioned,
PeakIops: 0, PeakMiBps: 0, Suggested: provisioned, IopsSaving: "", Gp3Saving: "", Note: "",
};
rows.push(row);
if (v.State !== "in-use") {
row.Note = `${v.State}: no metrics while detached; see the unattached-volume check`;
continue;
}
if (v.MultiAttachEnabled) row.Note = "Multi-Attach: check per-instance load before changing";
const peak = await peakUsage(cw, row.Volume, start, end);
if (peak.points < days * 1440 * 0.5) row.Note ||= `only ${peak.points} minutes of data: attached recently?`;
row.PeakIops = Math.ceil(Math.max(peak.ops, peak.consumed));
row.PeakMiBps = Math.round(peak.mibps * 10) / 10;
row.Suggested = Math.max(100, Math.ceil((row.PeakIops * (1 + headroom)) / 100) * 100);
if (row.Suggested >= provisioned) {
row.Suggested = provisioned;
row.IopsSaving = "$0.00";
} else {
const now = iopsCost(type, provisioned);
const after = iopsCost(type, row.Suggested);
row.IopsSaving = money(now === undefined || after === undefined ? undefined : now - after);
}
// gp3 alternative: storage + IOPS above 3,000 + throughput above 125 MiB/s, against today's io1/io2 bill.
const gp3Iops = Math.max(3000, row.Suggested);
const gp3Mibps = Math.max(125, Math.ceil(row.PeakMiBps * (1 + headroom)));
const current = iopsCost(type, provisioned);
if (gp3Iops <= GP3_MAX_IOPS && gp3Mibps <= GP3_MAX_MIBPS && current !== undefined) {
const gp3 = gib * PRICE.gp3Gb + (gp3Iops - 3000) * PRICE.gp3Iops + (gp3Mibps - 125) * PRICE.gp3Mibps;
row.Gp3Saving = money(gib * PRICE.pioGb + current - gp3);
} else {
row.Gp3Saving = "n/a";
}
}
return rows;
}
function toCsv(rows: Row[]): string {
const cols = Object.keys(rows[0] ?? {}) as (keyof Row)[];
const cell = (v: string | number) => `"${String(v).replace(/"/g, '""')}"`;
return [cols.join(","), ...rows.map((r) => cols.map((c) => cell(r[c])).join(","))].join("\n") + "\n";
}
async function main(): Promise<void> {
const rows: Row[] = [];
for (const region of regions) rows.push(...(await scanRegion(region)));
rows.sort((a, b) => Number(b.IopsSaving.replace(/[^0-9.]/g, "")) - Number(a.IopsSaving.replace(/[^0-9.]/g, "")));
console.table(rows);
const total = rows.reduce((n, r) => n + (Number(r.IopsSaving.replace(/[^0-9.]/g, "")) || 0), 0);
const over = rows.filter((r) => r.Suggested < r.Provisioned).length;
console.log(`${rows.length} io1/io2 volumes, ${over} provisioned above peak + ${headroom * 100}% headroom`);
console.log(`Lowering their IOPS would save about $${total.toFixed(2)} a month (us-east-1 list prices, before gp3)`);
if (csvPath) {
writeFileSync(csvPath, toCsv(rows));
console.log(`Wrote ${rows.length} rows to ${csvPath}`);
}
console.log("Report only: no volume was modified.");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-ec2 @aws-sdk/client-cloudwatch
npm install --save-dev tsx typescript @types/node
# Two Regions, default 30% headroom, with a CSV for the database owners
AWS_PROFILE=readonly npx tsx find-overprovisioned-ebs-iops.ts --regions us-east-1,eu-west-1 --csv io-volumes.csv
# Spiky workload: keep 60% headroom
AWS_PROFILE=readonly npx tsx find-overprovisioned-ebs-iops.ts --headroom 0.6
Sample output
┌─────────┬─────────────┬─────────────────────────┬───────┬──────┬─────────────┬──────────┬───────────┬───────────┬────────────┬────────────┬──────────────────────────────────────────────────────────────┐
│ (index) │ Region │ Volume │ Type │ GiB │ Provisioned │ PeakIops │ PeakMiBps │ Suggested │ IopsSaving │ Gp3Saving │ Note │
├─────────┼─────────────┼─────────────────────────┼───────┼──────┼─────────────┼──────────┼───────────┼───────────┼────────────┼────────────┼──────────────────────────────────────────────────────────────┤
│ 0 │ 'us-east-1' │ 'vol-0a1b2c3d4e5f60718' │ 'io1' │ 500 │ 20000 │ 4100 │ 180.2 │ 5400 │ '$949.00' │ '$1303.90' │ '' │
│ 1 │ 'us-east-1' │ 'vol-0b2c3d4e5f6071829' │ 'io2' │ 2000 │ 40000 │ 21800 │ 610.4 │ 28400 │ '$602.00' │ '$2370.86' │ '' │
│ 2 │ 'us-east-1' │ 'vol-0c3d4e5f607182930' │ 'io1' │ 100 │ 3000 │ 2650 │ 48.3 │ 3000 │ '$0.00' │ '$199.50' │ '' │
│ 3 │ 'eu-west-1' │ 'vol-0d4e5f60718293a41' │ 'io2' │ 64 │ 3200 │ 0 │ 0 │ 3200 │ '' │ '' │ 'available: no metrics while detached; see the unattached-volume check' │
└─────────┴─────────────┴─────────────────────────┴───────┴──────┴─────────────┴──────────┴───────────┴───────────┴────────────┴────────────┴──────────────────────────────────────────────────────────────┘
4 io1/io2 volumes, 2 provisioned above peak + 30% headroom
Lowering their IOPS would save about $1551.00 a month (us-east-1 list prices, before gp3)
Report only: no volume was modified.
IDs are illustrative. The io2 volume could drop to 28,400 IOPS for $602.00 a month, and gp3 would save far more, but only if the workload can live without io2‘s higher durability and sub-millisecond latency. The detached volume belongs in the script to find and tag unattached EBS volumes.
Lower the IOPS or move to gp3?
Lowering provisioned IOPS keeps everything else the same and is the safe first step. Moving to gp3 is the bigger saving: it includes 3,000 IOPS and 125 MiB/s, supports up to 80,000 IOPS, and costs $0.08 per GB-month instead of $0.125. What you give up is io2‘s 99.999% durability, its latency profile and Multi-Attach, which gp3 doesn’t support. For a database that was put on io1 “to be safe”, gp3 is usually the answer.
Both changes are online Elastic Volumes modifications. Apply them in a maintenance window anyway, watch VolumeIOPSExceededCheck afterwards, and change one database at a time. The FinOps Foundation calls this practice usage optimization, including rightsizing to actual usage. The same logic works elsewhere: find overprovisioned DynamoDB read and write capacity, find overprovisioned Lambda memory settings and detect underutilized EC2 instances by CPU. For shared file storage, the script to find EFS file systems without a lifecycle policy applies the same idea to data that has gone cold.
Troubleshooting
PeakIopsis 0 for an attached volume. The volume had no I/O in the window. On older Xen-based instances, EBS only reports data when there is activity, so idle minutes are missing rather than zero. Check the volume’s Monitoring tab.- “only N minutes of data”. The volume was attached recently or was detached for part of the window. Re-run after it has 14 days of history.
AccessDeniedonGetMetricData. The profile lackscloudwatch:GetMetricData; the steps to fix AWS IAM access denied errors decode the message.- The suggestion looks too low for a database. Check the instance’s own EBS limits and whether the busiest period, such as month-end jobs, falls inside the window. Raise
--headroom, or run the report again right after the busy period, since--daysis capped at 15.
Ask ChatWithCloud instead
For a quick look, ask ChatWithCloud “Which io1 or io2 volumes in us-east-1 have provisioned IOPS far above their peak usage in the last 14 days?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and explains the numbers; see how ChatWithCloud turns a question into AWS SDK calls. It uses one profile and one Region per session, and runs generated code without asking first, so connect ChatWithCloud to a read-only AWS profile. To see where storage sits in the total bill, ask AI why your AWS bill increased.
Frequently asked questions
Can I reduce provisioned IOPS on an io1 or io2 volume without downtime?
Yes. Changing IOPS is an Elastic Volumes modification on current-generation instances; the volume stays attached and in use while it optimizes.
What is the minimum IOPS for io1?
100 IOPS. The maximum is 50 IOPS per GiB, up to 64,000; io2 allows up to 1,000 IOPS per GiB and 256,000 IOPS on Nitro instances.
How do I calculate EBS IOPS from CloudWatch?
Take the Sum of VolumeReadOps plus VolumeWriteOps for a period and divide by the period in seconds. With 1-minute data, divide by 60.
Is gp3 cheaper than io1 for the same IOPS?
Usually by a wide margin at us-east-1 list prices, because storage is cheaper, 3,000 IOPS are included and extra IOPS cost $0.005 instead of $0.065. Add the throughput charge if you need more than 125 MiB/s. It isn’t a like-for-like replacement for io2 durability or Multi-Attach.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud