How can I clean up old, unused AWS EBS volumes using Python?
Quick Answer
Use boto3's describe_volumes with a filter for status=available, meaning the volume is not attached to any instance, compare each volume's CreateTime against a cutoff date, and delete volumes older than the threshold, always behind a DRY_RUN flag first, since deletion is irreversible and unattached does not always mean unused.
Detailed Answer
This is like a storage unit facility running a report on units that haven't had anyone visit in 30 days, so facilities management can start the process of reclaiming and re-renting them — but a good facility manager still checks the paperwork before drilling the lock off a unit, because "nobody's visited" doesn't always mean "nobody wants it," especially if it's someone's disaster-recovery storage they intentionally never touch.
This script exists because EBS volumes left behind after an instance is terminated, rather than the volume being explicitly deleted alongside it, silently continue billing at their full provisioned rate indefinitely — this is one of the most common sources of unnoticed cloud waste, since nothing about an orphaned volume is loud or urgent, it just sits there costing money with no owner watching it. DeleteOnTermination defaults to true for the root volume but not for additional attached volumes, which is exactly how so many of these accumulate over time in accounts with frequent instance replacement.
The script filters describe_volumes for status available, which EC2 sets specifically when a volume is not currently attached to any instance, as opposed to in-use or creating. It then compares each volume's CreateTime, a timezone-aware datetime returned directly by boto3, against a cutoff computed with timedelta, using timezone.utc explicitly to avoid the classic naive-vs-aware datetime comparison error that raises a TypeError in Python. Deletion itself is a single delete_volume call gated entirely behind the DRY_RUN flag, meaning the exact same discovery and filtering logic runs whether or not anything is actually deleted, which is the safest structure for this kind of destructive automation.
In production, this should never run as a single unattended step straight to deletion — the standard operating pattern is DRY_RUN=True on a schedule generating a report for review, often posted to a cost-optimization channel, with a human or a secondary approval step confirming the list before flipping to DRY_RUN=False. What to check before deleting: whether the volume has a tag indicating intentional retention, such as a manual snapshot source, disaster-recovery data, or a volume awaiting a support ticket investigation, and whether a recent snapshot exists — teams commonly add a rule that skips deletion for volumes without at least one recent snapshot, treating "no snapshot and no attachment" as the real deletion criterion rather than age alone.
The non-obvious gotcha is that available status only means "not currently attached" — it does not mean "safe to delete." A volume can be intentionally detached and parked, common during a maintenance window, a migration in progress, or as a deliberate disaster-recovery strategy of keeping data attached to nothing until needed, and a purely age-based cleanup script with no additional context will happily delete something a team is actively relying on, with no way to recover it once gone since EBS volume deletion is permanent unless a snapshot exists. Mature versions of this script cross-reference tags, recent snapshot existence, and even a pending-deletion grace-period tag applied a week before actual deletion, giving owners a window to object.