Find Orphaned EBS Snapshots Whose Volume Is Gone

Long rows of empty metal shelving in a dimly lit warehouse

Photo by Adrien Olichon on Unsplash

To find orphaned EBS snapshots, list your snapshots with DescribeSnapshots (OwnerIds: ["self"]), collect every volume ID that still exists from DescribeVolumes, and keep the snapshots whose VolumeId isn’t in that set. Then drop any snapshot referenced in the block device mappings of an AMI you own, including disabled and deprecated AMIs, before you delete or archive anything.

Snapshots outlive their volumes by design: deleting a volume has no effect on the snapshots made from it. That’s what you want for backups and exactly how storage bills creep up after migrations, test environments and instance cleanups. This example is for engineers who want to find orphaned EBS snapshots, see roughly what they cost, and then archive or delete them with a guard rail. The opposite gap, an in-use volume with no recent snapshot at all, is what the script to find EBS volumes without a recent snapshot reports.

It complements the age-based example to clean up AMIs and snapshots older than 30 days. That one asks “is it old?”; this one asks “does the thing it backed up still exist?”, which catches recent leftovers and spares old snapshots of live volumes. Both are part of our AWS SDK v3 cost and cleanup examples.

What counts as an orphaned EBS snapshot?

For this script, a snapshot is orphaned when all of these are true:

  • You own it, and its status is completed.
  • Its VolumeId doesn’t match any volume in the Region.
  • No AMI you own lists it in BlockDeviceMappings. AWS won’t let you delete a snapshot of the root device of a registered AMI, even a deprecated or disabled one, so the script asks for both with IncludeDeprecated and IncludeDisabled.
  • It’s older than --days (default 30), and it isn’t tagged keep=true.

One trap: snapshots created by a copy operation carry an arbitrary volume ID that, in AWS’s words, you should not use for any purpose. Every copied snapshot therefore looks orphaned. That’s often correct (the copy has no source volume in its Region), but a disaster-recovery copy in a second Region is orphaned by design. Read the Description column before acting.

How much do orphaned snapshots cost?

EBS snapshot prices in US East (N. Virginia), as of September 2026, from the AWS Price List:

Tier Storage Other charges
Standard $0.05 per GB-month None to restore; snapshots are incremental
Archive $0.0125 per GB-month $0.03 per GB restored; 90-day minimum; stored as a full snapshot

The API doesn’t return the billed size of a snapshot. VolumeSize is the size of the source volume, and FullSnapshotSizeInBytes is every block written to the volume, not the incremental size. So the script’s estimate is an upper bound: 500 GiB × $0.05 = $25.00 a month at most. AWS’s own example shows why the real number can be lower: two snapshots of a 10 GiB volume where 4 GiB changed cost 14 GiB of storage, and deleting the first one only frees its 4 GiB of unique data, because the other 6 GiB is still referenced by the second.

That also tells you when archiving pays. A snapshot that is the last one left of a deleted volume already holds all of its data, so archiving 500 GiB of it costs 500 × $0.0125 = $6.25 a month instead of up to $25.00. The catch is the minimum: delete an archived snapshot after 40 days and you’re billed for the remaining 50. Restores also take time, up to 72 hours. Archive what you might need for compliance; delete what nobody will ask for. To hear about the next storage creep before the invoice does, create an AWS budget alert with AWS SDK v3.

What does the script do?

  1. Collects live volumespaginateDescribeVolumes, every volume ID in the Region.
  2. Collects snapshots that AMIs needpaginateDescribeImages with Owners: ["self"], IncludeDisabled and IncludeDeprecated, reading each Ebs.SnapshotId.
  3. Finds orphanspaginateDescribeSnapshots with OwnerIds: ["self"] and a status filter of completed, minus live volumes, AMI snapshots, recent ones and keep=true.
  4. Prices themSize × the tier’s price, largest first, with a total per run.
  5. Acts only with --applyDeleteSnapshot, or ModifySnapshotTier to archive with --archive, for at most --max snapshots (default 20). Errors are logged per snapshot and the run continues.

Prerequisites

  • Node.js 18 or later, npm, tsx and @aws-sdk/client-ec2.
  • A read-only profile for the report and a separate one for --apply.
  • Know your safety nets: if a Recycle Bin retention rule matches, a deleted snapshot is retained in the Recycle Bin instead of disappearing, which is worth setting up before any bulk delete.

Which IAM permissions does it need?

The three describe actions don’t support resource-level permissions. DeleteSnapshot and ModifySnapshotTier can be scoped to snapshot ARNs, which have no account ID. Leave the second statement out of the report-only policy.

orphaned-snapshots-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "FindOrphanedSnapshots",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeSnapshots",
        "ec2:DescribeVolumes",
        "ec2:DescribeImages"
      ],
      "Resource": "*"
    },
    {
      "Sid": "ArchiveOrDeleteSnapshots",
      "Effect": "Allow",
      "Action": [
        "ec2:DeleteSnapshot",
        "ec2:ModifySnapshotTier"
      ],
      "Resource": "arn:aws:ec2:*::snapshot/*"
    }
  ]
}

To tighten the second statement to snapshots with a particular tag, follow the checklist to review a generated IAM policy for least privilege, or start from the free IAM policy generator for TypeScript AWS SDK code.

The full script to find orphaned EBS snapshots

find-orphaned-ebs-snapshots.ts

// find-orphaned-ebs-snapshots.ts
// Finds EBS snapshots you own whose source volume no longer exists and that no AMI you own
// (including disabled and deprecated AMIs) references. Estimates what they cost per month.
// Report-only by default. --apply deletes up to --max snapshots; --apply --archive moves them to
// the archive tier instead.
// Usage: npx tsx find-orphaned-ebs-snapshots.ts [--regions us-east-1,eu-west-1] [--days 30]
//          [--apply [--archive] [--max 20]]
import {
  EC2Client,
  DeleteSnapshotCommand,
  ModifySnapshotTierCommand,
  paginateDescribeImages,
  paginateDescribeSnapshots,
  paginateDescribeVolumes,
  type Snapshot,
} from "@aws-sdk/client-ec2";

// USD per GB-month in us-east-1, as of September 2026. Other Regions differ.
const STANDARD_PER_GB = 0.05;
const ARCHIVE_PER_GB = 0.0125;

const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
  const i = args.indexOf(name);
  return i >= 0 ? args[i + 1] : undefined;
};
const regions = (flag("--regions") ?? process.env.AWS_REGION ?? "us-east-1").split(",").map((r) => r.trim()).filter(Boolean);
const DAYS = Number(flag("--days") ?? 30);
const MAX = Number(flag("--max") ?? 20);
const APPLY = args.includes("--apply");
const ARCHIVE = args.includes("--archive");
if (!Number.isFinite(DAYS) || DAYS < 0) throw new Error("--days must be 0 or more");
if (!Number.isInteger(MAX) || MAX < 1) throw new Error("--max must be a positive integer");
const cutoff = Date.now() - DAYS * 86_400_000;

interface Orphan {
  Region: string;
  Snapshot: string;
  FormerVolume: string;
  Created: string;
  SizeGiB: number;
  Tier: string;
  EstPerMonth: string;
  Description: string;
}

const isKept = (s: Snapshot): boolean =>
  (s.Tags ?? []).some((t) => t.Key?.toLowerCase() === "keep" && t.Value?.toLowerCase() === "true");

async function findOrphans(ec2: EC2Client, region: string): Promise<Orphan[]> {
  // 1. Every volume that still exists in the Region.
  const volumes = new Set<string>();
  for await (const page of paginateDescribeVolumes({ client: ec2 }, {})) {
    for (const v of page.Volumes ?? []) if (v.VolumeId) volumes.add(v.VolumeId);
  }
  // 2. Every snapshot that an AMI you own depends on, including disabled and deprecated AMIs.
  const amiSnapshots = new Set<string>();
  const images = paginateDescribeImages({ client: ec2 }, { Owners: ["self"], IncludeDisabled: true, IncludeDeprecated: true });
  for await (const page of images) {
    for (const img of page.Images ?? []) {
      for (const bdm of img.BlockDeviceMappings ?? []) if (bdm.Ebs?.SnapshotId) amiSnapshots.add(bdm.Ebs.SnapshotId);
    }
  }
  // 3. Your completed snapshots whose volume is gone, that no AMI needs, older than the cutoff.
  const orphans: Orphan[] = [];
  const snaps = paginateDescribeSnapshots({ client: ec2 }, { OwnerIds: ["self"], Filters: [{ Name: "status", Values: ["completed"] }] });
  for await (const page of snaps) {
    for (const s of page.Snapshots ?? []) {
      if (!s.SnapshotId || !s.VolumeId || volumes.has(s.VolumeId)) continue;
      if (amiSnapshots.has(s.SnapshotId) || isKept(s)) continue;
      if ((s.StartTime?.getTime() ?? Date.now()) > cutoff) continue;
      const size = s.VolumeSize ?? 0;
      const archived = s.StorageTier === "archive";
      orphans.push({
        Region: region,
        Snapshot: s.SnapshotId,
        FormerVolume: s.VolumeId,
        Created: s.StartTime?.toISOString().slice(0, 10) ?? "",
        SizeGiB: size,
        Tier: s.StorageTier ?? "standard",
        EstPerMonth: `<= $${(size * (archived ? ARCHIVE_PER_GB : STANDARD_PER_GB)).toFixed(2)}`,
        Description: (s.Description ?? "").slice(0, 60),
      });
    }
  }
  return orphans.sort((a, b) => b.SizeGiB - a.SizeGiB);
}

async function act(ec2: EC2Client, o: Orphan): Promise<boolean> {
  try {
    if (ARCHIVE) {
      if (o.Tier === "archive") return false; // already archived
      await ec2.send(new ModifySnapshotTierCommand({ SnapshotId: o.Snapshot, StorageTier: "archive" }));
      console.log(`archiving ${o.Snapshot} (${o.Region})`);
      return true;
    } else {
      await ec2.send(new DeleteSnapshotCommand({ SnapshotId: o.Snapshot }));
      console.log(`deleted ${o.Snapshot} (${o.Region})`);
      return true;
    }
  } catch (err) {
    const e = err as { name?: string; message?: string };
    console.error(`skipped ${o.Snapshot}: ${e.name ?? "Error"}: ${e.message ?? String(err)}`);
    return false;
  }
}

async function main(): Promise<void> {
  let acted = 0;
  let total = 0;
  let gib = 0;
  let monthly = 0;
  for (const region of regions) {
    const ec2 = new EC2Client({ region });
    const orphans = await findOrphans(ec2, region);
    total += orphans.length;
    gib += orphans.reduce((sum, o) => sum + o.SizeGiB, 0);
    monthly += orphans.reduce((sum, o) => sum + o.SizeGiB * (o.Tier === "archive" ? ARCHIVE_PER_GB : STANDARD_PER_GB), 0);
    if (orphans.length) console.table(orphans);
    if (!APPLY) continue;
    for (const o of orphans) {
      if (acted >= MAX) break;
      if (await act(ec2, o)) acted++;
    }
  }
  console.log(`${total} orphaned snapshots older than ${DAYS} days, ${gib} GiB of source volume size.`);
  console.log(`At most $${monthly.toFixed(2)} per month at us-east-1 rates (snapshots are incremental; the billed size is usually lower).`);
  if (!APPLY) console.log("Report only: nothing was deleted. Re-run with --apply (or --apply --archive).");
  else console.log(`${ARCHIVE ? "Archive" : "Delete"} requested for ${acted} snapshots (limit --max ${MAX}).`);
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

How do you run it?

Terminal

npm install @aws-sdk/client-ec2
npm install --save-dev tsx typescript

# Report only, two Regions, snapshots older than 30 days
AWS_PROFILE=readonly npx tsx find-orphaned-ebs-snapshots.ts --regions us-east-1,eu-west-1

# Archive up to 3 of them instead of deleting
AWS_PROFILE=cleanup npx tsx find-orphaned-ebs-snapshots.ts --regions us-east-1 --apply --archive --max 3

# Delete up to 10, only if older than 90 days
AWS_PROFILE=cleanup npx tsx find-orphaned-ebs-snapshots.ts --regions us-east-1 --days 90 --apply --max 10

Sample output

Output

$ AWS_PROFILE=readonly npx tsx find-orphaned-ebs-snapshots.ts --regions us-east-1 --days 30
┌─────────┬─────────────┬──────────────────────────┬─────────────────────────┬──────────────┬─────────┬────────────┬─────────────┬─────────────────────────────────────────────┐
│ (index) │ Region      │ Snapshot                 │ FormerVolume            │ Created      │ SizeGiB │ Tier       │ EstPerMonth │ Description                                 │
├─────────┼─────────────┼──────────────────────────┼─────────────────────────┼──────────────┼─────────┼────────────┼─────────────┼─────────────────────────────────────────────┤
│ 0       │ 'us-east-1' │ 'snap-0c4b5a69788011223' │ 'vol-0c7d6e5f403122938' │ '2025-06-30' │ 1000    │ 'archive'  │ '<= $12.50' │ 'end of project archive'                    │
│ 1       │ 'us-east-1' │ 'snap-0f1e2d3c4b5a69788' │ 'vol-0a9b8c7d6e5f40312' │ '2025-11-03' │ 500     │ 'standard' │ '<= $25.00' │ 'pre-migration backup db-01'                │
│ 2       │ 'us-east-1' │ 'snap-0e2d3c4b5a6978801' │ 'vol-0b8c7d6e5f4031229' │ '2026-02-17' │ 200     │ 'standard' │ '<= $10.00' │ ''                                          │
│ 3       │ 'us-east-1' │ 'snap-0d3c4b5a697880112' │ 'vol-0e6f5a4b3c2d10987' │ '2026-04-02' │ 100     │ 'standard' │ '<= $5.00'  │ 'copy of reports volume for eu-west-1 test' │
└─────────┴─────────────┴──────────────────────────┴─────────────────────────┴──────────────┴─────────┴────────────┴─────────────┴─────────────────────────────────────────────┘
4 orphaned snapshots older than 30 days, 1800 GiB of source volume size.
At most $52.50 per month at us-east-1 rates (snapshots are incremental; the billed size is usually lower).
Report only: nothing was deleted. Re-run with --apply (or --apply --archive).

$ AWS_PROFILE=cleanup npx tsx find-orphaned-ebs-snapshots.ts --regions us-east-1 --days 30 --apply --archive --max 3
(same table)
archiving snap-0f1e2d3c4b5a69788 (us-east-1)
archiving snap-0e2d3c4b5a6978801 (us-east-1)
archiving snap-0d3c4b5a697880112 (us-east-1)
4 orphaned snapshots older than 30 days, 1800 GiB of source volume size.
At most $52.50 per month at us-east-1 rates (snapshots are incremental; the billed size is usually lower).
Archive requested for 3 snapshots (limit --max 3).

IDs are illustrative. The archived row costs at most $12.50 because it’s already in the archive tier, which is also why --archive skipped it. The last row is a copy: its FormerVolume is the arbitrary ID copies carry, and its description says it’s a test, so it was a safe candidate. The totals line adds each row at its own tier’s rate: $12.50 + $25.00 + $10.00 + $5.00 = $52.50.

Troubleshooting

  • InvalidSnapshot.InUse on delete. An AMI still uses the snapshot, possibly one owned by another team’s pipeline that the script couldn’t see as self. Deregister the AMI first, or leave the snapshot.
  • Snapshots created by AWS Backup fail to delete. You can’t delete Backup-managed snapshots through EC2; delete the recovery points in the backup vault instead.
  • The bill didn’t drop after a delete. Other snapshots still reference its blocks, and that data is now billed to them. It’s the incremental model working as designed.
  • Launch templates. A launch template can reference a snapshot directly in its block device mapping. The script doesn’t read launch templates, so check them if you build instances from templates rather than AMIs.
  • Throttling in large accounts. The SDK retries throttled calls; to raise the attempts, see how to configure retry and timeout settings in AWS SDK for JavaScript v3.

How do you stop orphaned snapshots piling up again?

Cleanup is one half of what the FinOps Foundation calls usage optimization: matching resources to actual usage, then making the fix routine. For snapshots that means tags with an owner and a purpose at creation time (the script to find untagged AWS resources catches the ones without), lifecycle policies for scheduled backups instead of hand-made ones, and a run of this script after every migration, such as the relaunch that follows when you find previous-generation EC2 instances to upgrade. Check the neighbors too: the volumes left behind by the same cleanups show up when you find and tag unattached EBS volumes, and database leftovers when you find and delete old RDS manual snapshots. Before archiving anything shared, make sure it isn’t also exposed: the check to find public EBS and RDS snapshots takes a minute.

Ask ChatWithCloud instead

For a quick count, ask ChatWithCloud “How many of my EBS snapshots in us-east-1 have a volume that no longer exists, and how many GiB is that?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and summarizes the answer, the same loop it uses to list AWS resources with natural language. It works in one profile and one Region per session, and it runs generated code without a confirmation step, so don’t ask it to delete anything from a profile that can: connect ChatWithCloud with a read-only AWS profile. For a repeatable, multi-Region report with a dry run and a --max limit, use the script. If snapshots turn out to be a large part of the bill, ask AI why your AWS bill increased to see how EBS compares with everything else.

Frequently asked questions

Are EBS snapshots deleted when I delete the volume?

No. Deleting a volume has no effect on its snapshots, and deleting a snapshot has no effect on the volume. Snapshots stay, and stay billed, until you delete them.

Is it safe to delete a snapshot whose volume is gone?

Only if nothing else needs it. Check AMIs (including disabled and deprecated ones), launch templates, copies kept for disaster recovery, and whether anyone needs the data. Archiving is the cautious middle ground.

How much will deleting orphaned snapshots save?

At most the snapshot’s size times $0.05 per GB-month in us-east-1. Because snapshots are incremental, data shared with other snapshots stays billed, so the real saving is often lower.

Should I archive or delete old EBS snapshots?

Archive when you might need the data and can wait up to 72 hours for a restore; it costs $0.0125 per GB-month with a 90-day minimum. Delete when nobody will ask for it.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud