Find Overprovisioned RDS Allocated Storage

Long rows of mostly empty metal shelves in a large warehouse

Photo by Oxana Melis on Unsplash

RDS allocated storage is too high when a database has used only a small share of it for weeks. Compare AllocatedStorage from DescribeDBInstances with the lowest CloudWatch FreeStorageSpace over 30 days to get peak usage. RDS can’t shrink storage in place, so reduce it with a blue/green deployment or a new instance, and price the gap first.

RDS storage only grows. Someone picks 1,000 GiB “to be safe”, storage autoscaling adds 10% during a one-off data load, a migration leaves temp tables that were dropped months ago, and the bill keeps the high-water mark. This example is for engineers and FinOps leads who want to know which databases have RDS allocated storage too high for their data, what that costs each month, and whether the storage type itself is the bigger saving.

The script is report only. Shrinking RDS storage means moving to a new volume, so it’s a planned change, not something a script should do on its own.

Why can’t you just reduce RDS allocated storage?

The RDS documentation is direct about it: you can increase allocated space by at least 10%, but you can’t deallocate space, and storage autoscaling never scales down. There are two supported ways to end up with less:

  • Blue/green deployment. Create a green copy with a lower allocated storage, let it sync, then switch over. The reduction only happens if the target is at least 20% more than current usage (the documented minimum is allocated × utilization × 1.2), and it runs a storage configuration upgrade that can take a long time on large databases. AWS announced the feature for RDS for PostgreSQL 12 and higher, MySQL 5.7 and higher and MariaDB 10.4 and higher.
  • New instance and migration. Create a smaller instance, move the data with native dump and restore or replication, and switch endpoints. This works for every engine but needs more planning.

Either way the work is real, so the report sorts by money: fix the largest gaps first. Deleting data before the move matters too; a copy that’s twice the size of the data it holds is also twice as slow to migrate.

What does overprovisioned RDS storage cost?

As of September 2026, the AWS Price List for Amazon RDS (published 24 September 2026) gives these us-east-1 prices for MySQL, PostgreSQL and MariaDB instances:

Storage type Single-AZ Multi-AZ (one standby)
gp2 per GB-month $0.115 $0.23
gp3 per GB-month $0.115 $0.23
gp3 extra IOPS per IOPS-month $0.02 $0.04
gp3 extra throughput per MiBps-month $0.08 $0.16
io1 / io2 per GB-month $0.125 $0.25
io1 / io2 per provisioned IOPS-month $0.10 $0.20

Provisioned storage is billed whether it holds data or not. Worked example: a Multi-AZ PostgreSQL instance with 1,000 GiB of gp3 that has never used more than 271 GiB. A target of 410 GiB leaves 50% headroom. The difference is 590 GiB × $0.23 = $135.70 a month, or $1,628.40 a year, for space that has never held data.

Two storage-type facts change the math. In RDS, gp3 costs the same per GB as gp2 in the price list, and on MySQL, PostgreSQL and MariaDB it includes a 3,000 IOPS and 125 MiB/s baseline below 400 GiB (12,000 IOPS and 500 MiB/s at 400 GiB and above). So gp2 to gp3 isn’t a per-GB saving here, unlike EBS, where converting EBS gp2 volumes to gp3 cuts the GB price. And io1 at 3,000 IOPS or less pays $300 a month for IOPS that gp3 includes; that’s often a bigger saving than the size itself. For EC2 volumes with the same problem, see finding overprovisioned EBS IOPS on io1 and io2.

What does the script do?

  1. Lists DB instancespaginateDescribeDBInstances per Region, skipping Aurora, whose storage belongs to the cluster and is billed on what it uses.
  2. Reads free spaceOne GetMetricData query per instance for the daily minimum of FreeStorageSpace over 30 days. The lowest value marks the peak used storage.
  3. Suggests a targetPeak used × 1.5, rounded up to 10 GiB, never below the storage type’s minimum (20 GiB for gp2 and gp3, 100 GiB for io1 and io2 on these engines). The 1.5 factor stays above the blue/green 1.2 minimum with room to grow.
  4. Prices the gapAllocated minus target, times the per-GB price, doubled for Multi-AZ.
  5. Adds notesgp2 and low-IOPS io1 that could move to gp3, autoscaling ceilings, and gp3 targets that would drop below the 400 GiB performance threshold.

FreeStorageSpace is what the engine reports as free, so logs, temporary files and bloat count as used. It’s a safe measure for sizing, not an exact data size.

Prerequisites

  • Node.js 18 or later with tsx, @aws-sdk/client-rds and @aws-sdk/client-cloudwatch.
  • A read-only profile; connecting AWS profiles, SSO and assumed roles shows how to set one up.
  • At least 30 days of history for the instances you care about, so month-end peaks are included.

Which IAM permissions does it need?

rds-storage-report-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadDbInstances",
      "Effect": "Allow",
      "Action": "rds:DescribeDBInstances",
      "Resource": "*"
    },
    {
      "Sid": "ReadStorageMetrics",
      "Effect": "Allow",
      "Action": "cloudwatch:GetMetricData",
      "Resource": "*"
    }
  ]
}

Both actions are read-only. The IAM policy generator for TypeScript code can check the policy if you add calls.

The script to find RDS allocated storage that’s too high

find-overprovisioned-rds-storage.ts

// find-overprovisioned-rds-storage.ts
// Compares each RDS DB instance's allocated storage with the lowest FreeStorageSpace seen over the
// last 30 days, suggests a smaller target size, and prices the difference. Also flags gp2 and io1
// storage that could move to gp3. Report only: RDS can't shrink storage in place.
// Usage: npx tsx find-overprovisioned-rds-storage.ts [--regions us-east-1,eu-west-1] [--days 30]
import { RDSClient, paginateDescribeDBInstances, type DBInstance } from "@aws-sdk/client-rds";
import { CloudWatchClient, paginateGetMetricData, type MetricDataQuery } from "@aws-sdk/client-cloudwatch";

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 = Math.min(Math.max(Number(flag("--days") ?? 30), 7), 90);
const HEADROOM = 1.5; // target = peak used storage x 1.5, so the database stays well under 70% full
const GIB = 1024 ** 3;

// us-east-1 list prices per GB-month and per provisioned IOPS-month (AWS Price List, September 2026).
// Multi-AZ DB instance deployments are billed at twice these rates.
const STORAGE_PRICE: Record<string, number> = { gp2: 0.115, gp3: 0.115, io1: 0.125, io2: 0.125 };
const IOPS_PRICE: Record<string, number> = { io1: 0.1, io2: 0.1, gp3: 0.02 }; // gp3: only IOPS above the included baseline
const MIN_STORAGE: Record<string, number> = { gp2: 20, gp3: 20, io1: 100, io2: 100 };

interface Row {
  Instance: string;
  Engine: string;
  Type: string;
  AllocatedGiB: number;
  PeakUsedGiB: number | string;
  TargetGiB: number | string;
  SavePerMonth: string;
  Note: string;
}

const errorText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));

/** Lowest FreeStorageSpace (bytes) per instance over the window. */
async function minFreeSpace(cw: CloudWatchClient, ids: string[]): Promise<Map<string, number>> {
  const end = new Date();
  const start = new Date(end.getTime() - days * 86_400_000);
  const queries: MetricDataQuery[] = ids.map((id, i) => ({
    Id: `f${i}`,
    MetricStat: {
      Metric: { Namespace: "AWS/RDS", MetricName: "FreeStorageSpace", Dimensions: [{ Name: "DBInstanceIdentifier", Value: id }] },
      Period: 86_400,
      Stat: "Minimum",
    },
  }));
  const out = new Map<string, number>();
  for (let i = 0; i < queries.length; i += 500) {
    const pages = paginateGetMetricData({ client: cw }, { MetricDataQueries: queries.slice(i, i + 500), StartTime: start, EndTime: end });
    for await (const page of pages) {
      for (const r of page.MetricDataResults ?? []) {
        const values = r.Values ?? [];
        if (!r.Id || values.length === 0) continue;
        const id = ids[Number(r.Id.slice(1))];
        out.set(id, Math.min(out.get(id) ?? Infinity, ...values));
      }
    }
  }
  return out;
}

function assess(db: DBInstance, minFree: number | undefined): Row {
  const type = db.StorageType ?? "unknown";
  const allocated = db.AllocatedStorage ?? 0;
  const multiAz = db.MultiAZ ? 2 : 1;
  const notes: string[] = [];
  if (type === "gp2") notes.push("gp2: gp3 has the same per-GB price here and a 3,000 IOPS baseline");
  if (type === "io1" && (db.Iops ?? 0) <= 3000) notes.push("io1 at 3,000 IOPS or less: gp3 includes that at no IOPS charge");
  if (db.MaxAllocatedStorage && db.MaxAllocatedStorage > allocated) notes.push(`autoscaling up to ${db.MaxAllocatedStorage} GiB`);

  const row: Row = {
    Instance: db.DBInstanceIdentifier ?? "",
    Engine: db.Engine ?? "",
    Type: type,
    AllocatedGiB: allocated,
    PeakUsedGiB: "-",
    TargetGiB: "-",
    SavePerMonth: "-",
    Note: "",
  };
  if (minFree === undefined) {
    notes.push("no FreeStorageSpace data");
  } else {
    const peakUsed = Math.max(allocated - minFree / GIB, 0);
    const target = Math.max(Math.ceil((peakUsed * HEADROOM) / 10) * 10, MIN_STORAGE[type] ?? 20);
    row.PeakUsedGiB = Math.round(peakUsed);
    row.TargetGiB = target;
    const price = STORAGE_PRICE[type];
    if (target < allocated && price !== undefined) {
      row.SavePerMonth = `$${((allocated - target) * price * multiAz).toFixed(2)}`;
      if (type === "gp3" && allocated >= 400 && target < 400 && !db.Engine?.startsWith("sqlserver")) {
        notes.push("below 400 GiB gp3 drops to a 3,000 IOPS / 125 MiB/s baseline");
      }
    } else {
      row.SavePerMonth = "$0.00";
    }
  }
  if ((type === "io1" || type === "io2") && db.Iops) {
    notes.push(`IOPS cost $${(db.Iops * (IOPS_PRICE[type] ?? 0) * multiAz).toFixed(2)}/month`);
  }
  row.Note = notes.join("; ");
  return row;
}

async function scanRegion(region: string): Promise<Row[]> {
  const rds = new RDSClient({ region });
  const dbs: DBInstance[] = [];
  for await (const page of paginateDescribeDBInstances({ client: rds }, {})) {
    for (const db of page.DBInstances ?? []) {
      // Aurora storage belongs to the cluster and is billed on what you use, so skip Aurora instances.
      if (db.DBInstanceIdentifier && !db.Engine?.startsWith("aurora")) dbs.push(db);
    }
  }
  const free = await minFreeSpace(new CloudWatchClient({ region }), dbs.map((d) => d.DBInstanceIdentifier ?? ""));
  return dbs.map((db) => assess(db, free.get(db.DBInstanceIdentifier ?? "")));
}

async function main(): Promise<void> {
  for (const region of regions) {
    try {
      const rows = await scanRegion(region);
      if (rows.length === 0) {
        console.log(`${region}: no RDS DB instances (Aurora excluded)`);
        continue;
      }
      rows.sort((a, b) => Number(b.SavePerMonth.replace(/[$-]/g, "") || 0) - Number(a.SavePerMonth.replace(/[$-]/g, "") || 0));
      console.log(`\n${region}: peak used = allocated - lowest FreeStorageSpace over ${days} days`);
      console.table(rows);
      const total = rows.reduce((sum, r) => sum + Number(r.SavePerMonth.replace(/[$-]/g, "") || 0), 0);
      console.log(`Storage you could stop paying for: about $${total.toFixed(2)} a month at us-east-1 list prices.`);
    } catch (err) {
      console.error(`${region}: ${errorText(err)}`);
    }
  }
}

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

Both paginators follow continuation tokens for you; the AWS SDK v3 paginators guide explains the pattern.

How do you run it?

Terminal

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

AWS_PROFILE=readonly npx tsx find-overprovisioned-rds-storage.ts --regions us-east-1,eu-west-1 --days 30

Sample output

Output


us-east-1: peak used = allocated - lowest FreeStorageSpace over 30 days
┌─────────┬─────────────────┬────────────┬───────┬──────────────┬─────────────┬───────────┬──────────────┬───────────────────────────────────────────────────────────────────────────────────────────┐
│ (index) │ Instance        │ Engine     │ Type  │ AllocatedGiB │ PeakUsedGiB │ TargetGiB │ SavePerMonth │ Note                                                                                      │
├─────────┼─────────────────┼────────────┼───────┼──────────────┼─────────────┼───────────┼──────────────┼───────────────────────────────────────────────────────────────────────────────────────────┤
│ 0       │ 'orders-prod'   │ 'postgres' │ 'gp3' │ 1000         │ 271         │ 410       │ '$135.70'    │ 'autoscaling up to 2000 GiB'                                                              │
│ 1       │ 'billing-mysql' │ 'mysql'    │ 'gp2' │ 500          │ 62          │ 100       │ '$46.00'     │ 'gp2: gp3 has the same per-GB price here and a 3,000 IOPS baseline'                       │
│ 2       │ 'analytics-pg'  │ 'postgres' │ 'io1' │ 400          │ 51          │ 100       │ '$37.50'     │ 'io1 at 3,000 IOPS or less: gp3 includes that at no IOPS charge; IOPS cost $300.00/month' │
│ 3       │ 'wiki-db'       │ 'mariadb'  │ 'gp3' │ 100          │ 72          │ 110       │ '$0.00'      │ ''                                                                                        │
└─────────┴─────────────────┴────────────┴───────┴──────────────┴─────────────┴───────────┴──────────────┴───────────────────────────────────────────────────────────────────────────────────────────┘
Storage you could stop paying for: about $219.20 a month at us-east-1 list prices.

This run used mocked RDS and CloudWatch responses. orders-prod is the worked example above. analytics-pg saves $37.50 on size, but its note matters more: it pays $300 a month for 3,000 io1 IOPS, while gp3 at 400 GiB or more includes 12,000. billing-mysql could drop to 100 GiB; moving it from gp2 to gp3 at the same time keeps a 3,000 IOPS baseline that a 100 GiB gp2 volume wouldn’t have. wiki-db is already sized right, and the Aurora instance was skipped.

How do you shrink it safely?

  • Check the performance threshold. On gp3, going below 400 GiB drops the baseline from 12,000 IOPS to 3,000. Check ReadIOPS and WriteIOPS before you pick a target, or provision IOPS explicitly.
  • Set an autoscaling ceiling. After shrinking, keep storage autoscaling on with a sensible MaxAllocatedStorage, so a runaway load can’t push it back to 2,000 GiB unnoticed.
  • Clean up first. Drop unused tables and old partitions before creating the green environment.
  • Plan for the storage upgrade. A blue/green storage reduction includes a storage configuration upgrade that’s I/O heavy; AWS notes it’s faster on Provisioned IOPS storage and larger instance sizes.

Storage is one line of the RDS bill. The scripts to find idle RDS instances with no connections, find and delete old RDS manual snapshots and find RDS databases on extended support cover the others. The FinOps Foundation’s usage optimization capability describes rightsizing as reducing resources with consistently low utilization, which is exactly this case.

Troubleshooting

  • “no FreeStorageSpace data”. The instance is new, stopped for the whole window, or the identifier changed. Run it again later or with a shorter --days (minimum 7).
  • Peak used looks too high. Transaction logs, replication slots or binary logs can fill space temporarily. Look at the daily values in CloudWatch before sizing to the peak.
  • SQL Server or Oracle numbers. The price constants are for MySQL, PostgreSQL and MariaDB. Licensed engines and additional storage volumes price differently.
  • Access denied. Compare your role with the policy above and see troubleshooting AWS IAM access denied errors.

Ask ChatWithCloud instead

For a quick check, ask ChatWithCloud “Which RDS instances have more than half their storage free?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile, and summarizes the result; how ChatWithCloud runs AWS queries covers the details. It can be wrong and makes changes without asking for confirmation, so use a read-only profile and plan storage changes yourself. More reports like this are in the AWS practical examples library.

Frequently asked questions

Can I decrease allocated storage on an RDS instance?

Not in place. Use a blue/green deployment with a lower allocated storage (for supported engines), or create a smaller instance and migrate the data.

How do I find how much RDS storage is actually used?

Subtract the CloudWatch FreeStorageSpace metric from AllocatedStorage. Use the minimum over 30 days to capture the peak.

Is gp3 cheaper than gp2 on RDS?

Not per GB in us-east-1: both are $0.115 per GB-month Single-AZ as of September 2026. gp3 includes a 3,000 IOPS baseline on small volumes, which gp2 only reaches as a baseline at 1,000 GiB (smaller gp2 volumes can burst to it).

Does storage autoscaling ever reduce RDS storage?

No. Autoscaling only increases storage, and after it scales up, the allocated storage can’t be reduced in place.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud