Cost AWS cost

unattached EBS volumes bill exactly like attached ones

An instance gets terminated. Its root volume goes with it, because DeleteOnTermination defaults to true for the root device. Any data volume attached later does not, because for those the default is the opposite. The volume survives in available state, holding data nobody will read again, and billing at $0.08 per GB-month for gp3 exactly as if it were still doing work.

EC2 API Python and Node.js Snapshot before delete
The short answer

An unattached EBS volume costs the same as an attached one. A forgotten 500 GB gp3 volume is $40 a month; gp2 is dearer still at $0.10/GB-month.

describe-volumes with a status filter of available finds every one. The safe move is to snapshot before deleting — a snapshot of the same data costs $0.05/GB-month, so you keep a recoverable copy at a lower rate while you confirm nobody wanted it.

The problem in plain words

The console shows the volume as available, which sounds like a resource ready for use rather than one quietly charging you. There is no warning, no age indicator on the cost, and no relationship shown to the instance that used to own it — that instance is gone.

The accumulation is steady rather than dramatic. Every terminated instance that had a data volume leaves one behind. A typical mid-size account carries five to fifteen of them, which lands somewhere between $50 and $200 a month of storage doing nothing.

Why it happens

The default is inconsistent by device. Root volumes delete on termination; additional volumes attached afterwards do not. That inconsistency is defensible — data volumes usually hold something you want to keep — but it means the outcome depends on how the volume was attached, which nobody remembers a year later.

Deleting feels irreversible, because it is. A volume might hold the only copy of something. Faced with that and no easy way to inspect the contents, the safe-feeling choice is to leave it, and leaving it is what costs money.

gp2 volumes cost more and nobody migrates them. gp3 is roughly 20% cheaper than gp2 with better baseline performance, and the migration is live — no detach, no downtime. Old volumes sit on gp2 purely because changing them was never anybody's task.

How to fix it

List what is unattached and how big it is

Size is what determines cost, so sort by it. The oldest volumes are not necessarily the expensive ones.

aws ec2 describe-volumes \
  --filters Name=status,Values=available \
  --query 'sort_by(Volumes,&Size)[].{Id:VolumeId,GB:Size,Type:VolumeType,Created:CreateTime}'

Snapshot before you delete anything

A snapshot of the same data costs $0.05/GB-month against $0.08 for the live gp3 volume, and it is restorable. Snapshot, wait for it to complete, then delete the volume: you have converted an expensive copy into a cheaper one without losing the option to go back.

Check the tags before assuming it is junk

A volume tagged with an environment or an owner belongs to somebody. One with no tags at all and a creation date two years old is a much safer delete. The script reports tags alongside cost so the decision has something to go on.

Migrate what remains from gp2 to gp3

For volumes still in use, modify-volume changes the type live. It is about 20% cheaper and generally faster, and the change needs no downtime.

aws ec2 modify-volume --volume-id vol-0abc123 --volume-type gp3

How to check it worked

Confirm the snapshot completed before the volume disappears:

aws ec2 describe-snapshots --snapshot-ids snap-0abc123 \
  --query 'Snapshots[].{State:State,Progress:Progress}'
# State: completed

aws ec2 describe-volumes --filters Name=status,Values=available \
  --query 'length(Volumes)'

The count should drop and next month's EBS line should follow it.

The full code

The script lists unattached volumes with size, type, age and tags, and totals the monthly cost. Deletion takes a specific volume id, snapshots it first, and waits for the snapshot to complete before removing anything — because a delete you cannot undo deserves the extra minute.

ebs_unattached_audit.py
"""Find unattached EBS volumes, cost them, and delete safely via a snapshot.

An unattached volume bills at the same rate as an attached one. Deletion is
irreversible, so this snapshots first and waits for the snapshot to complete
before removing anything.
"""
import argparse
import logging
import sys

import boto3

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("ebs_unattached_audit")

# us-east-1 list prices, August 2026.
PER_GB_MONTH = {"gp3": 0.08, "gp2": 0.10, "io1": 0.125, "io2": 0.125,
                "st1": 0.045, "sc1": 0.015, "standard": 0.05}
SNAPSHOT_PER_GB_MONTH = 0.05


def monthly_cost(volume):
    """Pure decision function: what this volume costs, and whether it is a safe delete.

    An untagged volume is a much safer delete than one carrying an owner or an
    environment, so that judgement is returned alongside the number.
    """
    size = volume.get("Size", 0)
    vtype = volume.get("VolumeType", "gp3")
    rate = PER_GB_MONTH.get(vtype, PER_GB_MONTH["gp3"])
    cost = size * rate
    tags = {t["Key"]: t["Value"] for t in volume.get("Tags", [])}
    confidence = "untagged, likely orphaned" if not tags else f"tagged {sorted(tags)}"
    saving_as_snapshot = cost - (size * SNAPSHOT_PER_GB_MONTH)
    return {"cost": cost, "confidence": confidence,
            "snapshot_saving": max(0.0, saving_as_snapshot), "tags": tags}


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--region", default="us-east-1")
    ap.add_argument("--delete", help="volume id to snapshot then delete")
    ap.add_argument("--apply", action="store_true")
    args = ap.parse_args()

    ec2 = boto3.client("ec2", region_name=args.region)

    vols = ec2.describe_volumes(
        Filters=[{"Name": "status", "Values": ["available"]}])["Volumes"]
    total = 0.0
    for v in sorted(vols, key=lambda x: -x.get("Size", 0)):
        info = monthly_cost(v)
        total += info["cost"]
        log.warning("%s  %4d GB %-8s $%6.2f/mo  %s  (created %s)",
                    v["VolumeId"], v["Size"], v["VolumeType"], info["cost"],
                    info["confidence"], v["CreateTime"].date())
    if vols:
        log.warning("%d unattached volume(s) costing about $%.2f/month",
                    len(vols), total)
    else:
        log.info("no unattached volumes in %s", args.region)

    if args.delete:
        if not args.apply:
            log.info("WOULD snapshot then delete %s -- pass --apply", args.delete)
            return 0
        snap = ec2.create_snapshot(
            VolumeId=args.delete,
            Description=f"pre-delete safety copy of {args.delete}")
        log.info("snapshot %s started; waiting for it to complete", snap["SnapshotId"])
        ec2.get_waiter("snapshot_completed").wait(SnapshotIds=[snap["SnapshotId"]])
        ec2.delete_volume(VolumeId=args.delete)
        log.info("deleted %s -- restorable from %s", args.delete, snap["SnapshotId"])
    return 0


if __name__ == "__main__":
    sys.exit(main())
ebs-unattached-audit.mjs
/**
 * Find unattached EBS volumes, cost them, and delete safely via a snapshot.
 *
 * An unattached volume bills at the same rate as an attached one. Deletion is
 * irreversible, so this snapshots first and waits for completion.
 */
import {
  EC2Client,
  DescribeVolumesCommand,
  CreateSnapshotCommand,
  DeleteVolumeCommand,
  waitUntilSnapshotCompleted,
} from '@aws-sdk/client-ec2';

// us-east-1 list prices, August 2026.
const PER_GB_MONTH = {
  gp3: 0.08, gp2: 0.10, io1: 0.125, io2: 0.125, st1: 0.045, sc1: 0.015, standard: 0.05,
};
const SNAPSHOT_PER_GB_MONTH = 0.05;

/**
 * Pure decision function: what this volume costs, and whether it is a safe delete.
 * An untagged volume is a much safer delete than one carrying an owner.
 */
export function monthlyCost(volume) {
  const size = volume.Size ?? 0;
  const rate = PER_GB_MONTH[volume.VolumeType ?? 'gp3'] ?? PER_GB_MONTH.gp3;
  const cost = size * rate;
  const tags = Object.fromEntries((volume.Tags ?? []).map((t) => [t.Key, t.Value]));
  const keys = Object.keys(tags).sort();
  return {
    cost,
    confidence: keys.length ? `tagged ${JSON.stringify(keys)}` : 'untagged, likely orphaned',
    snapshotSaving: Math.max(0, cost - size * SNAPSHOT_PER_GB_MONTH),
    tags,
  };
}

async function main() {
  const region = process.env.AWS_REGION ?? 'us-east-1';
  const apply = process.argv.includes('--apply');
  const toDelete = process.argv[process.argv.indexOf('--delete') + 1];
  const ec2 = new EC2Client({ region });

  const { Volumes = [] } = await ec2.send(new DescribeVolumesCommand({
    Filters: [{ Name: 'status', Values: ['available'] }],
  }));
  let total = 0;
  for (const v of [...Volumes].sort((a, b) => (b.Size ?? 0) - (a.Size ?? 0))) {
    const info = monthlyCost(v);
    total += info.cost;
    console.warn(`${v.VolumeId}  ${v.Size} GB ${v.VolumeType}  $${info.cost.toFixed(2)}/mo  ${info.confidence}`);
  }
  if (Volumes.length) {
    console.warn(`${Volumes.length} unattached volume(s) costing about $${total.toFixed(2)}/month`);
  } else {
    console.log(`no unattached volumes in ${region}`);
  }

  if (process.argv.includes('--delete')) {
    if (!apply) return console.log(`WOULD snapshot then delete ${toDelete} -- pass --apply`);
    const snap = await ec2.send(new CreateSnapshotCommand({
      VolumeId: toDelete, Description: `pre-delete safety copy of ${toDelete}`,
    }));
    console.log(`snapshot ${snap.SnapshotId} started; waiting`);
    await waitUntilSnapshotCompleted({ client: ec2, maxWaitTime: 900 },
      { SnapshotIds: [snap.SnapshotId] });
    await ec2.send(new DeleteVolumeCommand({ VolumeId: toDelete }));
    console.log(`deleted ${toDelete} -- restorable from ${snap.SnapshotId}`);
  }
}

if (import.meta.url === `file://${process.argv[1]}`) main();

Add a test

The pricing table and the tag heuristic are both worth pinning: an unknown volume type must not silently cost zero, and a tagged volume must never be described as orphaned.

test_ebs_unattached_audit.py
from ebs_unattached_audit import monthly_cost


def test_gp3_pricing():
    assert monthly_cost({"Size": 500, "VolumeType": "gp3"})["cost"] == 40.0


def test_gp2_costs_more_than_gp3():
    gp2 = monthly_cost({"Size": 100, "VolumeType": "gp2"})["cost"]
    gp3 = monthly_cost({"Size": 100, "VolumeType": "gp3"})["cost"]
    assert gp2 > gp3


def test_unknown_type_falls_back_rather_than_costing_zero():
    """A new volume type must not silently report as free."""
    assert monthly_cost({"Size": 100, "VolumeType": "gp9"})["cost"] > 0


def test_untagged_volume_is_flagged_as_orphaned():
    assert "orphaned" in monthly_cost({"Size": 10, "VolumeType": "gp3"})["confidence"]


def test_tagged_volume_is_never_called_orphaned():
    info = monthly_cost({"Size": 10, "VolumeType": "gp3",
                         "Tags": [{"Key": "Owner", "Value": "platform"}]})
    assert "orphaned" not in info["confidence"]


def test_snapshot_is_cheaper_than_the_volume():
    assert monthly_cost({"Size": 100, "VolumeType": "gp3"})["snapshot_saving"] > 0
ebs-unattached-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { monthlyCost } from './ebs-unattached-audit.mjs';

test('gp3 pricing', () => {
  assert.equal(monthlyCost({ Size: 500, VolumeType: 'gp3' }).cost, 40);
});

test('gp2 costs more than gp3', () => {
  assert.ok(monthlyCost({ Size: 100, VolumeType: 'gp2' }).cost
    > monthlyCost({ Size: 100, VolumeType: 'gp3' }).cost);
});

test('an unknown type falls back rather than costing zero', () => {
  assert.ok(monthlyCost({ Size: 100, VolumeType: 'gp9' }).cost > 0);
});

test('an untagged volume is flagged as orphaned', () => {
  assert.match(monthlyCost({ Size: 10, VolumeType: 'gp3' }).confidence, /orphaned/);
});

test('a tagged volume is never called orphaned', () => {
  const info = monthlyCost({ Size: 10, VolumeType: 'gp3', Tags: [{ Key: 'Owner', Value: 'p' }] });
  assert.doesNotMatch(info.confidence, /orphaned/);
});

FAQ

Does an unattached volume really cost the same as an attached one?

Yes. EBS bills for provisioned storage, not for use. A 500 GB gp3 volume is about $40 a month whether an instance is reading from it or nothing has touched it in a year.

Why did the volume survive when I terminated the instance?

DeleteOnTermination defaults to true for the root device and false for volumes attached afterwards. The outcome depends on how the volume was attached, which is rarely remembered later.

Is it safe to delete an unattached volume?

Only after you have a copy. Snapshot it first, wait for the snapshot to complete, then delete: a snapshot of the same data costs $0.05/GB-month against $0.08 for the live gp3 volume, so you keep a restorable copy at a lower rate.

Should I move gp2 volumes to gp3?

Usually yes. gp3 is roughly 20% cheaper with better baseline performance, and modify-volume changes the type live with no detach and no downtime.

How much is typically sitting there?

Five to fifteen unattached volumes is normal for a mid-size account, which lands somewhere between $50 and $200 a month. It accumulates one terminated instance at a time rather than arriving all at once.

Related field notes

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.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.