EBS gp2 to gp3 migration is the conversion of Amazon EBS General Purpose SSD volumes from the gp2 type to gp3, executed online through the ModifyVolume API. gp3 has been generally available since December 2020 and lists in us-east-1 at the time of writing at approximately $0.08 per GB-month versus gp2's approximately $0.10 per GB-month — on the order of a 20% reduction in base storage cost, with 3,000 IOPS and 125 MiB/s of throughput included as a free baseline. Most mid-market teams we work with know the migration is worth doing. They have not done it yet because the rollout is fiddly: a few volumes need provisioned IOPS that gp2 was burst-covering invisibly, and a careless bulk-modify can produce performance regressions on databases that were never instrumented for IOPS consumption.
This piece walks the migration math, the volumes you should not migrate, and an automation pattern that works for fleets of 100 to 1,000 volumes. Verify current list pricing directly with the AWS EBS pricing page before sizing a business case; AWS no longer publishes static $/GB rates in marketing copy and regional variation matters.
Why this is still on the backlog
We see the same picture across a lot of AWS estates at mid-market scale (50-500 person companies): EBS is the third or fourth-largest line item on the bill, somewhere between 8% and 18% of total AWS spend, and the gp2-to-gp3 conversion has been on the engineering backlog for two years. The reasons are consistent.
First, the savings per volume are unspectacular when you only look at one volume. A 500 GB root volume saves roughly $10/month. Nobody runs a project for $10/month per resource.
Second, the volumes that matter (the data volumes attached to RDS, the EBS-backed Kafka brokers, the high-IOPS Postgres instances on EC2) have just enough operational anxiety attached that the on-call engineer would rather not touch them on a Wednesday afternoon.
Third, the gp2 burst credit model is poorly understood, and the team that knows it works does not want to risk discovering they actually need it after the change.
The aggregate is a real cost: for a mid-market fleet running 200-500 EBS volumes in the multi-TB range, the migration is typically worth $5,000 to $50,000 per month, depending on storage footprint and how many volumes you exclude. That is enough to fund a focused two-week effort with margin to spare.
The pricing math, simply
The per-region us-east-1 list prices, approximate at time of writing (verify directly with the AWS EBS pricing page — prices vary by region and change):
| Component | gp2 | gp3 |
|---|---|---|
| Storage | ~$0.10/GB-month | ~$0.08/GB-month |
| IOPS | Bundled (3 IOPS/GB up to 16,000; burst to 3,000) | 3,000 baseline included; $0.005/provisioned IOPS-month above that, up to 80,000 |
| Throughput | Bundled (scales with size, max 250 MiB/s) | 125 MiB/s baseline included; $0.04/provisioned MiB/s-month above that, up to 2,000 MiB/s |
For the volumes most fleets have most of — general-purpose volumes under 1 TiB, with workloads that do not sustain more than 3,000 IOPS or 125 MiB/s — gp3 is straight-up cheaper at the same performance. The ~20% storage saving applies, no provisioned add-ons needed.
The decision point is what happens above the baseline.
A 1 TiB gp2 volume provides 3,000 baseline IOPS (3 per GB) with the ability to burst higher using credits. Cost: approximately $100/month.
A 1 TiB gp3 volume at the free baseline provides 3,000 IOPS and 125 MiB/s, costs approximately $80/month, and saves roughly $20/month.
A 1 TiB gp2 volume that was actually sustaining 5,000 IOPS via burst credits is the interesting case. On gp3, you would provision the extra 2,000 IOPS at $0.005 each = $10/month. Total: approximately $90/month. Still cheaper than gp2, by about $10/month. Net savings from migration should always be modelled after the provisioned IOPS and throughput add-ons, not against the headline 20% storage figure.
gp3's published ceiling is up to 80,000 IOPS and 2,000 MiB/s throughput per volume in standard regions (the 16,000 IOPS / 1,000 MiB/s number you may see elsewhere is the Outposts-specific limit, not the standard EBS limit; do not anchor a decision boundary on it). Per AWS, see the general purpose SSD volume documentation and EBS volume types. Above 80,000 sustained IOPS or 2,000 MiB/s sustained throughput, gp3 caps out and io2 Block Express enters the conversation; we cover that case at the end.
Most fleets discover that 85-95% of their volumes are well below 5,000 sustained IOPS and well below 125 MiB/s sustained throughput. Those are the volumes the migration is paying for.
The volumes you should not migrate
This is the part missing from most migration write-ups. Three categories deserve a closer look before you ModifyVolume them.
High-burst transactional databases on gp2
The gp2 burst credit model is, in some specific workloads, a feature rather than a bug. A volume with baseline below 3,000 IOPS earns burst credits while idle and spends them during traffic spikes. For a small transactional database that runs at 500 IOPS for 22 hours a day and bursts to 8,000 IOPS for an hour at end-of-day processing, gp2 silently absorbs the burst using accumulated credits at zero marginal cost. Move that volume to gp3 at the free baseline (3,000 IOPS) and the burst becomes a hard ceiling. The end-of-day job either runs slower or you provision IOPS that gp2 was giving you free.
Identify these before migrating. The CloudWatch metric to look at is VolumeReadOps + VolumeWriteOps per minute, divided to per-second, over a 30-day window. You want the P99 and the duration of the bursts. If P50 is well under baseline (suggesting credit accumulation) and P99 spikes high but briefly, that volume is a gp2 burst-model beneficiary. Either keep it on gp2, migrate to gp3 with provisioned IOPS sized to the P95, or take a small performance regression on the burst windows. The right answer is workload-dependent; the wrong answer is migrating blindly.
Sustained high-IOPS workloads above the gp3 ceiling
Above 80,000 IOPS or 2,000 MiB/s sustained, gp3 hits its standard cap. The next tier is io2 (up to 64,000 IOPS on non-Nitro instances) or io2 Block Express (up to 256,000 IOPS, up to 4,000 MiB/s, sub-millisecond latency — though the 256,000 IOPS ceiling is achievable only on Nitro-based EC2 instance types; non-Nitro instances are capped at 32,000 IOPS actually delivered, with up to 64,000 IOPS provisioned). For most mid-market fleets, this is a small handful of volumes — the OLTP primary on a Postgres instance handling tens of thousands of writes per second, the Kafka broker for a high-throughput event bus.
If you are spending substantial dollars on a small number of high-IOPS volumes, io2 Block Express is usually the right destination, not gp3. Pricing is higher per GB and per IOPS, but the performance ceiling and latency profile match the workload. Do not lump these in with the broader migration; treat them as a separate review.
Boot volumes for instances scheduled for retirement
If an EC2 instance is on the deprecation list (older generation, replatform planned, EOL OS), the gp2 root volume on it is not worth the change-management cost of modifying. Wait for the instance to be replaced and provision the new root volume as gp3 from the start. This sounds obvious; it is also the source of about a third of "wasted" migration effort we see in our engagement reviews.
The actual migration
The mechanic is ModifyVolume. The change is online: no detach, no instance restart, no downtime. AWS rebuilds the volume's storage backend in place while serving I/O at gp2 performance until the conversion completes; gp3 performance becomes available after.
Four things to know before automating:
- Modification frequency limit. Per AWS ModifyVolume documentation, you can modify a single volume up to four times within a rolling 24-hour period. Do not confuse this with the time a single modification takes to complete — those are two different constraints. A 1 TiB gp2-to-gp3 modification can take up to six hours of optimization, depending on size; the rolling-24h modification count limit is separate.
- Modification state visibility. Use
DescribeVolumesModificationsto track progress. States includemodifying,optimizing,completed, andfailed. Completion time depends on volume size: smaller volumes often finish the type change in under an hour, but 1 TiB and larger volumes can take up to six hours to optimize. Plan rollout pacing accordingly. - No revert without consequence. You can modify gp3 back to gp2 with another
ModifyVolumecall, but the rolling 24-hour modification count still applies, and any provisioned IOPS / throughput you set on gp3 will be lost. Plan as if the change is sticky. - API throttling at fleet scale.
ec2:ModifyVolumeis subject to standard EC2 API rate limits. For a 1,000-volume rollout you will hitRequestLimitExceededif you fire calls in a tight loop; back off with exponential retry, parallelize at a single-digit concurrency, and pace across accounts. The script below paces between calls for this reason.
A working migration script that handles the rollout, skips volumes you have tagged for exclusion, respects the rolling modification count, and writes modification state to a CSV for production audit:
`python
"""
EBS gp2 to gp3 migration helper.
Skips volumes tagged migrate-skip=true and volumes already gp3.
Optionally sets provisioned IOPS/throughput from tags for non-default cases.
Writes per-volume modification state to a CSV for production audit.
"""
import boto3
import csv
import time
from datetime import datetime, timezone
from botocore.exceptions import ClientError
REGION = "us-east-1" DRY_RUN = True # flip to False after review BATCH_SIZE = 25 # volumes per pass PAUSE_BETWEEN = 30 # seconds between modifications in a batch MAX_RETRIES = 5 # exponential backoff for RequestLimitExceeded CSV_OUT = "ebs-migration-log.csv"
ec2 = boto3.client("ec2", region_name=REGION)
def list_gp2_volumes(): """Return all in-region gp2 volumes that are not explicitly skipped.""" paginator = ec2.get_paginator("describe_volumes") pages = paginator.paginate(Filters=[{"Name": "volume-type", "Values": ["gp2"]}]) volumes = [] for page in pages: for vol in page["Volumes"]: tags = {t["Key"]: t["Value"] for t in vol.get("Tags", [])} if tags.get("migrate-skip", "").lower() == "true": continue volumes.append({ "VolumeId": vol["VolumeId"], "Size": vol["Size"], "Iops": vol.get("Iops"), "Tags": tags, }) return volumes
def desired_params(vol): """Derive ModifyVolume params from tags; default to gp3 baseline.""" tags = vol["Tags"] params = {"VolumeId": vol["VolumeId"], "VolumeType": "gp3"} if "gp3-iops" in tags: params["Iops"] = int(tags["gp3-iops"]) if "gp3-throughput" in tags: params["Throughput"] = int(tags["gp3-throughput"]) return params
def modify_one(vol, writer): params = desired_params(vol) if DRY_RUN: print(f"DRY_RUN ModifyVolume {params}") writer.writerow([vol["VolumeId"], "dry-run", "", datetime.now(timezone.utc).isoformat()]) return delay = 2 for attempt in range(MAX_RETRIES): try: resp = ec2.modify_volume(**params) mod = resp.get("VolumeModification", {}) state = mod.get("ModificationState", "unknown") print(f"Modified {vol['VolumeId']} -> gp3 (state={state})") writer.writerow([vol["VolumeId"], state, "", datetime.now(timezone.utc).isoformat()]) return except ClientError as exc: code = exc.response.get("Error", {}).get("Code", "") if code in ("RequestLimitExceeded", "Throttling"): print(f"Throttled on {vol['VolumeId']}; backing off {delay}s") time.sleep(delay) delay *= 2 continue print(f"Failed {vol['VolumeId']}: {exc}") writer.writerow([vol["VolumeId"], "failed", str(exc), datetime.now(timezone.utc).isoformat()]) return writer.writerow([vol["VolumeId"], "failed", "max retries exhausted", datetime.now(timezone.utc).isoformat()])
def wait_for_completion(volume_ids, writer, timeout_seconds=21600): """Poll DescribeVolumesModifications until states settle. 6h timeout for large volumes.""" start = time.time() pending = set(volume_ids) while pending and (time.time() - start) < timeout_seconds: try: resp = ec2.describe_volumes_modifications(VolumeIds=list(pending)) except ClientError as exc: print(f"DescribeVolumesModifications error: {exc}") time.sleep(60) continue for mod in resp["VolumesModifications"]: state = mod["ModificationState"] if state in ("completed", "failed"): print(f"{mod['VolumeId']} -> {state}") writer.writerow([mod["VolumeId"], state, mod.get("StatusMessage", ""), datetime.now(timezone.utc).isoformat()]) pending.discard(mod["VolumeId"]) if pending: time.sleep(60) if pending: print(f"Timeout still waiting on: {pending}")
def main(): candidates = list_gp2_volumes() print(f"Found {len(candidates)} gp2 volumes eligible for migration") batch = candidates[:BATCH_SIZE] with open(CSV_OUT, "a", newline="") as fh: writer = csv.writer(fh) writer.writerow(["volume_id", "modification_state", "status_message", "timestamp_utc"]) for vol in batch: modify_one(vol, writer) time.sleep(PAUSE_BETWEEN) if not DRY_RUN: wait_for_completion([v["VolumeId"] for v in batch], writer)
if __name__ == "__main__":
main()
`
A few notes on production use.
The exclusion mechanism is a tag, not a hardcoded list. We tag volumes as migrate-skip=true after the IOPS analysis identifies them as gp2 burst beneficiaries or as scheduled for io2 Block Express. The script reads the tag, never edits it.
The provisioned-IOPS path is also tag-driven. For the volumes that need more than the gp3 baseline, we set gp3-iops and gp3-throughput tags during the analysis pass; the script reads them and provisions accordingly during the modify.
DRY_RUN = True is the default. The first thing this script prints is what it would do. We have not had a single engagement where we did not catch something useful in the dry-run output.
The CSV log is the audit trail. For a 1,000-volume rollout, the change-advisory board wants per-volume evidence of state transitions; the CSV ties volume id to modification state and any failure message. Pipe it into S3 or your observability platform after each batch.
A 100-volume rollout vs a 1,000-volume rollout
The scope of the fleet changes the rollout pattern materially. The math does not change; the operational shape does.
For a 100-volume fleet (typical of a 50-150 person company), the migration is a one-week project for one engineer. Day one is the IOPS analysis — pull 30 days of CloudWatch data for every gp2 volume, sort by P95 IOPS, identify the 5-15 volumes that need a closer look. Day two is the decisions on those volumes (skip, migrate with provisioned IOPS, or move to io2 Block Express). Days three through five are batched migrations of the unambiguous majority, with morning runs of 20-30 volumes per day to stay safely below API throttling and allow afternoon verification. The whole rollout is over by Friday.
For a 1,000-volume fleet (typical of a 300-500 person company with multi-account AWS), the migration is a four to six-week project. The scaling work is not in the modify itself; it is in the analysis, the cross-account orchestration, and the verification. The components that change:
- The IOPS analysis runs against every volume in every account, with results aggregated to a central spreadsheet (or a small DynamoDB table) for review. Pulling 30 days of CloudWatch metrics for 1,000 volumes is a substantial fetch job; we usually script it to run overnight and produce a CSV.
- The exclusion review is a meeting with the database team and the platform team, working through the flagged volumes one by one. This is where the conversion-rate decisions live: in our experience, 85-92% of volumes migrate cleanly, 5-10% need provisioned IOPS, and 1-3% stay on gp2 or move to io2 Block Express.
- The rollout itself runs over multiple weeks at a cadence of 50-100 volumes per business day. This is conservative; the per-volume modification count limit is generous (four in 24 hours), but
ec2:ModifyVolumeis subject to account-level API throttling and you do not want aRequestLimitExceededstorm fighting your retry loop. Pace, parallelize at single-digit concurrency, and back off. - Verification matters more. For a small fleet, you can eyeball CloudWatch on every volume the next morning. For 1,000 volumes, you need an alarm threshold for IOPS exhaustion or throughput throttling, and an automated check that compares post-migration P95 IOPS and throughput to pre-migration baselines.
At both scales, the direct storage saving is on the order of 15-20% on the migrated volumes, net of any provisioned IOPS and throughput surcharges you add back for the ~5-10% of volumes that need them. The avoided-cost benefit — paying for over-provisioned gp2 burst capacity nobody was using — comes on top.
What we have seen in engagements
Across recent FinOps engagements at 50-500 person companies, the EBS migration pattern produces consistent results:
- A B2B SaaS at 250 employees with 380 gp2 volumes totalling 95 TiB: approximately $1,520/month saved on the bulk migration, $0 net for the 18 volumes that needed provisioned IOPS, and a separate decision to move two Kafka brokers to io2 Block Express that did not change the migration math but improved tail latency materially.
- A fintech at 400 employees with 1,150 gp2 volumes across four AWS accounts: approximately $11,800/month saved in steady state, achieved over a six-week rollout coordinated with the database team. The largest single saving was on EC2-hosted Postgres instances where gp2 had been over-provisioned for headroom.
- A media company at 80 employees with 110 gp2 volumes totalling 22 TiB: approximately $400/month saved in a three-day rollout. Small number; ran the migration anyway because the dry-run script took an hour to write and the analysis confirmed there were no IOPS-sensitive workloads to worry about.
The pattern: the saving scales with footprint, the rollout shape scales with fleet count, and the analysis matters more on the long-running stateful workloads than on the bulk of root and data volumes.
For the broader cloud spend picture this sits inside, see our FinOps savings guide and the mid-market FinOps framework. For the Kubernetes-side counterpart — where EBS volumes attached to EKS persistent volume claims also benefit from this migration — see EKS cost optimization for mid-market. If you want help running the analysis or the migration on your fleet, our FinOps services page describes how we approach engagements like this.
FAQ
Q: Can ModifyVolume change volume type online without downtime? Yes. The volume stays attached and continues serving I/O at gp2 performance during the modification; gp3 performance becomes available after the optimization phase completes. Completion time depends on volume size — smaller volumes often complete the type change in under an hour, while 1 TiB and larger volumes can take up to six hours to optimize. Per the AWS ModifyVolume documentation, you can modify the same volume up to four times within any rolling 24-hour period.
Q: Will gp3 default IOPS (3,000) be enough for my workload? For most workloads, yes. The 3,000 IOPS baseline matches the gp2 baseline for any volume up to 1 TiB and exceeds it for smaller volumes. The case where it does not is sustained workload above 3,000 IOPS, where gp2 was using burst credits that masked the demand. Pull the 30-day P95 from CloudWatch before migrating; provision IOPS on gp3 only if the P95 is above 3,000.
Q: What about throughput? The gp3 baseline is 125 MiB/s.
Lower than gp2 throughput for volumes above 250 GiB, which scale to 250 MiB/s on gp2. For most workloads this does not matter; throughput is rarely the bottleneck below 1 TiB. For workloads that read large sequential blocks (analytics, backups, video processing), check the VolumeReadBytes and VolumeWriteBytes CloudWatch metrics and provision throughput above the baseline if needed — it costs approximately $0.04 per MiB/s-month, so an extra 125 MiB/s adds roughly $5/month.
Q: Should we use io2 Block Express instead of gp3 for our databases? For most mid-market databases, no — gp3 with provisioned IOPS is sufficient and meaningfully cheaper. io2 Block Express is the right choice when you need sustained IOPS above gp3's 80,000 ceiling, throughput above 2,000 MiB/s, or sub-millisecond latency for OLTP workloads where tail latency directly affects user experience. Note that io2 Block Express's headline 256,000 IOPS ceiling is delivered only on Nitro-based EC2 instance types; on non-Nitro instances the practical ceiling is lower (32,000 IOPS actually delivered against up to 64,000 provisioned on standard io2). That is a small fraction of the typical mid-market fleet; identify those volumes and treat them as a separate decision.
Q: How do we identify which volumes were using gp2 burst credits?
The CloudWatch metric BurstBalance (gp2 only) tracks remaining burst credit as a percentage. A volume that frequently drops below 100% during the day is consuming burst credit; one that stays at 100% is not. Combine with VolumeReadOps + VolumeWriteOps per-second over a 30-day window to see the burst pattern. Volumes that sustain above their baseline (3 IOPS per GB) during business hours and drop overnight to refill credits are the ones to look at carefully.
Q: What if a volume is attached to an RDS instance, not EC2?
RDS manages its own storage layer. Modifying RDS storage from gp2 to gp3 uses the RDS console or ModifyDBInstance API, not EBS ModifyVolume. The economics are similar but the migration is a separate operation on the RDS path. We typically run the RDS migration as a follow-up project after the EC2 EBS migration is settled.
Q: How does the modification count limit affect a large rollout?
Per AWS, a single volume can be modified up to four times within a rolling 24-hour window. For a one-way gp2-to-gp3 conversion this is rarely the binding constraint — you only need one modification per volume. The constraint that bites at scale is account-level API throttling on ec2:ModifyVolume; for fleets above a few hundred volumes, pace the calls, parallelize at single-digit concurrency, and implement exponential backoff on RequestLimitExceeded. Treat the per-volume modification limit as a safety net for the rare case where you need to adjust provisioned IOPS shortly after the type change.
Q: Do we need to update any monitoring after the migration?
Yes. The BurstBalance metric does not exist on gp3 (there are no burst credits). Any alarms watching BurstBalance should be removed. The replacement signal on gp3 is VolumeIdleTime and the standard IOPS / throughput utilization metrics against the provisioned ceiling. Update dashboards in the same pass.

