Find and Tag Unattached EBS Volumes to Cut Costs

Rows of hard disk drives mounted in a storage array

Photo by benjamin lehman on Unsplash

To find unattached EBS volumes, call EC2 DescribeVolumes with the filter status = available. A volume in the available state isn’t attached to any instance but is still billed for its full provisioned size. The script below lists them with an estimated monthly cost and, only when you pass --tag, tags them for review. It never deletes anything.

Unattached volumes pile up quietly: an instance is terminated but its data volume had DeleteOnTermination set to false, a test volume is created and forgotten, a volume is detached “for a minute” during a migration. This example is for engineers who want to find unattached EBS volumes across a region, put a dollar figure on them, and start a safe cleanup process. You’ll get a TypeScript script for the AWS SDK for JavaScript v3 that reports every unattached volume and, on request, tags each one with the date it was first found.

It’s one of our hands-on AWS practical examples for cost cleanup. If EC2 - Other is high in your bill, where EBS storage is reported, the script to get last month’s AWS cost by service confirms it’s worth the time. For the security side of the same volume inventory, the example to find unencrypted EBS volumes and turn on default encryption checks encryption.

Report-only by default: without --tag the script only reads. With --tag it adds one tag, cost-review:unattached-first-seen, to volumes that don’t already have it. It doesn’t detach, snapshot or delete volumes.

Why tag unattached volumes instead of deleting them?

An unattached volume might be the only copy of something: a database restored for an investigation, or a disk detached on purpose while an instance is rebuilt. Deleting it is permanent. Tagging it with the date you first saw it unattached gives owners time to object, and lets a later run delete only volumes that have stayed unattached for, say, 30 days, after taking a snapshot. Because the script never overwrites an existing tag, the first-seen date survives every run, and the report shows how many days each volume has been waiting. If you can’t tell who owns a volume, it’s probably missing other tags too; the script to find untagged AWS resources with the Tagging API lists every resource without your required keys.

What does the script do?

  1. Lists unattached volumespaginateDescribeVolumes with Filters: [{ Name: "status", Values: ["available"] }]. Volumes attached to stopped instances are in-use, so they’re correctly left out.
  2. Estimates monthly costSize in GiB × the storage price for the volume type, using a small price table for US East (N. Virginia). Provisioned IOPS and throughput are billed on top and aren’t included.
  3. Prints a ranked reportVolume ID, Name tag, type, size, Availability Zone, creation date, days since first seen and estimated cost, most expensive first.
  4. Tags only with --tagCreateTags adds cost-review:unattached-first-seen=YYYY-MM-DD to volumes without the tag, in batches of 50.

Volume states and every filter DescribeVolumes accepts are listed in the EC2 DescribeVolumes API reference.

Prerequisites

  • Node.js 18 or later, npm and tsx.
  • The @aws-sdk/client-ec2 package.
  • A profile with a default region, or AWS_REGION set. EBS volumes are regional, so run the script once per region you use.

Which IAM permissions does it need?

Report-only mode needs ec2:DescribeVolumes. For --tag, the second statement allows ec2:CreateTags on volumes only, and the aws:TagKeys condition limits it to the single review tag, so the profile can’t change any other tags (replace 123456789012 with your account ID).

tag-unattached-ebs-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListVolumes",
      "Effect": "Allow",
      "Action": "ec2:DescribeVolumes",
      "Resource": "*"
    },
    {
      "Sid": "TagVolumesForReviewOnly",
      "Effect": "Allow",
      "Action": "ec2:CreateTags",
      "Resource": "arn:aws:ec2:*:123456789012:volume/*",
      "Condition": {
        "ForAllValues:StringEquals": {
          "aws:TagKeys": ["cost-review:unattached-first-seen"]
        }
      }
    }
  ]
}

The guide to review IAM policies for least privilege explains why conditions like this beat a bare ec2:CreateTags on *. If you extend the script, the AI IAM policy generator for TypeScript code drafts the extra actions.

The full script to find unattached EBS volumes and tag them

tag-unattached-ebs.ts

// tag-unattached-ebs.ts
// Lists EBS volumes in the "available" state (not attached to any instance),
// estimates their monthly storage cost and, with --tag, tags them for review.
// Report-only by default. It never detaches, snapshots or deletes anything.
// Usage: npx tsx tag-unattached-ebs.ts [--tag]
import {
  EC2Client,
  paginateDescribeVolumes,
  CreateTagsCommand,
  type Volume,
} from "@aws-sdk/client-ec2";

const TAG_KEY = "cost-review:unattached-first-seen";

// USD per GB-month for storage only, us-east-1, as of September 2026.
// Provisioned IOPS/throughput are billed on top and not included. Other regions differ.
const PRICE_PER_GB_MONTH: Record<string, number> = {
  gp3: 0.08,
  gp2: 0.1,
  io1: 0.125,
  st1: 0.045,
  sc1: 0.015,
  standard: 0.05,
};

const today = new Date().toISOString().slice(0, 10);
const tagValue = (v: Volume, key: string) => v.Tags?.find((t) => t.Key === key)?.Value;

function daysSince(isoDate: string | undefined): number | undefined {
  if (!isoDate) return undefined;
  const ms = Date.now() - new Date(`${isoDate}T00:00:00Z`).getTime();
  return Number.isNaN(ms) ? undefined : Math.floor(ms / 86_400_000);
}

async function main(): Promise<void> {
  const applyTags = process.argv.includes("--tag");
  const ec2 = new EC2Client({}); // region from AWS_REGION or your AWS profile

  const volumes: Volume[] = [];
  for await (const page of paginateDescribeVolumes(
    { client: ec2 },
    { Filters: [{ Name: "status", Values: ["available"] }] },
  )) {
    volumes.push(...(page.Volumes ?? []));
  }
  if (volumes.length === 0) {
    console.log("No unattached EBS volumes in this region.");
    return;
  }

  const rows = volumes
    .map((v) => {
      const type = v.VolumeType ?? "unknown";
      const price = PRICE_PER_GB_MONTH[type];
      const firstSeen = tagValue(v, TAG_KEY);
      return {
        id: v.VolumeId ?? "",
        name: tagValue(v, "Name") ?? "",
        type,
        sizeGiB: v.Size ?? 0,
        zone: v.AvailabilityZone ?? "",
        created: v.CreateTime?.toISOString().slice(0, 10) ?? "",
        firstSeen,
        monthly: price === undefined ? undefined : (v.Size ?? 0) * price,
      };
    })
    .sort((a, b) => (b.monthly ?? 0) - (a.monthly ?? 0));

  console.table(
    rows.map((r) => ({
      Volume: r.id,
      Name: r.name,
      Type: r.type,
      "Size GiB": r.sizeGiB,
      Zone: r.zone,
      Created: r.created,
      "Unattached (days seen)": r.firstSeen ? daysSince(r.firstSeen) ?? "?" : "new",
      "Est. $/month": r.monthly === undefined ? "n/a" : r.monthly.toFixed(2),
    })),
  );
  const total = rows.reduce((s, r) => s + (r.monthly ?? 0), 0);
  const totalGiB = rows.reduce((s, r) => s + r.sizeGiB, 0);
  console.log(`${rows.length} unattached volumes, ${totalGiB} GiB, about $${total.toFixed(2)}/month in storage`);

  const untagged = rows.filter((r) => !r.firstSeen).map((r) => r.id);
  if (!applyTags) {
    console.log(`Report only. ${untagged.length} volumes would get ${TAG_KEY}=${today}. Re-run with --tag to apply.`);
    return;
  }

  // Tag only volumes without the tag, so the first-seen date survives later runs.
  for (let i = 0; i < untagged.length; i += 50) {
    const batch = untagged.slice(i, i + 50);
    await ec2.send(new CreateTagsCommand({ Resources: batch, Tags: [{ Key: TAG_KEY, Value: today }] }));
    console.log(`Tagged ${batch.join(", ")}`);
  }
  console.log(`Done. ${untagged.length} volumes tagged; ${rows.length - untagged.length} already had the tag.`);
}

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

Volume types without a price in the table, such as io2, show n/a rather than a guessed figure. Update the table for your region if you don’t run in US East (N. Virginia).

How do you run it?

Terminal

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

# 1. Report only (default): nothing is changed
AWS_PROFILE=readonly AWS_REGION=us-east-1 npx tsx tag-unattached-ebs.ts

# 2. Tag volumes that don't have the review tag yet
AWS_PROFILE=ops AWS_REGION=us-east-1 npx tsx tag-unattached-ebs.ts --tag

Sample output

Output (report-only run)

┌─────────┬─────────────────────────┬────────────────┬───────┬──────────┬──────────────┬──────────────┬────────────────────────┬──────────────┐
│ (index) │ Volume                  │ Name           │ Type  │ Size GiB │ Zone         │ Created      │ Unattached (days seen) │ Est. $/month │
├─────────┼─────────────────────────┼────────────────┼───────┼──────────┼──────────────┼──────────────┼────────────────────────┼──────────────┤
│ 0       │ 'vol-0a1b2c3d4e5f60718' │ 'old-db-data'  │ 'gp2' │ 500      │ 'us-east-1a' │ '2025-11-03' │ 34                     │ '50.00'      │
│ 1       │ 'vol-0123456789abcdef0' │ ''             │ 'gp3' │ 200      │ 'us-east-1b' │ '2026-08-14' │ 'new'                  │ '16.00'      │
│ 2       │ 'vol-0fedcba9876543210' │ 'backup-cold'  │ 'sc1' │ 1000     │ 'us-east-1a' │ '2026-02-20' │ 12                     │ '15.00'      │
│ 3       │ 'vol-0b2c3d4e5f6071829' │ 'perf-test'    │ 'io2' │ 100      │ 'us-east-1c' │ '2026-09-02' │ 'new'                  │ 'n/a'        │
└─────────┴─────────────────────────┴────────────────┴───────┴──────────┴──────────────┴──────────────┴────────────────────────┴──────────────┘
4 unattached volumes, 1800 GiB, about $81.00/month in storage
Report only. 2 volumes would get cost-review:unattached-first-seen=2026-09-27. Re-run with --tag to apply.

Volume IDs and figures are illustrative. The io2 volume isn’t in the price table, so it’s left out of the total. If io1 or io2 volumes are still attached, the script to find overprovisioned EBS IOPS compares provisioned and used IOPS.

How much do unattached EBS volumes cost?

You pay for the provisioned size of a volume whether or not it’s attached. Storage prices per GB-month in US East (N. Virginia), as of September 2026, from AWS’s published EBS pricing:

Volume type Storage price per GB-month 500 GiB unattached for a month
gp3 $0.08 500 × 0.08 = $40.00
gp2 $0.10 500 × 0.10 = $50.00
io1 $0.125 (plus provisioned IOPS) 500 × 0.125 = $62.50 before IOPS
st1 $0.045 500 × 0.045 = $22.50
sc1 $0.015 500 × 0.015 = $7.50

Standard EBS snapshots cost $0.05 per GB-month in the same region and are incremental, so snapshotting a volume before you delete it is usually much cheaper than keeping it. Those snapshots outlive the volume, so once the review period is over, the script to find orphaned EBS snapshots whose volume is gone shows which ones are still worth keeping. For comparison with object storage, the free S3 storage cost calculator shows what the same data would cost in S3 if it doesn’t need to live on a block device. For attached volumes, the table also shows why it pays to convert EBS gp2 volumes to gp3: the same 500 GiB costs $10 a month less.

Troubleshooting

  • UnauthorizedOperation on CreateTags. Either the profile lacks the second statement or the tag key doesn’t match the condition exactly. The IAM access denied guide in the related links below covers decoding the authorization message.
  • A volume you expected is missing. It may be attached to a stopped instance (in-use), or in another region. Stopped instances are covered by the sibling script to detect underutilized EC2 instances by CPU.
  • Volumes keep coming back. Check launch templates and BlockDeviceMappings for DeleteOnTermination: false on data volumes.
  • Estimated cost doesn’t match the bill. The table covers storage only in one region. IOPS, throughput and regional prices differ; EC2 - Other in Cost Explorer is the source of truth.

Ask ChatWithCloud instead

You can also ask ChatWithCloud “Which EBS volumes are unattached, and how big are they?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your AWS profile and summarizes the answer, the same way it handles the questions in the guide to list AWS resources in plain English from your terminal. Keep tagging and deletion out of the conversation unless you mean it: ChatWithCloud runs changes without a confirmation step. Connect ChatWithCloud with a read-only AWS profile for this kind of audit, and read how ChatWithCloud runs AWS SDK code locally to see exactly what happens between your question and the answer.

Frequently asked questions

How do I find unattached EBS volumes in the AWS CLI?

Run aws ec2 describe-volumes --filters Name=status,Values=available in each region. It returns the same volumes the script reports.

Do unattached EBS volumes cost money?

Yes. EBS bills the provisioned size of every volume, attached or not, plus provisioned IOPS and throughput where they apply.

Is it safe to delete an EBS volume in the available state?

Only once you know nobody needs the data. Take a snapshot first, and wait a review period, such as 30 days after the volume was tagged, before deleting.

How can I tell when a volume was detached?

DescribeVolumes doesn’t record it. CloudTrail DetachVolume events do, for as long as you retain them, which is why the script records its own first-seen date as a tag.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud