Photo by Samsung Memory on Unsplash
To convert gp2 to gp3, call EC2 ModifyVolume with VolumeType: "gp3" on each gp2 volume. The change happens online: the volume stays attached and usable. gp3 storage costs 20% less per GB, but large gp2 volumes deliver more than gp3’s 3,000 IOPS and 125 MiB/s baseline, so the script below sets IOPS and throughput to match each volume and shows the saving before it changes anything.
gp3 has been the cheaper General Purpose SSD volume type for years, yet gp2 volumes linger: old launch templates, AMIs created before gp3, CloudFormation stacks nobody touches. This example is for engineers who want to convert gp2 to gp3 across a region without a performance surprise. You’ll get a TypeScript script for the AWS SDK for JavaScript v3 that plans every conversion, prices it, applies it in small batches only when you pass --apply, and then tracks the modifications until they’re complete.
It’s part of our AWS cost cleanup examples in TypeScript. Start with volumes that are actually in use; for volumes that aren’t attached to anything, the example to find and tag unattached EBS volumes is the better first step, since converting a volume nobody uses only makes it slightly cheaper to keep. A type change doesn’t encrypt a volume either; unencrypted volumes need the snapshot-and-replace steps in the example to find unencrypted EBS volumes and turn on default encryption.
Dry run by default: without --apply the script only reads. With --apply it modifies at most 5 volumes per run (change with --max). A volume modification can’t be cancelled once it’s submitted, and you can’t go back to gp2 until the modification completes.
How much does converting gp2 to gp3 save?
EBS prices in US East (N. Virginia), as of September 2026, from the official Amazon EBS pricing page:
| Charge | gp2 | gp3 |
|---|---|---|
| Storage | $0.10 per GB-month | $0.08 per GB-month |
| IOPS | Included (3 per GiB) | 3,000 included; $0.005 per provisioned IOPS-month above that |
| Throughput | Included (up to 250 MiB/s) | 125 MiB/s included; $0.04 per provisioned MiB/s-month above that |
Worked examples, matching each volume’s gp2 performance on gp3:
- 500 GiB: gp2 gives 1,500 IOPS and 250 MiB/s. gp3 needs 3,000 IOPS (the baseline) and 250 MiB/s. gp2: 500 × $0.10 = $50.00. gp3: 500 × $0.08 + (250 − 125) × $0.04 = $40.00 + $5.00 = $45.00. Saving: $5.00 a month.
- 2,000 GiB: gp2 gives 6,000 IOPS and 250 MiB/s. gp3: 2,000 × $0.08 + (6,000 − 3,000) × $0.005 + 125 × $0.04 = $160.00 + $15.00 + $5.00 = $180.00, against $200.00 on gp2. Saving: $20.00.
- 100 GiB: gp2 gives 300 IOPS (bursting to 3,000) and 128 MiB/s. gp3: $8.00 + 3 × $0.04 = $8.12, against $10.00. Saving: $1.88.
Across a fleet the saving is close to the headline 20%, minus the throughput you choose to keep. If you don’t need 250 MiB/s on a mid-sized volume, leaving it at 125 MiB/s saves another $5 a month per volume.
What IOPS and throughput should gp3 get?
gp2 performance scales with size: 3 IOPS per GiB, with a floor of 100 and a ceiling of 16,000, and throughput of 128 MiB/s up to 170 GiB and 250 MiB/s from 334 GiB (volumes in between can burst to 250). Volumes under 1 TiB can also burst to 3,000 IOPS on credits. gp3 has no bursting: it delivers 3,000 IOPS and 125 MiB/s for any size, and you pay for anything above that.
The script sets gp3 IOPS to the higher of 3,000 and the gp2 baseline, and throughput to the higher of 125 MiB/s and the gp2 figure. That’s the same rule EBS applies when you change the type without specifying performance, but spelling it out means the dry run can show the cost of each choice. Edit plan() if you’d rather size from observed VolumeReadOps and VolumeWriteOps metrics. For Provisioned IOPS volumes, the script to find io1 and io2 volumes paying for unused IOPS does that sizing from CloudWatch.
What does the script do?
- Lists gp2 volumes
paginateDescribeVolumeswith avolume-type = gp2filter, or one volume with--volume. - Plans each changeComputes gp2 performance, the matching gp3 settings and the monthly cost of both, sorted by saving.
- Applies only with
--applyCallsModifyVolumefor up to--maxvolumes, skipping tiny volumes where gp3 would cost more, and logs errors per volume instead of stopping. - Tracks progress with
--statusDescribeVolumesModificationsshows each modification moving throughmodifying,optimizingandcompleted(orfailed).
Which IAM permissions does it need?
The dry run and --status need only the two describe actions. ec2:ModifyVolume is limited to volumes in your account (replace 123456789012).
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PlanAndTrack",
"Effect": "Allow",
"Action": [
"ec2:DescribeVolumes",
"ec2:DescribeVolumesModifications"
],
"Resource": "*"
},
{
"Sid": "ConvertVolumes",
"Effect": "Allow",
"Action": "ec2:ModifyVolume",
"Resource": "arn:aws:ec2:*:123456789012:volume/*"
}
]
}
Give the dry run to a read-only profile and keep the second statement for the profile that applies changes. The checklist to review a generated IAM policy for least privilege suggests adding a tag condition if only some teams’ volumes should be touched.
The full script to convert gp2 to gp3 safely
// convert-gp2-to-gp3.ts
// Plans (default) or applies (--apply) a gp2 -> gp3 change for EBS volumes in one region,
// keeping IOPS and throughput at or above what each gp2 volume delivers today.
// Usage: npx tsx convert-gp2-to-gp3.ts [--apply] [--max 5] [--volume vol-123] | --status
import {
EC2Client,
ModifyVolumeCommand,
paginateDescribeVolumes,
paginateDescribeVolumesModifications,
type Filter,
type Volume,
} from "@aws-sdk/client-ec2";
// USD, us-east-1, as of September 2026. Other regions differ.
const GP2_PER_GB = 0.1;
const GP3_PER_GB = 0.08;
const GP3_PER_IOPS = 0.005; // per provisioned IOPS-month above 3,000
const GP3_PER_MIBPS = 0.04; // per provisioned MiB/s-month above 125
const GP3_BASE_IOPS = 3000;
const GP3_BASE_MIBPS = 125;
const ec2 = new EC2Client({}); // region from AWS_REGION or your profile
function arg(name: string): string | undefined {
const i = process.argv.indexOf(name);
return i === -1 ? undefined : process.argv[i + 1];
}
// What a gp2 volume delivers: 3 IOPS per GiB (min 100, max 16,000);
// 128 MiB/s up to 170 GiB, up to 250 MiB/s above that.
function gp2Performance(sizeGiB: number) {
const iops = Math.min(16_000, Math.max(100, sizeGiB * 3));
const mibps = sizeGiB <= 170 ? 128 : 250;
return { iops, mibps };
}
function plan(v: Volume) {
const size = v.Size ?? 0;
const gp2 = gp2Performance(size);
const iops = Math.max(GP3_BASE_IOPS, gp2.iops);
const mibps = Math.max(GP3_BASE_MIBPS, gp2.mibps);
const gp2Cost = size * GP2_PER_GB;
const gp3Cost = size * GP3_PER_GB + (iops - GP3_BASE_IOPS) * GP3_PER_IOPS + (mibps - GP3_BASE_MIBPS) * GP3_PER_MIBPS;
return { id: v.VolumeId ?? "", size, state: v.State ?? "", gp2, iops, mibps, gp2Cost, gp3Cost };
}
async function showStatus(): Promise<void> {
const filters: Filter[] = [{ Name: "original-volume-type", Values: ["gp2"] }];
const rows = [];
for await (const page of paginateDescribeVolumesModifications({ client: ec2 }, { Filters: filters })) {
for (const m of page.VolumesModifications ?? []) {
if (m.TargetVolumeType !== "gp3") continue;
rows.push({
Volume: m.VolumeId,
State: m.ModificationState,
"Progress %": m.Progress,
IOPS: m.TargetIops,
"MiB/s": m.TargetThroughput,
Started: m.StartTime?.toISOString().slice(0, 16),
});
}
}
if (rows.length === 0) console.log("No gp2 -> gp3 modifications found.");
else console.table(rows);
}
async function main(): Promise<void> {
if (process.argv.includes("--status")) return showStatus();
const apply = process.argv.includes("--apply");
const max = Number(arg("--max") ?? 5);
const only = arg("--volume");
const filters: Filter[] = [{ Name: "volume-type", Values: ["gp2"] }];
if (only) filters.push({ Name: "volume-id", Values: [only] });
const volumes: Volume[] = [];
for await (const page of paginateDescribeVolumes({ client: ec2 }, { Filters: filters })) {
volumes.push(...(page.Volumes ?? []));
}
if (volumes.length === 0) {
console.log("No gp2 volumes in this region.");
return;
}
const plans = volumes.map(plan).sort((a, b) => b.gp2Cost - b.gp3Cost - (a.gp2Cost - a.gp3Cost));
console.table(
plans.map((p) => ({
Volume: p.id,
"Size GiB": p.size,
State: p.state,
"gp2 IOPS": p.gp2.iops,
"gp3 IOPS": p.iops,
"gp3 MiB/s": p.mibps,
"gp2 $/mo": p.gp2Cost.toFixed(2),
"gp3 $/mo": p.gp3Cost.toFixed(2),
"Saves $/mo": (p.gp2Cost - p.gp3Cost).toFixed(2),
})),
);
const saving = plans.reduce((s, p) => s + p.gp2Cost - p.gp3Cost, 0);
console.log(`${plans.length} gp2 volumes; converting all saves about $${saving.toFixed(2)}/month.`);
if (!apply) {
console.log(`Dry run. Re-run with --apply to convert up to ${max} volumes (use --max to change).`);
return;
}
// Tiny volumes can cost more as gp3 (matching 128 MiB/s is billed above 125), so skip those.
for (const p of plans.filter((x) => x.gp3Cost < x.gp2Cost).slice(0, max)) {
try {
const res = await ec2.send(
new ModifyVolumeCommand({
VolumeId: p.id,
VolumeType: "gp3",
// Only send values above the gp3 baseline; the baseline is included in the price.
Iops: p.iops > GP3_BASE_IOPS ? p.iops : undefined,
Throughput: p.mibps > GP3_BASE_MIBPS ? p.mibps : undefined,
}),
);
console.log(`${p.id}: ${res.VolumeModification?.ModificationState ?? "requested"}`);
} catch (err) {
// e.g. IncorrectModificationState when a previous modification hasn't completed
console.error(`${p.id}: ${(err as Error).name}: ${(err as Error).message}`);
}
}
console.log("Track progress with --status. Volumes stay usable while they change.");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-ec2
npm install --save-dev tsx typescript
# 1. Dry run: plan and price every gp2 volume
AWS_PROFILE=readonly AWS_REGION=us-east-1 npx tsx convert-gp2-to-gp3.ts
# 2. Convert one volume first, then check it
AWS_PROFILE=ops AWS_REGION=us-east-1 npx tsx convert-gp2-to-gp3.ts --apply --volume vol-0a1b2c3d4e5f60718
AWS_PROFILE=readonly AWS_REGION=us-east-1 npx tsx convert-gp2-to-gp3.ts --status
# 3. Convert the next 10 biggest savings
AWS_PROFILE=ops AWS_REGION=us-east-1 npx tsx convert-gp2-to-gp3.ts --apply --max 10
Sample output
┌─────────┬─────────────────────────┬──────────┬───────────┬──────────┬──────────┬───────────┬──────────┬──────────┬────────────┐
│ (index) │ Volume │ Size GiB │ State │ gp2 IOPS │ gp3 IOPS │ gp3 MiB/s │ gp2 $/mo │ gp3 $/mo │ Saves $/mo │
├─────────┼─────────────────────────┼──────────┼───────────┼──────────┼──────────┼───────────┼──────────┼──────────┼────────────┤
│ 0 │ 'vol-0fedcba9876543210' │ 2000 │ 'in-use' │ 6000 │ 6000 │ 250 │ '200.00' │ '180.00' │ '20.00' │
│ 1 │ 'vol-0123456789abcdef0' │ 1000 │ 'in-use' │ 3000 │ 3000 │ 250 │ '100.00' │ '85.00' │ '15.00' │
│ 2 │ 'vol-0a1b2c3d4e5f60718' │ 500 │ 'in-use' │ 1500 │ 3000 │ 250 │ '50.00' │ '45.00' │ '5.00' │
│ 3 │ 'vol-0b2c3d4e5f6071829' │ 100 │ 'in-use' │ 300 │ 3000 │ 128 │ '10.00' │ '8.12' │ '1.88' │
│ 4 │ 'vol-0c3d4e5f607182930' │ 8 │ 'in-use' │ 100 │ 3000 │ 128 │ '0.80' │ '0.76' │ '0.04' │
└─────────┴─────────────────────────┴──────────┴───────────┴──────────┴──────────┴───────────┴──────────┴──────────┴────────────┘
5 gp2 volumes; converting all saves about $41.92/month.
Dry run. Re-run with --apply to convert up to 5 volumes (use --max to change).
Volume IDs are illustrative; the figures follow from the prices above. An 8 GiB boot volume saves only a few cents, which is why converting launch templates and AMIs matters more for small volumes than converting them one by one.
What happens while a volume is modifying?
The volume stays attached and in service. It moves from modifying to optimizing to completed, and you’re billed at the new configuration once the modification starts. AWS says a modification can take from a few minutes to a few hours; a fully used 1 TiB volume typically takes about six hours. While it’s optimizing, performance sits between the old and new specifications and is no lower than the gp2 volume delivered. In rare cases a transient fault leaves a modification failed; that says nothing about the volume’s health, and you simply retry.
You must wait for one 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. Older write-ups mention a six-hour wait between modifications; the EBS Elastic Volumes documentation now describes the four-per-24-hours rule, along with the instance-type requirements for modifying attached volumes.
Troubleshooting
IncorrectModificationState. A previous modification on that volume hasn’t completed. Check--statusand try again later.UnauthorizedOperation. The profile has the describe actions but notec2:ModifyVolume, or the account ID in the ARN is wrong. The steps to troubleshoot IAM access denied errors show how to decode the message.- The volume is attached to a previous-generation instance. Elastic Volumes needs a current-generation instance or one of a few older families. The script to find previous-generation EC2 instances lists which of yours are affected. Otherwise detach a data volume, or stop the instance for a root volume, then modify.
- New gp2 volumes keep appearing. Set
VolumeTypetogp3in launch templates, Auto Scaling groups, AMI block device mappings and infrastructure-as-code definitions.
Ask ChatWithCloud instead
For the discovery half, ask ChatWithCloud “How many gp2 volumes do I have, and how big are they?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and summarizes the answer, the same approach as the guide to list AWS resources with natural language from your terminal. Keep the conversion itself in the script: ChatWithCloud runs changes without a confirmation step, so a prompt like “convert them to gp3” would act immediately. Use a read-only AWS profile with ChatWithCloud, and read how ChatWithCloud runs generated SDK code locally for the details.
Frequently asked questions
Can I convert gp2 to gp3 without downtime?
Yes, on supported instance types. ModifyVolume changes the type while the volume stays attached and in use; no detach or reboot is needed.
Is gp3 always cheaper than gp2?
Almost always. Storage is 20% cheaper, and you pay extra only for IOPS above 3,000 and throughput above 125 MiB/s. Very small volumes where you match gp2’s 128 MiB/s can cost a few cents more.
How do I convert gp2 to gp3 with the AWS CLI?
Run aws ec2 modify-volume --volume-id vol-0123456789abcdef0 --volume-type gp3, adding --iops and --throughput when the volume needs more than the gp3 baseline.
Should I snapshot before converting?
A type change doesn’t touch the data, but a recent snapshot is cheap insurance. The example to clean up AMIs and snapshots older than 30 days keeps those snapshots from piling up afterwards.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud