Find gp3 Volumes With Unused Provisioned IOPS and Throughput

Close-up of a solid state drive circuit board with memory chips

Photo by Samsung Memory on Unsplash

gp3 provisioned throughput cost is $0.04 per MiB/s-month above the included 125 MiB/s, and extra IOPS cost $0.005 per IOPS-month above 3,000 (us-east-1, September 2026). To find waste, list gp3 volumes with Iops above 3,000 or Throughput above 125, compare them with CloudWatch VolumeAvgIOPS and VolumeAvgThroughput peaks, then lower them with ModifyVolume.

gp3 lets you buy performance separately from size, which is great until someone sets a database volume to 16,000 IOPS and 1,000 MiB/s for a load test and never sets it back. The extra performance is billed every month whether the volume uses it or not. This example is for engineers and FinOps reviewers who want to see which gp3 volumes carry extra IOPS and throughput, what the gp3 provisioned throughput cost adds up to, and how far each volume can safely come down.

The script reports by default. It only changes volumes you name with --apply --volume-ids, because lowering performance under a busy database is a decision for its owner.

What does gp3 provisioned IOPS and throughput cost?

Every gp3 volume includes 3,000 IOPS and 125 MiB/s in the storage price, at any size. You pay separately for anything you provision above that. As of September 2026, the AWS Price List for Amazon EC2 in US East (N. Virginia), published 25 September 2026, gives these rates:

gp3 charge (us-east-1) Price Included free
Storage $0.08 per GB-month –
Provisioned IOPS $0.005 per IOPS-month 3,000 IOPS
Provisioned throughput $0.04 per MiB/s-month 125 MiB/s

Worked example: a 500 GiB volume set to 16,000 IOPS and 1,000 MiB/s pays (16,000 − 3,000) × $0.005 = $65.00 for IOPS and (1,000 − 125) × $0.04 = $35.00 for throughput. That’s $100.00 a month on top of $40.00 of storage, and $1,200 a year. If its busiest minute in two weeks was 2,100 IOPS and 90 MiB/s, the included baseline covers it and the whole $100.00 is waste.

The limits matter when you pick new values. The EBS User Guide lists up to 80,000 IOPS at 500 IOPS per GiB, and up to 2,000 MiB/s at 0.25 MiB/s per provisioned IOPS. So 3,000 IOPS supports up to 750 MiB/s; above that you need more IOPS, and the script raises IOPS when a throughput target requires it. Other volume types have their own reports: finding overprovisioned io1 and io2 IOPS covers Provisioned IOPS SSD, and converting EBS gp2 volumes to gp3 covers the move that usually comes first.

How does the script measure what a gp3 volume uses?

For volumes attached to Nitro-based instances, EBS publishes VolumeAvgIOPS (operations per second) and VolumeAvgThroughput (KiB/s), each the average for one minute. The script asks CloudWatch for the hourly Maximum of those one-minute values over 14 days, so it gets the busiest minute of every hour in 336 datapoints per metric rather than 20,160.

It also reads VolumeIOPSExceededCheck and VolumeThroughputExceededCheck. Each reports 1 when the application tried to drive more than the provisioned IOPS or throughput in a minute. If either was ever 1, the volume is already at its limit, so the script keeps that setting instead of cutting it.

Two caveats. A one-minute average hides sub-minute bursts, which is why a 1.3 headroom factor is applied (change it with --headroom). And these metrics are only published for attached volumes on Nitro instances, so older Xen-based instances show up as “no data”.

What does the script do?

  1. Lists gp3 volumes above the baselinepaginateDescribeVolumes with a volume-type filter of gp3, keeping volumes with more than 3,000 IOPS or 125 MiB/s.
  2. Reads peaksFour GetMetricData queries per volume, 500 per call, for the average IOPS, average throughput and the two exceeded checks.
  3. Suggests new valuesPeak × headroom, rounded up, never below 3,000 IOPS and 125 MiB/s, never above today’s settings, with IOPS high enough for the throughput ratio.
  4. Prices the differenceExtra cost now minus extra cost at the suggested values, sorted by saving.
  5. Applies on requestWith --apply --volume-ids, calls ModifyVolume for those volumes only, skipping any with a modification still in progress.

Prerequisites

  • Node.js 18 or later with tsx, @aws-sdk/client-ec2 and @aws-sdk/client-cloudwatch.
  • A read-only profile for the report; the guide to connecting AWS profiles, SSO and assumed roles covers setup. Use a separate role for --apply.
  • Volumes that have been attached for at least a week, so the peaks include a normal business cycle.

Which IAM permissions does it need?

gp3-performance-report-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadVolumesAndMetrics",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeVolumes",
        "ec2:DescribeVolumesModifications",
        "cloudwatch:GetMetricData"
      ],
      "Resource": "*"
    },
    {
      "Sid": "LowerGp3PerformanceOnlyWithApply",
      "Effect": "Allow",
      "Action": "ec2:ModifyVolume",
      "Resource": "arn:aws:ec2:*:123456789012:volume/*"
    }
  ]
}

Drop the second statement for report-only use. The Describe actions don’t support resource-level permissions, so the read statement uses *. To check a policy against your own changes, paste the script into the IAM policy generator for TypeScript code.

The script to find gp3 volumes with unused IOPS and throughput

find-gp3-extra-performance.ts

// find-gp3-extra-performance.ts
// Finds gp3 volumes provisioned above the included 3,000 IOPS / 125 MiB/s, compares that with the
// peak IOPS and throughput CloudWatch saw over the last 14 days, and prices what you could stop paying.
// Report only by default. With --apply --volume-ids it lowers IOPS and throughput on the listed volumes.
// Usage: npx tsx find-gp3-extra-performance.ts [--regions us-east-1,eu-west-1] [--days 14] [--headroom 1.3]
//        npx tsx find-gp3-extra-performance.ts --regions us-east-1 --apply --volume-ids vol-0abc,vol-0def
import {
  EC2Client,
  paginateDescribeVolumes,
  DescribeVolumesModificationsCommand,
  ModifyVolumeCommand,
  type Volume,
} from "@aws-sdk/client-ec2";
import { CloudWatchClient, paginateGetMetricData, type MetricDataQuery } from "@aws-sdk/client-cloudwatch";

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(Math.max(Number(flag("--days") ?? 14), 3), 30);
const headroom = Math.max(Number(flag("--headroom") ?? 1.3), 1);
const apply = args.includes("--apply");
const applyIds = new Set((flag("--volume-ids") ?? "").split(",").map((v) => v.trim()).filter(Boolean));

// gp3 includes 3,000 IOPS and 125 MiB/s. us-east-1 prices from the AWS Price List (published 25 September 2026).
const BASE_IOPS = 3000;
const BASE_MIBPS = 125;
const PRICE_IOPS = 0.005; // per provisioned IOPS-month above 3,000
const PRICE_MIBPS = 0.04; // per provisioned MiB/s-month above 125
const MIBPS_PER_IOPS = 0.25; // gp3 throughput can be at most 0.25 MiB/s per provisioned IOPS

interface Plan {
  Volume: string;
  Attached: string;
  Iops: number;
  PeakIops: number | string;
  NewIops: number | string;
  MiBps: number;
  PeakMiBps: number | string;
  NewMiBps: number | string;
  ExtraPerMonth: string;
  SavePerMonth: string;
  Note: string;
  save: number;
  target?: { iops: number; mibps: number };
}

interface Peaks {
  iops?: number;
  kibps?: number;
  iopsExceeded: number;
  tputExceeded: number;
}

const errorText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));
const extraCost = (iops: number, mibps: number): number =>
  Math.max(iops - BASE_IOPS, 0) * PRICE_IOPS + Math.max(mibps - BASE_MIBPS, 0) * PRICE_MIBPS;

/** Hourly maximum of the per-minute EBS averages, reduced to one peak per volume. */
async function readPeaks(cw: CloudWatchClient, ids: string[]): Promise<Map<string, Peaks>> {
  const end = new Date();
  const start = new Date(end.getTime() - days * 86_400_000);
  const metrics = ["VolumeAvgIOPS", "VolumeAvgThroughput", "VolumeIOPSExceededCheck", "VolumeThroughputExceededCheck"];
  const queries: MetricDataQuery[] = ids.flatMap((id, v) =>
    metrics.map((name, m) => ({
      Id: `q${v}_${m}`,
      MetricStat: {
        Metric: { Namespace: "AWS/EBS", MetricName: name, Dimensions: [{ Name: "VolumeId", Value: id }] },
        Period: 3600,
        Stat: "Maximum",
      },
    })),
  );
  const out = new Map<string, Peaks>(ids.map((id) => [id, { iopsExceeded: 0, tputExceeded: 0 }]));
  for (let i = 0; i < queries.length; i += 500) {
    const pages = paginateGetMetricData({ client: cw }, { MetricDataQueries: queries.slice(i, i + 500), StartTime: start, EndTime: end });
    for await (const page of pages) {
      for (const r of page.MetricDataResults ?? []) {
        const values = r.Values ?? [];
        const match = /^q(\d+)_(\d)$/.exec(r.Id ?? "");
        if (!match || values.length === 0) continue;
        const peaks = out.get(ids[Number(match[1])]);
        if (!peaks) continue;
        const max = Math.max(...values);
        const which = Number(match[2]);
        if (which === 0) peaks.iops = Math.max(peaks.iops ?? 0, max);
        if (which === 1) peaks.kibps = Math.max(peaks.kibps ?? 0, max);
        if (which === 2) peaks.iopsExceeded = Math.max(peaks.iopsExceeded, max);
        if (which === 3) peaks.tputExceeded = Math.max(peaks.tputExceeded, max);
      }
    }
  }
  return out;
}

function plan(vol: Volume, peaks: Peaks | undefined): Plan {
  const iops = vol.Iops ?? BASE_IOPS;
  const mibps = vol.Throughput ?? BASE_MIBPS;
  const attached = vol.Attachments?.[0]?.InstanceId ?? "(unattached)";
  const row: Plan = {
    Volume: vol.VolumeId ?? "",
    Attached: attached,
    Iops: iops,
    PeakIops: "-",
    NewIops: "-",
    MiBps: mibps,
    PeakMiBps: "-",
    NewMiBps: "-",
    ExtraPerMonth: `$${extraCost(iops, mibps).toFixed(2)}`,
    SavePerMonth: "$0.00",
    Note: "",
    save: 0,
  };
  if (!vol.Attachments?.length) {
    row.Note = "unattached: no metrics, extra performance is billed anyway";
    return row;
  }
  if (!peaks || peaks.iops === undefined || peaks.kibps === undefined) {
    row.Note = "no VolumeAvgIOPS data (not a Nitro instance, or newly attached)";
    return row;
  }
  const peakMibps = peaks.kibps / 1024;
  row.PeakIops = Math.round(peaks.iops);
  row.PeakMiBps = Math.round(peakMibps);
  // Size to the peak plus headroom, never below the included baseline, never above today's setting.
  let newMibps = Math.min(Math.max(Math.ceil(peakMibps * headroom), BASE_MIBPS), mibps);
  let newIops = Math.min(Math.max(Math.ceil((peaks.iops * headroom) / 100) * 100, BASE_IOPS), iops);
  newIops = Math.max(newIops, Math.ceil(newMibps / MIBPS_PER_IOPS)); // keep throughput within 0.25 MiB/s per IOPS
  const notes: string[] = [];
  if (peaks.iopsExceeded > 0 && newIops < iops) {
    notes.push("IOPS limit was hit: keeping IOPS");
    newIops = iops;
  }
  if (peaks.tputExceeded > 0 && newMibps < mibps) {
    notes.push("throughput limit was hit: keeping throughput");
    newMibps = mibps;
  }
  row.NewIops = newIops;
  row.NewMiBps = newMibps;
  row.save = extraCost(iops, mibps) - extraCost(newIops, newMibps);
  row.SavePerMonth = `$${row.save.toFixed(2)}`;
  if (row.save > 0) row.target = { iops: newIops, mibps: newMibps };
  else if (notes.length === 0) notes.push("peak plus headroom needs what is provisioned");
  row.Note = notes.join("; ");
  return row;
}

async function applyPlan(ec2: EC2Client, rows: Plan[]): Promise<void> {
  const chosen = rows.filter((r) => applyIds.has(r.Volume) && r.target);
  if (chosen.length === 0) {
    console.log("Nothing to apply: none of --volume-ids has a saving in this region.");
    return;
  }
  const mods = await ec2.send(new DescribeVolumesModificationsCommand({ VolumeIds: chosen.map((r) => r.Volume) })).catch(() => undefined);
  const busy = new Set(
    (mods?.VolumesModifications ?? [])
      .filter((m) => m.ModificationState === "modifying" || m.ModificationState === "optimizing")
      .map((m) => m.VolumeId),
  );
  for (const r of chosen) {
    if (busy.has(r.Volume)) {
      console.log(`${r.Volume}: skipped, a previous modification hasn't completed yet`);
      continue;
    }
    try {
      const res = await ec2.send(new ModifyVolumeCommand({ VolumeId: r.Volume, Iops: r.target?.iops, Throughput: r.target?.mibps }));
      console.log(`${r.Volume}: ${res.VolumeModification?.ModificationState} -> ${r.target?.iops} IOPS, ${r.target?.mibps} MiB/s`);
    } catch (err) {
      console.error(`${r.Volume}: ${errorText(err)}`);
    }
  }
}

async function scanRegion(region: string): Promise<void> {
  const ec2 = new EC2Client({ region });
  const vols: Volume[] = [];
  for await (const page of paginateDescribeVolumes({ client: ec2 }, { Filters: [{ Name: "volume-type", Values: ["gp3"] }] })) {
    for (const v of page.Volumes ?? []) {
      if ((v.Iops ?? 0) > BASE_IOPS || (v.Throughput ?? 0) > BASE_MIBPS) vols.push(v);
    }
  }
  if (vols.length === 0) {
    console.log(`${region}: no gp3 volumes above 3,000 IOPS or 125 MiB/s`);
    return;
  }
  const peaks = await readPeaks(new CloudWatchClient({ region }), vols.map((v) => v.VolumeId ?? ""));
  const rows = vols.map((v) => plan(v, peaks.get(v.VolumeId ?? ""))).sort((a, b) => b.save - a.save);
  console.log(`\n${region}: gp3 volumes with extra IOPS or throughput, peaks over ${days} days, headroom x${headroom}`);
  console.table(rows.map(({ save: _save, target: _target, ...visible }) => visible));
  const extra = vols.reduce((sum, v) => sum + extraCost(v.Iops ?? BASE_IOPS, v.Throughput ?? BASE_MIBPS), 0);
  const saving = rows.reduce((sum, r) => sum + r.save, 0);
  console.log(`Extra gp3 performance: $${extra.toFixed(2)} a month. Lowering it to the suggested values saves $${saving.toFixed(2)}.`);
  if (apply) await applyPlan(ec2, rows);
}

async function main(): Promise<void> {
  if (apply && applyIds.size === 0) {
    console.error("--apply needs --volume-ids vol-...,vol-... (the script never changes volumes you didn't name)");
    process.exit(2);
  }
  for (const region of regions) {
    try {
      await scanRegion(region);
    } catch (err) {
      console.error(`${region}: ${errorText(err)}`);
    }
  }
}

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

Both paginators follow continuation tokens for you; the guide to AWS SDK v3 paginators explains the pattern.

How do you run it?

Terminal

npm install @aws-sdk/client-ec2 @aws-sdk/client-cloudwatch
npm install --save-dev tsx typescript @types/node

# Report only
AWS_PROFILE=readonly npx tsx find-gp3-extra-performance.ts --regions us-east-1,eu-west-1

# Lower two volumes after their owners agree
AWS_PROFILE=storage-admin npx tsx find-gp3-extra-performance.ts --regions us-east-1 --apply --volume-ids vol-0a1orders,vol-0c3build

Sample output

Output


us-east-1: gp3 volumes with extra IOPS or throughput, peaks over 14 days, headroom x1.3
┌─────────┬─────────────────┬────────────────┬───────┬──────────┬─────────┬───────┬───────────┬──────────┬───────────────┬──────────────┬───────────────────────────────────────────────────────────────────┐
│ (index) │ Volume          │ Attached       │ Iops  │ PeakIops │ NewIops │ MiBps │ PeakMiBps │ NewMiBps │ ExtraPerMonth │ SavePerMonth │ Note                                                              │
├─────────┼─────────────────┼────────────────┼───────┼──────────┼─────────┼───────┼───────────┼──────────┼───────────────┼──────────────┼───────────────────────────────────────────────────────────────────┤
│ 0       │ 'vol-0a1orders' │ 'i-0db1'       │ 16000 │ 2100     │ 3000    │ 1000  │ 90        │ 125      │ '$100.00'     │ '$100.00'    │ ''                                                                │
│ 1       │ 'vol-0c3build'  │ 'i-0ci1'       │ 6000  │ 2400     │ 3200    │ 250   │ 146       │ 191      │ '$20.00'      │ '$16.36'     │ ''                                                                │
│ 2       │ 'vol-0b2kafka'  │ 'i-0kf1'       │ 12000 │ 11800    │ 12000   │ 500   │ 469       │ 500      │ '$60.00'      │ '$0.00'      │ 'peak plus headroom needs what is provisioned'                    │
│ 3       │ 'vol-0d4old'    │ '(unattached)' │ 5000  │ '-'      │ '-'     │ 125   │ '-'       │ '-'      │ '$10.00'      │ '$0.00'      │ 'unattached: no metrics, extra performance is billed anyway'      │
│ 4       │ 'vol-0f6xen'    │ 'i-0xen'       │ 4000  │ '-'      │ '-'     │ 125   │ '-'       │ '-'      │ '$5.00'       │ '$0.00'      │ 'no VolumeAvgIOPS data (not a Nitro instance, or newly attached)' │
└─────────┴─────────────────┴────────────────┴───────┴──────────┴─────────┴───────┴───────────┴──────────┴───────────────┴──────────────┴───────────────────────────────────────────────────────────────────┘
Extra gp3 performance: $195.00 a month. Lowering it to the suggested values saves $116.36.

This run used mocked EC2 and CloudWatch responses. vol-0a1orders is the worked example: its peaks fit inside the free baseline, so it drops to 3,000 IOPS and 125 MiB/s and saves $100.00. vol-0c3build keeps a little extra (3,200 IOPS, 191 MiB/s) for its 2,400 IOPS and 146 MiB/s peaks. vol-0b2kafka runs close to its settings and hit its IOPS limit, so nothing changes. vol-0d4old is unattached and still pays $10.00 a month for IOPS; the script to find and tag unattached EBS volumes is the better next step for it.

What happens when you lower gp3 performance?

  • It’s online. Elastic Volumes can increase or decrease gp3 IOPS and throughput without detaching the volume or stopping the instance.
  • One change at a time. The ModifyVolume API reference says you must wait for a modification to reach completed before starting another on the same volume, and you can modify a volume up to four times in a rolling 24-hour period. The script checks DescribeVolumesModifications and skips volumes still modifying or optimizing.
  • Watch the first busy day. After the change, alarm on VolumeIOPSExceededCheck and VolumeThroughputExceededCheck. If they start reporting 1, raise the setting again; with four changes a day you can react quickly.
  • Check the instance limit too. Every instance type has its own EBS bandwidth ceiling. If InstanceEBSThroughputExceededCheck is 1, extra volume throughput was never usable anyway.

This is the rightsizing loop the FinOps Foundation describes under workload optimization: measure utilization, shrink what stays low, and watch for impact. Compute Optimizer’s EBS findings are a second opinion; the example to get EC2 rightsizing recommendations from Compute Optimizer shows how to read them from code. The instances behind those volumes can carry their own quiet charge; the script to find T3 and other burstable instances with unlimited mode charges covers surplus CPU credits.

Troubleshooting

  • “no VolumeAvgIOPS data”. The volume is attached to a Xen-based instance, sits in an Outpost or Local Zone where these metrics aren’t available, or was attached recently. For Xen instances, size from VolumeReadOps and VolumeWriteOps as the io1 and io2 report does.
  • The suggested IOPS is above the peak. That’s the 0.25 MiB/s-per-IOPS rule: a throughput target above 750 MiB/s needs more than 3,000 IOPS.
  • ModifyVolume fails with a rate or state error. A previous change hasn’t completed, or the volume already had four modifications in 24 hours. Wait and rerun with the same IDs.
  • Access denied. Compare the role with the policy above, then work through troubleshooting AWS IAM access denied errors.

Ask ChatWithCloud instead

For a quick look, ask ChatWithCloud “Which gp3 volumes have more than 3,000 IOPS or 125 MiB/s provisioned, and what do those extras cost a month?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and summarizes the result; how ChatWithCloud runs AWS queries on your machine explains the loop. It can be wrong and runs changes without a confirmation step, so use a read-only profile and make volume changes yourself. If the bill is the starting point, asking AI why your AWS bill increased shows how to trace an EBS jump first. More reports live in the AWS practical examples library.

Frequently asked questions

How much does gp3 provisioned throughput cost?

In us-east-1, $0.04 per MiB/s-month for throughput above the included 125 MiB/s, as of September 2026. Extra IOPS above 3,000 cost $0.005 per IOPS-month.

Can I lower gp3 IOPS and throughput without downtime?

Yes. ModifyVolume changes them while the volume stays attached. Wait for the modification to complete before changing the same volume again.

What is the gp3 baseline performance?

3,000 IOPS and 125 MiB/s at any size, included in the storage price. gp3 doesn’t burst; it sustains what you provision.

How do I see actual IOPS on an EBS volume?

On Nitro instances, read VolumeAvgIOPS and VolumeAvgThroughput in CloudWatch. Otherwise divide the sum of VolumeReadOps and VolumeWriteOps for a period by its length in seconds.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud