EBS Volume Types and Snapshot Costs: gp3, io2, Archive Tier and DLM

Cut EBS costs: gp2 to gp3, io1/io2 and unused provisioned IOPS, st1/sc1, stopped instances, snapshot and Archive tier pricing, AMIs, DLM and Recycle Bin.

EBS is the part of the EC2 bill nobody chose. You picked an instance type, the launch wizard picked the disk, and years later the account holds a few hundred volumes of whatever type was the default at the time. Stopping an instance doesn’t stop its disk billing, deleting an instance doesn’t delete its snapshots, and deregistering an AMI doesn’t either. Storage only accumulates.

Most of the fixes are configuration changes, not migrations. This is how we go through an EBS bill: volume types, then provisioned performance, then the snapshot chain behind the volumes. Prices are us-east-1 list prices at the time of writing; the EBS pricing page has the current list for your region.

Find out which line is big

EBS bills per GB-month of provisioned size, not used space, plus per IOPS-month and per MiB/s-month where you provision performance, plus per GB-month for snapshots. In Cost Explorer it hides under the “EC2 - Other” service, so start by splitting that out by usage type:

# Read-only: last full month of EBS spend by usage type
# (GNU date first, BSD/macOS date as the fallback)
START=$(date -u -d "$(date -u +%Y-%m-01) -1 month" +%F 2>/dev/null || date -u -v1d -v-1m +%F)
END=$(date -u +%Y-%m-01)
aws ce get-cost-and-usage \
  --time-period Start="$START",End="$END" \
  --granularity MONTHLY --metrics UnblendedCost \
  --filter '{"Dimensions":{"Key":"SERVICE","Values":["EC2 - Other"]}}' \
  --group-by Type=DIMENSION,Key=USAGE_TYPE \
  --query 'ResultsByTime[0].Groups[].[Keys[0],Metrics.UnblendedCost.Amount]' \
  --output text | grep EBS | sort -t$'\t' -k2 -rn

Usage types named VolumeUsage.gp2, VolumeUsage.gp3, VolumeUsage.piops (io1) and VolumeUsage.io2 are storage by type. VolumeP-IOPS and VolumeP-Throughput are provisioned performance. SnapshotUsage is snapshot storage. Cost Explorer charges $0.01 per API request, so this is cheap to run but not free.

gp2 to gp3

gp2 costs $0.10 per GB-month and ties performance to size: 3 IOPS per GB, a floor of 100 IOPS, and a burst to 3,000 IOPS for volumes under 1 TB as long as credits last. gp3 costs $0.08 per GB-month and includes 3,000 IOPS and 125 MiB/s on every volume, whatever its size. Extra IOPS above 3,000 cost $0.005 per IOPS-month and extra throughput above 125 MiB/s costs $0.04 per MiB/s-month.

For most volumes the move is 20% off with no downside. A 100 GB dev disk goes from $10 to $8 a month and from a bursting 300 IOPS baseline to a steady 3,000. A 1,000 GB gp2 volume at $100 gets 3,000 IOPS and up to 250 MiB/s; the same size on gp3 is $80, or $85 if you provision the extra 125 MiB/s to match the throughput ceiling exactly.

The bigger win is the volume that was oversized to buy IOPS. A 3,334 GB gp2 exists because somebody needed 10,000 IOPS and gp2 only hands them out by the gigabyte. That volume costs $333.40 a month. On gp3, 500 GB with 10,000 IOPS provisioned is $40 plus 7,000 × $0.005, or $75. EBS volumes can’t be shrunk in place, so that one needs a new volume and a copy, but the type change on its own is still worth making first.

# Read-only: every gp2 volume, largest first, with its state
aws ec2 describe-volumes --filters Name=volume-type,Values=gp2 \
  --query 'reverse(sort_by(Volumes,&Size))[].[VolumeId,Size,Iops,State,Attachments[0].InstanceId]' \
  --output table

The change itself is one modify-volume call per volume, and the volume stays attached and in use throughout. Elastic Volumes allows one modification every six hours, and type, size and performance can change in the same call. We’ve yet to find a gp2 volume that was better off staying gp2.

When io1 and io2 are worth it

io1 is $0.125 per GB-month plus $0.065 per provisioned IOPS-month. io2 Block Express is the same $0.125 per GB-month, with IOPS priced in tiers: $0.065 up to 32,000, $0.046 from 32,001 to 64,000 and $0.032 above that, up to 256,000 IOPS on a single volume. io2 also carries 99.999% durability against gp3’s 99.8-99.9%, and sub-millisecond latency on the instances that support it.

The IOPS price is the whole story: every provisioned IOPS on io2 costs 13 times what it costs on gp3. A 2 TB volume that needs 12,000 IOPS costs $205 on gp3 ($160 storage plus 9,000 × $0.005) and $1,030 on io2 ($250 storage plus 12,000 × $0.065). Same IOPS, five times the price.

io2 earns that when you need something gp3 can’t give: more than 16,000 IOPS or 1,000 MiB/s on one volume, Multi-Attach for a clustered file system, or the extra durability for a database whose replica isn’t an acceptable recovery. AWS has raised the gp3 ceilings over time, so check the volume types page for the current limits.

io1 has nothing left over io2 at the same price, and io2 is a modify-volume away. Any remaining io1 volume is a free upgrade.

Provisioned IOPS you never use

The second question for every io1, io2 and high-IOPS gp3 volume is whether it ever reaches what you pay for. Provisioned IOPS bill every hour at the provisioned number. A 20,000 IOPS io2 volume that peaks at 4,000 is paying $1,040 a month to be ready for a day that hasn’t come.

CloudWatch has the answer. VolumeReadOps and VolumeWriteOps are sums per period, so dividing by the period gives average IOPS, and the maximum of that series over two weeks is the peak you’d have to cover:

# Read-only: peak 5-minute IOPS for one volume over the last 14 days
VOL=vol-0123456789abcdef0
END=$(date -u +%Y-%m-%dT%H:%M:%SZ)
START=$(date -u -d '14 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-14d +%Y-%m-%dT%H:%M:%SZ)
aws cloudwatch get-metric-data --start-time "$START" --end-time "$END" \
  --metric-data-queries '[
    {"Id":"r","ReturnData":false,"MetricStat":{"Metric":{"Namespace":"AWS/EBS","MetricName":"VolumeReadOps",
      "Dimensions":[{"Name":"VolumeId","Value":"'"$VOL"'"}]},"Period":300,"Stat":"Sum"}},
    {"Id":"w","ReturnData":false,"MetricStat":{"Metric":{"Namespace":"AWS/EBS","MetricName":"VolumeWriteOps",
      "Dimensions":[{"Name":"VolumeId","Value":"'"$VOL"'"}]},"Period":300,"Stat":"Sum"}},
    {"Id":"iops","Expression":"(r+w)/300","Label":"average IOPS"}
  ]' --query 'max(MetricDataResults[0].Values)' --output text

Compare that with Iops from describe-volumes. A five-minute average hides short spikes, so for a bursty workload rerun at a 60-second period over a busy day. Our rule: provision 20-30% above the two-week peak, and if that lands under 3,000 the volume belongs on plain gp3 with nothing provisioned at all. Provisioned throughput follows the same logic with VolumeReadBytes and VolumeWriteBytes.

The instance has its own EBS bandwidth limit, listed in its EBS-optimized specification, so IOPS provisioned above what the instance type can push are pure waste.

st1 and sc1 for streaming data

Throughput Optimized HDD (st1) is $0.045 per GB-month and Cold HDD (sc1) is $0.015. Both scale throughput with size: st1 has a 40 MiB/s per TB baseline bursting to 250, sc1 a 12 MiB/s per TB baseline bursting to 80. Minimum size is 125 GB and neither can be a boot volume.

They’re built for large sequential reads and writes: log archives, Kafka and Hadoop data nodes, ETL staging, backup landing zones. On random I/O they’re slow, so a database put on sc1 to save money spends the saving in waiting. The reverse mistake is more common and more expensive: 10 TB of log files on gp3 at $800 a month that would cost $150 on sc1 and read just as fast sequentially.

First check whether the data belongs on a block device at all. Archives nobody mounts are cheaper still in S3; our S3 storage classes guide covers that side.

Volumes attached to stopped instances

A stopped instance costs nothing for compute and full price for every volume attached to it. Dev boxes stopped in March, a migration source kept “just in case”, the old server after a cut-over: each keeps billing per GB-month, usually on gp2.

# Read-only: stopped instances, when they stopped, and the volumes still billing
IDS=$(aws ec2 describe-instances --filters Name=instance-state-name,Values=stopped \
  --query 'Reservations[].Instances[].InstanceId' --output text)
[ -n "$IDS" ] && aws ec2 describe-instances --instance-ids $IDS \
  --query 'Reservations[].Instances[].[InstanceId,StateTransitionReason,Tags[?Key==`Name`]|[0].Value]' --output table
[ -n "$IDS" ] && aws ec2 describe-volumes --filters Name=attachment.instance-id,Values=$(echo $IDS | tr ' ' ',') \
  --query 'Volumes[].[Attachments[0].InstanceId,VolumeId,VolumeType,Size]' --output table

StateTransitionReason gives the stop date. Anything stopped for more than a month is a candidate for snapshot-and-terminate: a snapshot stores only written blocks at $0.05 per GB-month, so a 500 GB gp2 root volume with 120 GB on it goes from $50 a month to about $6, and the instance can be rebuilt from the snapshot if anyone asks. If it has to come back quickly and often, keep it but move its volumes to gp3.

Volumes attached to nothing at all are a different list. We covered those, with the rest of the leftovers, in idle and orphaned AWS resources, so we won’t repeat it here.

How snapshots and the Archive tier are billed

Snapshots are incremental. The first snapshot of a volume stores every block that has been written; each later one stores only the blocks changed since the previous snapshot. You pay $0.05 per GB-month for the blocks stored, not for the volume’s nominal size. A 1 TB volume with 200 GB written and a 2% daily change rate costs about $10 for the first snapshot and about $1 for each daily one after it.

Two consequences follow. Deleting a snapshot only frees the blocks no other snapshot references, so deleting 300 old dailies often frees less than you’d think. And the console’s “size” column is the volume size, not what the snapshot stores. describe-snapshots returns FullSnapshotSizeInBytes, the size of everything the snapshot references, and the EBS direct API list-changed-blocks shows what is unique to one snapshot. For the account total, trust the SnapshotUsage line in Cost Explorer.

Fast Snapshot Restore is the charge people forget: $0.75 per data services unit-hour for every snapshot and Availability Zone it’s enabled on, about $550 a month per snapshot per AZ, often enabled for one launch and never turned off.

Then the Archive tier. Archived snapshots cost $0.0125 per GB-month, a quarter of the standard rate, with a 90-day minimum and $0.03 per GB to restore. A restore takes 24 to 72 hours, and the snapshot can’t be used until it’s back in the standard tier. The catch is that an archived snapshot is converted to a full snapshot: it stores every block the volume had at that point, not just the increment. Archiving a daily incremental that holds 4 GB of changes on a 200 GB volume turns $0.20 a month into 200 GB × $0.0125, or $2.50. Twelve times more expensive.

Archive pays off when the snapshot already carries most of the data on its own: the only remaining snapshot of a deleted volume, a compliance copy kept for years, the last snapshot in a chain after the rest were pruned. Compare FullSnapshotSizeInBytes × $0.0125 against the incremental size × $0.05 and archive only when the first number is smaller. Snapshots referenced by a registered AMI can’t be archived at all. The snapshot archive page has the full set of rules.

AMIs that pin snapshots

Every EBS-backed AMI is a set of snapshots, one per block device. The snapshot behind an AMI can’t be deleted while the AMI is registered, and deregistering the AMI doesn’t delete the snapshot. Build pipelines that bake an AMI per commit leave thousands of 8 to 30 GB snapshots behind, each built fresh, so the incremental saving doesn’t apply.

EC2 records when an AMI was last used to launch an instance, which is the question that matters:

# Read-only: your AMIs, when each was last launched, and the snapshots it pins
for ami in $(aws ec2 describe-images --owners self --query 'Images[].ImageId' --output text); do
  last=$(aws ec2 describe-image-attribute --image-id "$ami" --attribute lastLaunchedTime \
    --query 'LastLaunchedTime.Value' --output text)
  snaps=$(aws ec2 describe-images --image-ids "$ami" \
    --query 'Images[0].BlockDeviceMappings[].Ebs.SnapshotId' --output text | tr '\t' ' ')
  printf '%s\tlast launched: %s\t%s\n' "$ami" "$last" "$snaps"
done

None means no launch since tracking began. Check launch templates and any CloudFormation or Terraform that still names the AMI before deregistering it; a lastLaunchedTime of eighteen months ago on your production launch template’s AMI is a surprise best found this way, not during a scale-out. The sequence is deregister the AMI, then delete its snapshots, and a Recycle Bin rule, below, gives you a window to undo both.

Data Lifecycle Manager and Recycle Bin

Amazon Data Lifecycle Manager is free and does the pruning for you. A snapshot policy targets volumes or instances by tag, takes snapshots on a schedule from hourly to daily or a cron expression, and keeps either a count (1 to 1,000) or an age; when a snapshot ages out, DLM deletes it. Policies can also copy to other regions and accounts with their own retention, and move snapshots to the Archive tier instead of deleting them. Default policies cover every volume or instance in a region, with an option to skip boot volumes. The DLM documentation walks through the options.

An AMI policy does the same for images: create on a schedule, keep a count or age, deregister old AMIs and delete their snapshots in one go. That’s the fix for the build-pipeline pile-up. Run aws dlm get-lifecycle-policies to see what exists, and look at snapshots without the aws:dlm:lifecycle-policy-id tag: those are manual, and nothing will ever delete them.

Recycle Bin is the safety net. A retention rule for EBS_SNAPSHOT or EC2_IMAGE resources, region-wide or by tag, holds deleted resources for one day to one year, and you can restore from the bin in the meantime. Two things to know: resources in the bin bill at the full standard rate until they expire, so a 90-day rule means a 90-day lag before any deletion saves money; and a locked rule can’t be shortened in a hurry. aws rbin list-rules --resource-type EBS_SNAPSHOT shows yours; the Recycle Bin documentation covers the rest.

Cross-region copies

A snapshot copied to another region is billed as snapshot storage in the destination at that region’s rate, plus data transfer out of the source region, typically $0.02 per GB from us-east-1 to most other regions. Copies are incremental when a previous copy of the same volume’s snapshot already exists in the destination, so a DR schedule costs roughly what the source schedule costs. A copy encrypted with a different KMS key is a full copy every time, and so is the first copy to a new region.

The trap is retention. The source region has a DLM policy that keeps seven dailies; the destination has whatever the copy step was told, which is often “forever”. Run describe-snapshots --owner-ids self in the DR region and sort by StartTime. If the oldest copy is older than the oldest source snapshot, the copies have no retention. Fix it in the DLM cross-region copy rule or, if a script does the copying, give the copies a tag and a Recycle Bin rule of their own. AWS Backup, with one vault per region and lifecycle rules on each, is cleaner when EBS is one of several services being backed up.

FAQ

Is gp3 always cheaper than gp2?

Per GB, yes: $0.08 against $0.10 in us-east-1 at the time of writing, with 3,000 IOPS and 125 MiB/s included. gp3 only costs more if you provision IOPS or throughput you don’t need.

Does a stopped EC2 instance still pay for EBS?

Yes. Instance hours stop, volume GB-months don’t. A snapshot of the volume costs $0.05 per GB of written data instead of $0.08 to $0.125 per GB of provisioned size.

How is EBS snapshot storage priced?

$0.05 per GB-month for the blocks a snapshot stores, incrementally after the first snapshot. Archive tier is $0.0125 per GB-month, but archived snapshots are full copies, so it’s only cheaper when the snapshot already holds most of the volume’s data.

Does deregistering an AMI delete its snapshots?

No. The snapshots stay and keep billing. Delete them separately, or let a DLM AMI policy handle both steps, and use a Recycle Bin rule so a mistake can be undone.

Can I change an EBS volume type without downtime?

Yes. modify-volume changes type, size and provisioned performance while the volume is attached and in use, once every six hours per volume. Volumes can grow but not shrink.

Start with the gp2 list

Run the gp2 command above and change every volume on it to gp3, biggest first. It’s a few minutes of work, needs no downtime, and is the one change in this article with no failure mode worth worrying about. Then take the snapshot total from Cost Explorer and compare it with what your DLM policies would retain if they were the only thing creating snapshots; the gap is the manual snapshots and orphaned AMIs waiting to be cleaned up.

Kostrix runs this pass for you: its EBS check lists gp2 volumes with the gp3 saving, volumes on stopped instances and provisioned IOPS above measured use, and its snapshot check lists snapshots whose volume no longer exists, each priced from your own bill. The scan is read-only and runs weekly, so the list stays current as new volumes appear.