About this article: AI-assisted writing, reviewed against the cited sources. This is source research, not a lab validation of the procedures. Check the stated versions and your own recovery requirements before making changes.
Choose gp3 unless the workload needs something only io2 offers: more than 80,000 IOPS or 2,000 MiB/s on one volume, 99.999% durability, Multi-Attach, or consistently lower latency. Then check the instance. An EC2 instance has its own EBS ceiling, and no volume type can deliver past it.
Most disappointing EBS upgrades come down to that second check. Moving from gp3 to io2 does nothing when the instance is the limit.
Commands below come from the AWS references, except where marked as written for this article. None was run in a lab.
Why it matters
Three separate limits apply to the same stream of I/O: the volume's provisioned IOPS, the volume's provisioned throughput, and the instance's EBS bandwidth and IOPS. AWS states the rule directly: EBS performance is bounded by the instance type's limits or the aggregated performance of the attached volumes, whichever is smaller.
The workload hits whichever limit comes first. That depends on I/O size, so a figure quoted without an I/O size says little.
On AWS's illustrative rates, shown later, io2 can cost several times what gp3 does at the same IOPS. Buying it to fix the wrong limit is an expensive way to learn nothing.
The two volume types side by side
Figures are from the EBS volume types page and the General Purpose and Provisioned IOPS pages.
| Property | gp3 | io2 Block Express |
|---|---|---|
| Size | 1 GiB to 64 TiB | 4 GiB to 64 TiB |
| Maximum IOPS | 80,000 (at 25.6 KiB I/O) | 256,000 (at 16 KiB I/O) |
| Maximum throughput | 2,000 MiB/s | 4,000 MiB/s |
| Included baseline | 3,000 IOPS and 125 MiB/s | None; IOPS are provisioned |
| IOPS to size ratio | 500 IOPS per GiB | 1,000 IOPS per GiB |
| Throughput | Provisioned separately, up to 0.25 MiB/s per provisioned IOPS | Scales at 0.256 MiB/s per provisioned IOPS |
| Maximum IOPS needs | 160 GiB or larger | 256 GiB or larger |
| Maximum throughput needs | 8,000 IOPS or higher and 16 GiB or larger | 16,000 IOPS or higher |
| Latency | Single-digit milliseconds | Sub-millisecond, designed for an average under 500 microseconds at 16 KiB I/O |
| Durability | 99.8% to 99.9% | 99.999% |
| Performance consistency | Provisioned performance 99% of the time | Provisioned IOPS 99.9% of the time |
| Multi-Attach | No | Yes |
Two practical points follow from the table.
gp3 does not burst. AWS says it sustains full provisioned performance indefinitely. That removes the credit behaviour of gp2, which bursts to 3,000 IOPS under 1 TiB and earns credits at 3 per GiB per second.
"io2" now means Block Express. Since 30 April 2025, all new and previously created io2 volumes are io2 Block Express. Older guidance that separates the two no longer applies.
The instance matters to io2's headline number as well. The volume types page says Nitro-based instances reach 256,000 IOPS. Other instance types can attach volumes provisioned up to 64,000 IOPS, but achieve up to 32,000.
How I/O size decides which limit you hit
Throughput equals IOPS multiplied by I/O size. AWS's headline maximums each assume a particular size: 80,000 IOPS × 25.6 KiB is 2,000 MiB/s, and 256,000 IOPS × 16 KiB is 4,000 MiB/s. Those are the sizes required to reach maximum IOPS within the throughput limit.
EBS also splits and merges I/O. On SSD volumes the maximum I/O size counted is 256 KiB:
- A single 1,024 KiB read counts as 4 IOPS.
- Eight sequential 32 KiB operations merge and count as 1.
- Eight random 32 KiB operations count as 8.
A hypothetical example shows the effect. A gp3 volume at the free baseline of 3,000 IOPS and 125 MiB/s handles a workload doing 64 KiB I/O. Reaching 3,000 IOPS would need 187.5 MiB/s, so the 125 MiB/s limit stops it at 2,000 IOPS. Adding IOPS changes nothing here. The fix is more throughput.
To find the real I/O size, divide bytes by operations: VolumeWriteBytes / VolumeWriteOps, and the same for reads. Inside a Linux guest, the iostat columns rareq-sz and wareq-sz give average request size in KiB:
iostat -xmy 1
The flag combination is written for this article, from the man page. Do not read %util as saturation on these devices. The man page says that for devices serving requests in parallel, such as modern SSDs, the figure does not reflect their limits.
The instance limit
The EBS-optimized instances table gives a baseline and a maximum for each size. These rows were read on the check date:
| Instance | Baseline MB/s | Maximum MB/s | Baseline IOPS | Maximum IOPS | Maximum sustained? |
|---|---|---|---|---|---|
| m6i.large | 81.25 | 1,250 | 3,600 | 40,000 | 30 minutes |
| m6i.xlarge | 156.25 | 1,250 | 6,000 | 40,000 | 30 minutes |
| m6i.4xlarge | 625 | 1,250 | 20,000 | 40,000 | 30 minutes |
| m6i.8xlarge | 1,250 | 1,250 | 40,000 | 40,000 | Indefinitely |
| r6i.16xlarge | 2,500 | 2,500 | 80,000 | 80,000 | Indefinitely |
| m7i.48xlarge | 5,000 | 5,000 | 240,000 | 240,000 | Indefinitely |
The last column is the trap. Instances carrying the first footnote can sustain maximum performance for 30 minutes at least once every 24 hours, then revert to baseline. A short test on a fresh instance measures the burst figure. A production batch job running for two hours eventually gets the baseline.
Read the current values for your own types rather than trusting a copied table. This --query was written for this article from the describe-instance-types output fields:
aws ec2 describe-instance-types \
--instance-types m6i.xlarge m6i.4xlarge m6i.8xlarge \
--query 'InstanceTypes[].[InstanceType,
EbsInfo.EbsOptimizedInfo.BaselineThroughputInMBps,
EbsInfo.EbsOptimizedInfo.MaximumThroughputInMBps,
EbsInfo.EbsOptimizedInfo.BaselineIops,
EbsInfo.EbsOptimizedInfo.MaximumIops]' \
--output table
Three cautions apply when reading those numbers.
Units differ. Volume limits are in MiB/s and the instance table is in MB/s. The table's own arithmetic, 10,000 Mbps as 1,250 MB/s, implies decimal megabytes, so 1,250 MB/s is about 1,192 MiB/s. That conversion is an inference, but it matters near a limit.
The table assumes I/O sizes. Its headers state 128 KiB I/O for throughput and 16 KiB I/O for IOPS, and AWS says the limits are interdependent. At 64 KiB I/O, the m6i.8xlarge's 1,250 MB/s runs out at about 19,000 IOPS, well short of its 40,000 IOPS figure.
Limits are shared. The instance limit covers all attached volumes together. AWS's example is that 80,000 IOPS on an r6i.16xlarge takes five gp2 volumes at 16,000 IOPS or one gp3 at 80,000.
Some newer families also support bandwidth weighting. The ebs-1 setting raises EBS baseline bandwidth and reduces networking baseline by the same absolute amount. It changes bandwidth only. The IOPS specification stays as it was.
A worked sizing
This example is hypothetical. A database needs 30,000 IOPS at 16 KiB, sustained for hours.
- Throughput needed: 30,000 × 16 KiB is 468.75 MiB/s, or about 492 MB/s.
- A gp3 volume can supply this. 30,000 IOPS needs at least 60 GiB at 500 IOPS per GiB, and 500 MiB/s of provisioned throughput sits within the 0.25 MiB/s per IOPS ratio.
- An m6i.4xlarge has a 40,000 IOPS maximum, so a short test passes. Its baseline is 20,000 IOPS, so after the burst window the instance holds the workload at 20,000.
- An m6i.8xlarge sustains 40,000 IOPS and 1,250 MB/s, which clears the requirement.
Changing that volume to io2 on the m6i.4xlarge would leave the 20,000 IOPS ceiling exactly where it was.
Proving which limit is capping you
Averages over a minute hide short peaks. AWS calls this micro-bursting: high IOPS or throughput for less than the minute interval can leave the average below the provisioned figure. Use the checks built for this rather than comparing averages with the provisioned figure.
| Signal | Namespace or source | What it proves |
|---|---|---|
VolumeIOPSExceededCheck is 1 |
AWS/EBS |
The application consistently tried to exceed the volume's provisioned IOPS in the last minute |
VolumeThroughputExceededCheck is 1 |
AWS/EBS |
The same, for the volume's provisioned throughput |
InstanceEBSIOPSExceededCheck is 1 |
AWS/EC2 |
The application tried to exceed the instance's maximum EBS IOPS |
InstanceEBSThroughputExceededCheck is 1 |
AWS/EC2 |
The same, for instance throughput |
EBSIOBalance%, EBSByteBalance% low |
AWS/EC2 |
Burst credits are being spent on an instance of 4xlarge or smaller that bursts for 30 minutes |
VolumeStalledIOCheck is 1 |
AWS/EBS |
The volume failed a stalled I/O check in the last minute |
VolumeAvgReadLatency, VolumeAvgWriteLatency |
AWS/EBS |
Average latency in milliseconds |
All are defined on the CloudWatch metrics page. The exceeded checks need a Nitro instance. The instance metrics are not available for bare metal, the volume exceeded checks do not work with Multi-Attach, and these checks are not available in Outposts, Local Zones or Wavelength Zones.
The page describes the instance checks as comparing against the instance's maximum limits. It does not say how they behave after a burst-capable instance has fallen to baseline. For that case, add EBSReadOps and EBSWriteOps, divide the sum by the seconds in the period (60 for detailed monitoring, 300 for basic), and compare the result with the baseline IOPS for the type. These metrics are counts per period, not rates. Look at the balance metrics as well.
This query was written for this article from the get-metric-statistics synopsis:
aws cloudwatch get-metric-statistics \
--namespace AWS/EC2 \
--metric-name InstanceEBSIOPSExceededCheck \
--dimensions Name=InstanceId,Value=i-1234567890abcdef0 \
--start-time 2026-11-10T00:00:00Z \
--end-time 2026-11-10T06:00:00Z \
--period 60 \
--statistics Maximum
For finer resolution on Nitro instances, EBS detailed performance statistics give counters at up to one-second granularity at no extra cost:
sudo ./ebsnvme stats /dev/nvme0n1
nvme amzn stats /dev/nvme0n1
The nvme amzn form is included on Amazon Linux AMIs published after 12 November 2024. On Windows, the page gives .\nvme_amzn.exe stats disk_number, which needs AWSNVMe driver 1.7.0 or later. The useful counters, reported in microseconds, are:
ebs_volume_performance_exceeded_iopsandebs_volume_performance_exceeded_tp: time demand exceeded the volume.ec2_instance_ebs_performance_exceeded_iopsandec2_instance_ebs_performance_exceeded_tp: time the volume exceeded the instance's maximum.
The same output has read and write latency histograms, which show tail behaviour that an average hides. Device names change between boots on Nitro, so map the NVMe device to its volume ID before acting on what you see.
Reading the result:
- A volume check at 1 with the instance check at 0 identifies volume demand exceeding a limit. Raising that limit may help if the instance and workload can use the extra capacity.
- An instance check at 1 means changing the volume will not help. Use a larger instance, or spread the load.
- Neither at 1 with poor latency points elsewhere. AWS notes that a workload not delivering enough I/O requests might not reach the provisioned figures. Also check snapshot initialisation, covered next.
- If latency, not capacity, is the complaint and nothing exceeds its limit, that is the real case for io2. AWS says Block Express reduces I/Os exceeding 800 microseconds by over 10 times compared with General Purpose.
Snapshot initialisation distorts results
A volume created from a snapshot has its blocks downloaded on first access, with a significant increase in latency when each block is first read. Empty volumes need no initialisation.
This catches restores and migrations. A freshly restored volume can look broken for reasons that have nothing to do with its type. On Provisioned IOPS volumes, performance may drop below 50 percent during initialisation with a status warning that AWS says is expected.
Options, all described by AWS:
- Fast snapshot restore, for a fully initialised volume at creation.
- Provisioned Rate for Volume Initialization, from 100 to 300 MiB/s, billed per GiB of full snapshot data.
- A manual read of every block:
sudo fio --filename=/dev/xvdf --rw=read --bs=1M --iodepth=32 --ioengine=libaio --direct=1 --name=volume-initialize
Warning: AWS notes that incorrect use of
ddcan destroy a volume's data. If you useddinstead offio, confirm which device is the input. The device name above is AWS's example, and it will differ on your instance.
Changing a live volume
Elastic Volumes changes type, IOPS and throughput without detaching if your instance supports Elastic Volumes. The limits are firm and easy to forget during an incident:
- Up to four modifications in a rolling 24 hours, and each must reach
completedfirst. - A modification cannot be cancelled once started.
- Size can only grow.
- Completion is best effort. It takes a few minutes to a few hours, typically about six hours for a fully used 1 TiB volume. It can take more than 24 hours in some cases, such as when the volume has not been fully initialised.
- You pay for the new configuration from the start of the modification.
Always state the performance you want. The CLI reference says the --iops default is 3,000 when changing type to gp3, io1 or io2, and --throughput defaults to 125 unless source and target are both gp3. The Elastic Volumes guide says a gp2 to gp3 change without those values uses the gp2 equivalent or the gp3 baseline, whichever is higher. The two pages describe the default differently, so do not rely on either. This form is written for this article from the documented options:
aws ec2 modify-volume \
--volume-id vol-1234567890abcdef0 \
--volume-type io2 \
--iops 30000
aws ec2 describe-volumes-modifications \
--volume-ids vol-1234567890abcdef0
Watch ModificationState move from modifying to optimizing to completed. During optimizing, performance sits between source and target, and is not below the source. If you are reducing IOPS, it is not below the target.
Check the Region quota before a large change. The default for io2 IOPS per Region is 100,000, and it is adjustable. A single maximum-size io2 volume is well above that.
Benchmarking without fooling yourself
Follow AWS's benchmark procedure. It requires initialised volumes and an EBS-optimized instance, and starts from a queue length of about one per 1,000 IOPS. AWS's 16 KiB random read test is:
sudo fio --directory=/mnt/p_iops_vol0 --name fio_test_file --direct=1 --rw=randread --bs=16k --size=1G --numjobs=16 --time_based --runtime=180 --group_reporting --norandommap
Warning: AWS warns that some of these procedures destroy data. Run them only on volumes created for testing, never on production.
At 180 seconds that test stays inside the burst window of a 30-minute instance. To measure what the workload gets long term, run past 30 minutes or compare against the baseline figure. That is an editorial recommendation.
What to choose and why
These are editorial recommendations, except where linked to AWS.
- Default to gp3. It covers most database and application volumes, and IOPS and throughput can be raised independently of size.
- Choose io2 for latency-sensitive workloads, as AWS recommends, once measurement shows latency rather than capacity is the problem.
- Choose io2 when the durability figure is a requirement. The annual failure rate is 0.001%, compared with no higher than 0.2% for gp3.
- Choose io2 for Multi-Attach, which gp3 does not offer.
- Consider a RAID 0 stripe only when one volume cannot reach the figure. Loss of one volume loses the array, and the worst volume sets its performance. AWS does not recommend RAID 5 or 6, or RAID 1, on EBS.
- Size the instance on its baseline when the workload is sustained, and on its maximum only when the bursts are under 30 minutes.
Cost shape
The EBS pricing page's worked examples use illustrative rates, not Sydney prices. Applying them to 2,000 GB with 20,000 IOPS and 500 MB/s gives this arithmetic, per 30-day month in USD:
| Item | gp3 | io2 |
|---|---|---|
| Storage | 2,000 × 0.08 = 160.00 | 2,000 × 0.125 = 250.00 |
| IOPS | 17,000 above baseline × 0.005 = 85.00 | 20,000 × 0.065 = 1,300.00 |
| Throughput | 375 above baseline × 0.06 = 22.50 | Not charged separately on these examples |
| Total | 267.50 | 1,550.00 |
Check current rates for ap-southeast-2 with the AWS pricing calculator before committing. The ratio is what this table shows, not the amounts. io2 IOPS pricing is tiered per volume, so the first 32,000 IOPS cost the most, and ten small volumes do not earn the lower tier.
Risks and limits
- Instance tables change as AWS adds types. Query your own type rather than using the rows above.
- The exceeded checks say demand passed a limit. They do not say by how much.
- Latency averages can hide tails, so use the histograms for latency-driven decisions.
- gp3's 80,000 IOPS and 2,000 MiB/s do not apply on Outposts, where the limits are 16 TiB, 16,000 IOPS and 1,000 MiB/s.
- A modification cannot be stopped, and only four are allowed in a rolling 24 hours, so a wrong change costs a full cycle to correct.
- No figure here was measured. All are from AWS documentation on the check date.
Sources
Opened 2026-11-10. Sources behind the corrections reopened 10 November 2026.
- Amazon EBS volume types
- General Purpose SSD volumes
- Provisioned IOPS SSD volumes
- Amazon EBS-optimized instances
- EC2 instance bandwidth weighting configuration
- I/O characteristics and monitoring
- Amazon CloudWatch metrics for Amazon EBS
- Amazon EBS detailed performance statistics
- Map EBS volumes to NVMe device names
- Amazon EBS performance
- Initialize Amazon EBS volumes
- Modify an Amazon EBS volume using Elastic Volumes
- Monitor the progress of volume modifications
- Benchmark Amazon EBS volumes
- Amazon EBS and RAID configuration
- Amazon EBS quotas
- Amazon EBS pricing
- AWS CLI: modify-volume
- AWS CLI: describe-volumes-modifications
- AWS CLI: describe-instance-types
- AWS CLI: get-metric-statistics
- iostat(1) manual page
This article was originally published on .