Cost AWS cost
orphaned EBS snapshots outlive the volumes they came from
Snapshots are the well-behaved part of EBS: incremental, cheap, easy to automate. That is exactly why they accumulate. A backup script from three years ago is still running. The volumes it protected were deleted long ago. Nothing in AWS notices the connection, so the snapshots remain, billing $0.05 per GB-month for a restore nobody will ever perform.
Deleting a volume does not delete its snapshots, and AWS applies no default retention. A snapshot whose VolumeId no longer exists is orphaned and will bill forever.
Find them by listing snapshots you own, listing volumes, and taking the difference. Deleting is generally safe once the source volume is gone and no AMI references the snapshot — that second check is the one people skip, and it is what breaks a launch template months later.
The problem in plain words
Nothing marks a snapshot as orphaned. The console shows a list with sizes and dates and a VolumeId that may point at something deleted years ago, with no indication that it does.
Because they are incremental, people assume they are nearly free. Each snapshot only stores blocks changed since the last one, which is true — but a chain of them collectively stores the full volume plus every change, and when the source volume is gone the chain still holds all of it.
Why it happens
Automation outlives its purpose. A nightly snapshot job is set up once and rarely reviewed. It keeps running after the workload it protected is decommissioned, and it produces a new orphan every night.
There is no default retention. Unlike some services, EBS snapshots do not expire. Data Lifecycle Manager can enforce retention but has to be configured deliberately, and typically is not until after somebody notices the bill.
Deleting can break an AMI. An AMI is backed by snapshots. Delete one that an AMI depends on and the AMI stops being launchable — usually discovered by an autoscaling group at three in the morning. That real risk makes people avoid the whole job, which is how thousands accumulate.
How to fix it
List every snapshot you own
--owner-ids self matters. Without it you get every public snapshot in the region, which is not what you want to be reading.
aws ec2 describe-snapshots --owner-ids self \
--query 'Snapshots[].{Id:SnapshotId,Vol:VolumeId,GB:VolumeSize,When:StartTime}'
Work out which source volumes still exist
List current volume ids and subtract. A snapshot whose VolumeId is not in that set has no live source, which is the first condition for being orphaned.
Check no AMI depends on it — this is the step people skip
Deregistered or not, an AMI backed by a snapshot needs that snapshot to launch. describe-images --owners self exposes the block device mappings; collect every snapshot id they reference and exclude those from deletion no matter how orphaned they look.
Keep a floor, then set up retention
Deleting every orphan can leave you with no recovery point at all for a volume you deleted last week by mistake. Keep the most recent one per source volume, or anything under 30 days old. Then configure Data Lifecycle Manager so the problem stops regenerating.
How to check it worked
Count before and after, and confirm nothing that an AMI needs went away:
aws ec2 describe-snapshots --owner-ids self --query 'length(Snapshots)'
# every AMI should still be launchable
aws ec2 describe-images --owners self \
--query 'Images[].{Id:ImageId,State:State}'
# every State: available
The full code
The script finds snapshots whose source volume no longer exists, excludes any referenced by an AMI, keeps anything newer than a cutoff you set, and totals the monthly cost of what is left. Deletion is per snapshot id and behind --apply.
"""Find EBS snapshots whose source volume is gone and no AMI depends on.
Snapshots do not expire and deleting a volume does not delete them, so they
accumulate one backup job at a time. The AMI check is the important part: an AMI
backed by a snapshot cannot launch without it, and that failure surfaces later,
usually in an autoscaling event.
"""
import argparse
import datetime as dt
import logging
import sys
import boto3
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("ebs_snapshot_orphans")
SNAPSHOT_PER_GB_MONTH = 0.05
def classify(snapshot, live_volume_ids, ami_snapshot_ids, min_age_days, now):
"""Pure decision function. Returns (deletable, reason).
Three separate reasons to keep a snapshot, and they are checked in order of how
badly deleting would hurt: an AMI dependency breaks launches, a live volume
means it is a current backup, and a recent snapshot may be the only recovery
point for something deleted by mistake.
"""
if snapshot["SnapshotId"] in ami_snapshot_ids:
return False, "an AMI depends on it"
if snapshot.get("VolumeId") in live_volume_ids:
return False, "source volume still exists"
age = (now - snapshot["StartTime"]).days
if age < min_age_days:
return False, f"only {age} days old, inside the safety window"
return True, f"source volume gone, {age} days old"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--region", default="us-east-1")
ap.add_argument("--min-age-days", type=int, default=30)
ap.add_argument("--delete", help="snapshot id to delete")
ap.add_argument("--apply", action="store_true")
args = ap.parse_args()
ec2 = boto3.client("ec2", region_name=args.region)
now = dt.datetime.now(dt.timezone.utc)
live = {v["VolumeId"] for v in ec2.describe_volumes()["Volumes"]}
ami_snaps = {
m["Ebs"]["SnapshotId"]
for img in ec2.describe_images(Owners=["self"])["Images"]
for m in img.get("BlockDeviceMappings", [])
if m.get("Ebs", {}).get("SnapshotId")
}
log.info("%d live volume(s), %d snapshot(s) referenced by an AMI",
len(live), len(ami_snaps))
total = 0.0
for snap in ec2.describe_snapshots(OwnerIds=["self"])["Snapshots"]:
deletable, reason = classify(snap, live, ami_snaps, args.min_age_days, now)
cost = snap.get("VolumeSize", 0) * SNAPSHOT_PER_GB_MONTH
if deletable:
total += cost
log.warning("ORPHAN %s %3d GB $%5.2f/mo %s",
snap["SnapshotId"], snap.get("VolumeSize", 0), cost, reason)
if total:
log.warning("orphaned snapshots are costing about $%.2f/month", total)
else:
log.info("no orphaned snapshots older than %d days", args.min_age_days)
if args.delete:
if args.apply:
ec2.delete_snapshot(SnapshotId=args.delete)
log.info("deleted %s", args.delete)
else:
log.info("WOULD delete %s -- pass --apply", args.delete)
return 0
if __name__ == "__main__":
sys.exit(main())
/**
* Find EBS snapshots whose source volume is gone and no AMI depends on.
*
* The AMI check is the important part: an AMI backed by a snapshot cannot launch
* without it, and that failure surfaces later, usually in an autoscaling event.
*/
import {
EC2Client,
DescribeVolumesCommand,
DescribeImagesCommand,
DescribeSnapshotsCommand,
DeleteSnapshotCommand,
} from '@aws-sdk/client-ec2';
const SNAPSHOT_PER_GB_MONTH = 0.05;
/**
* Pure decision function. Returns { deletable, reason }.
*
* Checked in order of how badly deleting would hurt: an AMI dependency breaks
* launches, a live volume means it is a current backup, and a recent snapshot may
* be the only recovery point for something deleted by mistake.
*/
export function classify(snapshot, liveVolumeIds, amiSnapshotIds, minAgeDays, now) {
if (amiSnapshotIds.has(snapshot.SnapshotId)) {
return { deletable: false, reason: 'an AMI depends on it' };
}
if (liveVolumeIds.has(snapshot.VolumeId)) {
return { deletable: false, reason: 'source volume still exists' };
}
const age = Math.floor((now - new Date(snapshot.StartTime)) / 86400_000);
if (age < minAgeDays) {
return { deletable: false, reason: `only ${age} days old, inside the safety window` };
}
return { deletable: true, reason: `source volume gone, ${age} days old` };
}
async function main() {
const region = process.env.AWS_REGION ?? 'us-east-1';
const minAgeDays = Number(process.env.MIN_AGE_DAYS ?? 30);
const apply = process.argv.includes('--apply');
const toDelete = process.argv[process.argv.indexOf('--delete') + 1];
const ec2 = new EC2Client({ region });
const now = new Date();
const { Volumes = [] } = await ec2.send(new DescribeVolumesCommand({}));
const live = new Set(Volumes.map((v) => v.VolumeId));
const { Images = [] } = await ec2.send(new DescribeImagesCommand({ Owners: ['self'] }));
const amiSnaps = new Set(Images.flatMap((i) =>
(i.BlockDeviceMappings ?? []).map((m) => m.Ebs?.SnapshotId).filter(Boolean)));
console.log(`${live.size} live volume(s), ${amiSnaps.size} snapshot(s) referenced by an AMI`);
const { Snapshots = [] } = await ec2.send(new DescribeSnapshotsCommand({ OwnerIds: ['self'] }));
let total = 0;
for (const snap of Snapshots) {
const { deletable, reason } = classify(snap, live, amiSnaps, minAgeDays, now);
if (!deletable) continue;
const cost = (snap.VolumeSize ?? 0) * SNAPSHOT_PER_GB_MONTH;
total += cost;
console.warn(`ORPHAN ${snap.SnapshotId} ${snap.VolumeSize} GB $${cost.toFixed(2)}/mo ${reason}`);
}
if (total) console.warn(`orphaned snapshots are costing about $${total.toFixed(2)}/month`);
if (process.argv.includes('--delete')) {
if (apply) {
await ec2.send(new DeleteSnapshotCommand({ SnapshotId: toDelete }));
console.log(`deleted ${toDelete}`);
} else {
console.log(`WOULD delete ${toDelete} -- pass --apply`);
}
}
}
if (import.meta.url === `file://${process.argv[1]}`) main();
Add a test
Three independent reasons to keep a snapshot, and the AMI one has to win even when every other signal says orphaned. That precedence is what the tests lock down.
import datetime as dt
from ebs_snapshot_orphans import classify
NOW = dt.datetime(2026, 8, 28, tzinfo=dt.timezone.utc)
def snap(days_old=365, vol="vol-gone", sid="snap-1", size=100):
return {"SnapshotId": sid, "VolumeId": vol, "VolumeSize": size,
"StartTime": NOW - dt.timedelta(days=days_old)}
def test_old_snapshot_with_no_volume_is_deletable():
ok, _ = classify(snap(), set(), set(), 30, NOW)
assert ok is True
def test_ami_dependency_wins_over_everything():
"""Even ancient, even with no source volume: deleting breaks the AMI."""
ok, reason = classify(snap(days_old=2000), set(), {"snap-1"}, 30, NOW)
assert ok is False
assert "AMI" in reason
def test_live_source_volume_is_kept():
ok, reason = classify(snap(vol="vol-live"), {"vol-live"}, set(), 30, NOW)
assert ok is False
assert "still exists" in reason
def test_recent_snapshot_is_inside_the_safety_window():
ok, reason = classify(snap(days_old=5), set(), set(), 30, NOW)
assert ok is False
assert "safety window" in reason
def test_the_age_boundary():
assert classify(snap(days_old=29), set(), set(), 30, NOW)[0] is False
assert classify(snap(days_old=30), set(), set(), 30, NOW)[0] is True
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify } from './ebs-snapshot-orphans.mjs';
const NOW = new Date('2026-08-28T00:00:00Z');
const snap = ({ daysOld = 365, vol = 'vol-gone', sid = 'snap-1', size = 100 } = {}) => ({
SnapshotId: sid, VolumeId: vol, VolumeSize: size,
StartTime: new Date(NOW.getTime() - daysOld * 86400_000).toISOString(),
});
test('an old snapshot with no volume is deletable', () => {
assert.equal(classify(snap(), new Set(), new Set(), 30, NOW).deletable, true);
});
test('an AMI dependency wins over everything', () => {
const r = classify(snap({ daysOld: 2000 }), new Set(), new Set(['snap-1']), 30, NOW);
assert.equal(r.deletable, false);
assert.match(r.reason, /AMI/);
});
test('a live source volume is kept', () => {
const r = classify(snap({ vol: 'vol-live' }), new Set(['vol-live']), new Set(), 30, NOW);
assert.equal(r.deletable, false);
});
test('a recent snapshot is inside the safety window', () => {
const r = classify(snap({ daysOld: 5 }), new Set(), new Set(), 30, NOW);
assert.match(r.reason, /safety window/);
});
FAQ
Are snapshots not almost free because they are incremental?
Each snapshot stores only blocks changed since the last one, which is true. But a chain collectively holds the full volume plus every change, and when the source volume is deleted the chain still holds all of it, at $0.05 per GB-month indefinitely.
Does deleting a volume delete its snapshots?
No, and there is no default retention either. Snapshots persist until something deletes them, which is why a backup job that outlived its workload produces a new orphan every night.
What is the risk in deleting one?
An AMI backed by that snapshot stops being launchable. The failure usually surfaces later, in an autoscaling event, at an inconvenient hour. Always check describe-images for block device mappings before deleting, which is the step most cleanup scripts omit.
Why keep recent snapshots even when the volume is gone?
Because a volume deleted last week might have been deleted by mistake, and the snapshot is the only way back. A 30-day floor costs very little and preserves that option.
How do I stop them accumulating again?
Data Lifecycle Manager enforces retention policies on snapshots. Configuring it turns this from a recurring cleanup into a one-off, which is the actual fix.
Related field notes
- Unattached EBS volumes bill exactly like attached ones
- CloudWatch log groups default to keeping everything forever
- An idle NAT Gateway still costs $32 a month
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.