Find and Delete Old RDS Manual Snapshots

Rows of cardboard archive boxes stacked on metal shelves in a storage room

Photo by Nana Smirnova on Unsplash

To delete old RDS snapshots, list manual snapshots with DescribeDBSnapshots and SnapshotType: "manual", plus Aurora cluster snapshots with DescribeDBClusterSnapshots, filter by SnapshotCreateTime, and call DeleteDBSnapshot or DeleteDBClusterSnapshot on each one in the available state. Automated snapshots expire on their own, so leave them out.

Manual RDS snapshots never expire. They survive the deletion of the database they came from, and they keep counting toward backup storage until someone deletes them. This example is for engineers who want to find and delete old RDS snapshots across instances and Aurora clusters in a Region, with a report first and deletion only on request. The script uses AWS SDK for JavaScript v3 and deletes nothing unless you pass --delete --apply.

It belongs with our other AWS cost cleanup scripts in TypeScript, next to the one that can clean up AMIs and EBS snapshots older than 30 days. RDS snapshots live in a different API, so that script doesn’t see them. If the source database is still running with nobody connected, start with the script to find idle RDS instances with no connections.

Which RDS snapshots are safe to delete?

Snapshot type How it goes away Does the script touch it?
automated Expires at the end of the instance’s backup retention period No
manual (DB instance) Only when you delete it Yes, via DescribeDBSnapshots
manual (Aurora or Multi-AZ DB cluster) Only when you delete it Yes, via DescribeDBClusterSnapshots
awsbackup Managed by AWS Backup lifecycle rules No, delete those through AWS Backup
shared / public Owned by another account No

Aurora is the easy one to miss: Aurora clusters and Multi-AZ DB clusters take cluster snapshots, which DescribeDBSnapshots never returns. The script calls both APIs.

Before deleting, check two things the API can’t tell you. Some manual snapshots are the only copy of a decommissioned database kept for legal or audit reasons, and some are the source of a restore someone is planning. Tag those keep=true; the script skips them. Manual snapshots are also the only kind that can be shared publicly, which the script to find public EBS and RDS snapshots checks. They aren’t a substitute for automated backups either; the script to find RDS instances without automated backups or encryption confirms every database still has point-in-time recovery.

What do old RDS snapshots cost?

RDS doesn’t charge per snapshot. For RDS DB instances, automated backups and manual snapshots share one pool of backup storage per Region, as the RDS guide to backup storage describes, and backup storage up to 100% of your total provisioned database storage in that Region is included. Aurora has its own backup storage rules and a lower rate. You pay for what exceeds it. Prices in US East (N. Virginia) as of September 2026, as published on the Amazon RDS pricing page and in AWS’s Price List API:

Engine Backup storage beyond the free allocation
RDS for MySQL, PostgreSQL, MariaDB, Oracle, SQL Server, Db2 $0.095 per GB-month
Aurora MySQL and Aurora PostgreSQL $0.021 per GB-month

Snapshots are incremental: the first one of a database holds all its data, later ones only the blocks that changed. So deleting one snapshot frees only the data no other snapshot references, and the AllocatedStorage the script prints is the source volume size, not what the snapshot costs.

A worked example: you deleted a 400 GiB RDS for PostgreSQL instance last year but kept 6 manual snapshots of it, which together hold 520 GB of backup data. With the instance gone, its 400 GiB no longer counts toward your free allocation. If the rest of the Region’s backups already use up the allowance, the full 520 GB is billed: 520 × $0.095 = $49.40 per month, or $592.80 a year, for a database that doesn’t exist.

To see what you actually pay, filter Cost Explorer to Amazon RDS and group by usage type; backup storage appears under usage types containing ChargedBackupUsage (RDS) and BackupUsage (Aurora). Our examples to get last month’s AWS cost broken down by service and find your most expensive AWS service with Cost Explorer show the API side.

Prerequisites

  • Node.js 20 or later, npm and tsx.
  • The @aws-sdk/client-rds package.
  • A profile and Region. Snapshots are regional, so run the script once per Region you use, or loop over AWS_REGION values in a shell.

Which IAM permissions does it need?

The report needs the two describe actions. The delete statement is only for --delete --apply, and its deny on keep=true backs up the script’s own tag check at the IAM level. Replace the account ID and Region.

delete-old-rds-snapshots-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DescribeSnapshots",
      "Effect": "Allow",
      "Action": ["rds:DescribeDBSnapshots", "rds:DescribeDBClusterSnapshots"],
      "Resource": "*"
    },
    {
      "Sid": "DeleteManualSnapshots",
      "Effect": "Allow",
      "Action": ["rds:DeleteDBSnapshot", "rds:DeleteDBClusterSnapshot"],
      "Resource": [
        "arn:aws:rds:us-east-1:123456789012:snapshot:*",
        "arn:aws:rds:us-east-1:123456789012:cluster-snapshot:*"
      ]
    },
    {
      "Sid": "NeverDeleteKeptSnapshots",
      "Effect": "Deny",
      "Action": ["rds:DeleteDBSnapshot", "rds:DeleteDBClusterSnapshot"],
      "Resource": "*",
      "Condition": {
        "StringEqualsIgnoreCase": { "aws:ResourceTag/keep": "true" }
      }
    }
  ]
}

For a report-only role, keep the first statement alone; AWS’s ReadOnlyAccess policy also covers it. The IAM policy generator for TypeScript code drafts a policy from any variant of the script you write.

The script to delete old RDS snapshots

delete-old-rds-snapshots.ts

// delete-old-rds-snapshots.ts
// Finds manual RDS DB snapshots and manual Aurora / Multi-AZ cluster snapshots
// older than --days in the current Region. Read-only by default.
// --delete prints what would be deleted (dry run); --delete --apply deletes.
// Snapshots tagged keep=true are always skipped.
// Usage: npx tsx delete-old-rds-snapshots.ts [--days 90] [--delete [--apply]]
import {
  RDSClient,
  DeleteDBSnapshotCommand,
  DeleteDBClusterSnapshotCommand,
  paginateDescribeDBSnapshots,
  paginateDescribeDBClusterSnapshots,
  type Tag,
} from "@aws-sdk/client-rds";

type Snapshot = {
  kind: "instance" | "cluster";
  id: string;
  source: string;
  engine: string;
  created: Date;
  ageDays: number;
  allocatedGiB: number;
  status: string;
};

const DAY = 86_400_000;
const daysArg = process.argv.indexOf("--days");
const minAgeDays = daysArg > -1 ? Number(process.argv[daysArg + 1]) : 90;
if (!Number.isFinite(minAgeDays) || minAgeDays < 1) throw new Error("--days must be a positive number");
const doDelete = process.argv.includes("--delete");
const apply = process.argv.includes("--apply");

const rds = new RDSClient({});
const keep = (tags: Tag[] | undefined) =>
  (tags ?? []).some((t) => t.Key?.toLowerCase() === "keep" && t.Value?.toLowerCase() === "true");

async function findOldSnapshots(): Promise<Snapshot[]> {
  const now = Date.now();
  const found: Snapshot[] = [];

  // SnapshotType "manual" leaves out automated snapshots (they expire with the
  // backup retention period) and AWS Backup snapshots (type "awsbackup").
  for await (const page of paginateDescribeDBSnapshots({ client: rds }, { SnapshotType: "manual" })) {
    for (const s of page.DBSnapshots ?? []) {
      if (!s.DBSnapshotIdentifier || !s.SnapshotCreateTime || keep(s.TagList)) continue;
      found.push({
        kind: "instance",
        id: s.DBSnapshotIdentifier,
        source: s.DBInstanceIdentifier ?? "",
        engine: s.Engine ?? "",
        created: s.SnapshotCreateTime,
        ageDays: Math.floor((now - s.SnapshotCreateTime.getTime()) / DAY),
        allocatedGiB: s.AllocatedStorage ?? 0,
        status: s.Status ?? "",
      });
    }
  }

  // Aurora and Multi-AZ DB clusters keep their snapshots in a separate API.
  for await (const page of paginateDescribeDBClusterSnapshots({ client: rds }, { SnapshotType: "manual" })) {
    for (const s of page.DBClusterSnapshots ?? []) {
      if (!s.DBClusterSnapshotIdentifier || !s.SnapshotCreateTime || keep(s.TagList)) continue;
      found.push({
        kind: "cluster",
        id: s.DBClusterSnapshotIdentifier,
        source: s.DBClusterIdentifier ?? "",
        engine: s.Engine ?? "",
        created: s.SnapshotCreateTime,
        ageDays: Math.floor((now - s.SnapshotCreateTime.getTime()) / DAY),
        allocatedGiB: s.AllocatedStorage ?? 0,
        status: s.Status ?? "",
      });
    }
  }

  return found.filter((s) => s.ageDays > minAgeDays).sort((a, b) => a.created.getTime() - b.created.getTime());
}

async function main(): Promise<void> {
  const region = await rds.config.region();
  const old = await findOldSnapshots();
  console.log(`Manual snapshots older than ${minAgeDays} days in ${region}: ${old.length}`);
  console.table(
    old.map((s) => ({
      kind: s.kind,
      snapshot: s.id,
      source: s.source,
      engine: s.engine,
      created: s.created.toISOString().slice(0, 10),
      ageDays: s.ageDays,
      sourceGiB: s.allocatedGiB,
      status: s.status,
    })),
  );
  if (!doDelete) return;

  const deletable = old.filter((s) => s.status === "available");
  console.log(`\n${apply ? "Deleting" : "Dry run: would delete"} ${deletable.length} snapshots.`);
  for (const s of deletable) {
    console.log(`  ${s.kind.padEnd(8)} ${s.id}`);
    if (!apply) continue;
    if (s.kind === "instance") {
      await rds.send(new DeleteDBSnapshotCommand({ DBSnapshotIdentifier: s.id }));
    } else {
      await rds.send(new DeleteDBClusterSnapshotCommand({ DBClusterSnapshotIdentifier: s.id }));
    }
  }
  if (!apply && deletable.length > 0) console.log("Re-run with --delete --apply to delete them.");
}

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

Only snapshots in the available state are deleted; one that is still being created or copied would fail with InvalidDBSnapshotStateFault. Deletes run one at a time, which keeps the output readable and stays well away from API rate limits.

How do you run it?

Terminal

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

# Report manual snapshots older than 90 days
AWS_PROFILE=prod-readonly AWS_REGION=us-east-1 npx tsx delete-old-rds-snapshots.ts

# Older than 180 days, dry run of the delete
AWS_PROFILE=prod-admin AWS_REGION=us-east-1 npx tsx delete-old-rds-snapshots.ts --days 180 --delete

# Delete them
AWS_PROFILE=prod-admin AWS_REGION=us-east-1 npx tsx delete-old-rds-snapshots.ts --days 180 --delete --apply

# Protect a snapshot from the script (and from the Deny statement)
aws rds add-tags-to-resource \
  --resource-name arn:aws:rds:us-east-1:123456789012:snapshot:billing-db-final \
  --tags Key=keep,Value=true

Sample output

Output (illustrative)

Manual snapshots older than 180 days in us-east-1: 3
┌─────────┬────────────┬─────────────────────────────┬──────────────┬────────────────┬──────────────┬─────────┬───────────┬─────────────┐
│ (index) │ kind       │ snapshot                    │ source       │ engine         │ created      │ ageDays │ sourceGiB │ status      │
├─────────┼────────────┼─────────────────────────────┼──────────────┼────────────────┼──────────────┼─────────┼───────────┼─────────────┤
│ 0       │ 'instance' │ 'orders-db-pre-upgrade'     │ 'orders-db'  │ 'postgres'     │ '2025-01-14' │ 620     │ 400       │ 'available' │
│ 1       │ 'cluster'  │ 'analytics-before-migrate'  │ 'analytics'  │ 'aurora-mysql' │ '2025-06-02' │ 482     │ 250       │ 'available' │
│ 2       │ 'instance' │ 'orders-db-final'           │ 'orders-db'  │ 'postgres'     │ '2025-11-30' │ 301     │ 400       │ 'available' │
└─────────┴────────────┴─────────────────────────────┴──────────────┴────────────────┴──────────────┴─────────┴───────────┴─────────────┘

Dry run: would delete 3 snapshots.
  instance orders-db-pre-upgrade
  cluster  analytics-before-migrate
  instance orders-db-final
Re-run with --delete --apply to delete them.

Names and sizes are illustrative. orders-db-final looks like the final snapshot taken when the instance was deleted. If it’s the only copy of that data, tag it keep=true before running with --apply.

Troubleshooting

  • InvalidDBSnapshotStateFault or InvalidDBClusterSnapshotStateFault. The snapshot is still being created or copied. Wait and rerun; the script skips anything not available at listing time.
  • AccessDenied on delete. Check the Region in the resource ARNs, and whether the snapshot is tagged keep. The steps to troubleshoot AWS IAM access denied errors cover SCPs and boundaries.
  • A snapshot you expected is missing. It’s in another Region, it’s automated, or AWS Backup created it (type awsbackup). Snapshots copied to another Region are separate snapshots there.
  • The bill didn’t drop after deleting. Backup storage is billed per GB-month and only above the free allocation, and incremental data still referenced by remaining snapshots stays. Check again after a full billing day in Cost Explorer.

Ask ChatWithCloud instead

You can ask ChatWithCloud “Which manual RDS and Aurora snapshots are older than 90 days, and how big were their source databases?” from a read-only profile, and get the list with context in plain English; how ChatWithCloud answers AWS questions from your terminal explains the loop. It works in one Region per session. Because it runs generated code without asking for confirmation, don’t ask it to delete snapshots from a profile that allows it unless that is exactly what you want; the report-then-apply flow of this script is safer for deletions. For the bigger picture on a cost jump, see how to ask AI why your AWS bill increased.

Other cleanups that pay off in the same afternoon: find and tag unattached EBS volumes, release unassociated Elastic IP addresses and detect underutilized EC2 instances by CPU, or convert EBS gp2 volumes to gp3 for the volumes you keep. If you’re keeping long-term database exports in S3 instead, compare S3 storage class costs for backups.

Frequently asked questions

Do RDS manual snapshots expire?

No. Unlike automated backups, manual snapshots aren’t subject to the backup retention period. They stay, and are billed as backup storage, until you delete them, even after the DB instance is deleted.

How do I delete old RDS snapshots with the AWS CLI?

List them with aws rds describe-db-snapshots --snapshot-type manual, then run aws rds delete-db-snapshot --db-snapshot-identifier NAME for each. Use describe-db-cluster-snapshots and delete-db-cluster-snapshot for Aurora.

Can I delete automated RDS snapshots?

Not one by one. They’re removed when they pass the retention period. Setting an instance’s backup retention period to 0 deletes its automated backups and disables point-in-time recovery, so be careful.

Can I recover a deleted RDS snapshot?

No. Deletion is permanent, which is why the script reports first and needs both --delete and --apply.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud