
Photo by panumas nikhomkhai on Pexels
To find idle RDS instances, list them with DescribeDBInstances and read the CloudWatch DatabaseConnections metric for each one over the last 14 days. An instance whose maximum stayed at zero had no client connections at all in that window. The script below flags those instances, adds average CPU for context, estimates the monthly instance cost and changes nothing.
Databases are the resources people are most nervous about deleting, so idle ones linger longest. A reporting replica outlives its dashboard, a QA database stays up after the test environment moves, or a migration leaves the old instance running “just in case”. This example is for engineers who want to find idle RDS instances in a region with a number behind each one before they talk to the owner. You’ll get a read-only TypeScript script for the AWS SDK for JavaScript v3.
It’s one of our AWS cost cleanup examples with complete scripts. Idle databases leave backups behind, so the script to find and delete old RDS manual snapshots is the natural next step after you retire one.
What does an idle RDS instance cost?
On-Demand instance prices from AWS’s price data for US East (N. Virginia), Single-AZ, as of September 2026. Multi-AZ deployments cost roughly double, and storage, I/O and backups are billed on top.
| Instance class | MySQL per hour | PostgreSQL per hour | MySQL per month (730 hours) |
|---|---|---|---|
| db.t4g.micro | $0.016 | $0.016 | $11.68 |
| db.t3.medium | $0.068 | $0.072 | $49.64 |
| db.m6g.large | $0.152 | $0.159 | $110.96 |
| db.m5.large | $0.171 | $0.178 | $124.83 |
| db.r6g.large | $0.215 | $0.225 | $156.95 |
An idle Multi-AZ db.r6g.large MySQL instance at $0.430 an hour costs 0.430 × 730 = $313.90 a month, about $3,767 a year, before storage.
Why use DatabaseConnections to spot an idle database?
DatabaseConnections in the AWS/RDS namespace counts client network connections to the instance. It leaves out sessions the engine creates for itself and connections Amazon RDS makes for monitoring, so a daily maximum of zero across two weeks means no application, BI tool or person connected at all. The script reads it with the Maximum statistic, one data point per day, so even a short nightly job shows up.
CPU alone is a weaker signal. Background work such as autovacuum, replication or backups can keep CPU above zero on a database nobody uses, while a busy but efficient database can idle at 3%. The script shows average CPUUtilization next to the connection count as context, not as the verdict. The same caution appears in the example to detect underutilized EC2 instances by CPU.
What does the script do?
- Lists DB instances
paginateDescribeDBInstances, keeping those with statusavailable. Stopped instances are skipped. - Reads 14 days of metrics
GetMetricDatawith a dailyMaximumofDatabaseConnectionsand daily averageCPUUtilization, up to 250 instances (500 queries) per request. - Labels each instance
IDLEwhen the maximum stays at or under--max-conns(default 0),too newwhen it was created inside the window,no datawhen CloudWatch returned nothing. - Estimates costFrom a small built-in price table for MySQL and PostgreSQL classes, doubled for Multi-AZ. Other engines and classes show
-. - Flags what limits your optionsAurora cluster membership and read replica relationships appear in their own columns.
Prerequisites
- Node.js 18 or later, npm and
tsx. - The
@aws-sdk/client-rdsand@aws-sdk/client-cloudwatchpackages. - A profile with a default region, or
AWS_REGIONset. The script covers one region per run.
Which IAM permissions does it need?
Two read-only actions:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadRdsInstancesAndMetrics",
"Effect": "Allow",
"Action": [
"rds:DescribeDBInstances",
"cloudwatch:GetMetricData"
],
"Resource": "*"
}
]
}
To scope rds:DescribeDBInstances to specific databases, use arn:aws:rds:us-east-1:123456789012:db:* style ARNs. The AI IAM policy generator for TypeScript AWS code keeps the policy in step if you add calls such as StopDBInstance.
The full script to find idle RDS instances
// find-idle-rds-instances.ts
// Reports RDS DB instances in one region whose DatabaseConnections metric stayed at zero
// (or under --max-conns) for the last --days days, with an estimate of the instance-hour cost. Read-only.
// Usage: npx tsx find-idle-rds-instances.ts [--days 14] [--max-conns 0]
import { RDSClient, paginateDescribeDBInstances, type DBInstance } from "@aws-sdk/client-rds";
import { CloudWatchClient, GetMetricDataCommand } from "@aws-sdk/client-cloudwatch";
// On-Demand USD per hour, Single-AZ, us-east-1, as of September 2026. Multi-AZ is roughly double.
// Add your own classes and engines; unknown ones print "-".
const PER_HOUR: Record<string, Record<string, number>> = {
mysql: { "db.t4g.micro": 0.016, "db.t3.micro": 0.017, "db.t4g.medium": 0.065, "db.t3.medium": 0.068, "db.m6g.large": 0.152, "db.m7g.large": 0.168, "db.m5.large": 0.171, "db.r6g.large": 0.215 },
postgres: { "db.t4g.micro": 0.016, "db.t3.micro": 0.018, "db.t4g.medium": 0.065, "db.t3.medium": 0.072, "db.m6g.large": 0.159, "db.m7g.large": 0.168, "db.m5.large": 0.178, "db.r6g.large": 0.225 },
};
const HOURS_PER_MONTH = 730;
function arg(name: string): string | undefined {
const i = process.argv.indexOf(name);
return i === -1 ? undefined : process.argv[i + 1];
}
const rds = new RDSClient({}); // region from AWS_REGION or your profile
const cw = new CloudWatchClient({});
// Daily maximum connections and average CPU for up to 250 instances per request (500 queries max).
async function metrics(ids: string[], days: number) {
const out = new Map<string, { maxConns: number; avgCpu: number; points: number }>();
const end = new Date();
const start = new Date(end.getTime() - days * 86_400_000);
const q = (id: string, n: number, metric: string, stat: string) => ({
Id: `${metric === "DatabaseConnections" ? "c" : "u"}${n}`,
MetricStat: {
Metric: { Namespace: "AWS/RDS", MetricName: metric, Dimensions: [{ Name: "DBInstanceIdentifier", Value: id }] },
Period: 86_400,
Stat: stat,
},
});
for (let i = 0; i < ids.length; i += 250) {
const batch = ids.slice(i, i + 250);
const queries = batch.flatMap((id, n) => [q(id, n, "DatabaseConnections", "Maximum"), q(id, n, "CPUUtilization", "Average")]);
let NextToken: string | undefined;
const values = new Map<string, number[]>();
do {
const res = await cw.send(new GetMetricDataCommand({ StartTime: start, EndTime: end, MetricDataQueries: queries, NextToken }));
for (const r of res.MetricDataResults ?? []) values.set(r.Id ?? "", [...(values.get(r.Id ?? "") ?? []), ...(r.Values ?? [])]);
NextToken = res.NextToken;
} while (NextToken);
batch.forEach((id, n) => {
const conns = values.get(`c${n}`) ?? [];
const cpu = values.get(`u${n}`) ?? [];
out.set(id, {
maxConns: Math.max(0, ...conns),
avgCpu: cpu.length ? cpu.reduce((s, x) => s + x, 0) / cpu.length : 0,
points: conns.length,
});
});
}
return out;
}
async function main(): Promise<void> {
const days = Number(arg("--days") ?? 14);
const maxConns = Number(arg("--max-conns") ?? 0);
const instances: DBInstance[] = [];
for await (const page of paginateDescribeDBInstances({ client: rds }, {})) {
instances.push(...(page.DBInstances ?? []).filter((d) => d.DBInstanceStatus === "available"));
}
if (instances.length === 0) {
console.log("No available DB instances in this region.");
return;
}
const m = await metrics(instances.map((d) => d.DBInstanceIdentifier ?? ""), days);
const rows = [];
let idleCost = 0;
for (const db of instances) {
const id = db.DBInstanceIdentifier ?? "";
const stats = m.get(id) ?? { maxConns: 0, avgCpu: 0, points: 0 };
const ageDays = db.InstanceCreateTime ? (Date.now() - db.InstanceCreateTime.getTime()) / 86_400_000 : days;
const rate = PER_HOUR[db.Engine ?? ""]?.[db.DBInstanceClass ?? ""];
const monthly = rate === undefined ? undefined : rate * (db.MultiAZ ? 2 : 1) * HOURS_PER_MONTH;
const verdict =
ageDays < days ? "too new"
: stats.points === 0 ? "no data"
: stats.maxConns <= maxConns ? "IDLE"
: "in use";
if (verdict === "IDLE" && monthly !== undefined) idleCost += monthly;
rows.push({
Instance: id,
Engine: db.Engine ?? "",
Class: db.DBInstanceClass ?? "",
"Multi-AZ": db.MultiAZ ? "yes" : "no",
Cluster: db.DBClusterIdentifier ?? "",
Replica: db.ReadReplicaSourceDBInstanceIdentifier ? "replica" : (db.ReadReplicaDBInstanceIdentifiers ?? []).length ? "has replicas" : "",
[`Max conns (${days}d)`]: stats.maxConns,
"Avg CPU %": stats.avgCpu.toFixed(1),
"Instance $/mo": monthly === undefined ? "-" : monthly.toFixed(2),
Verdict: verdict,
});
}
console.table(rows);
console.log(`Idle instances with a known price: about $${idleCost.toFixed(2)}/month in instance hours (storage and backups extra).`);
console.log("Report only: nothing was stopped or deleted.");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
GetMetricData is billed per metric requested, so a run over a few dozen instances costs a fraction of a cent. Extend PER_HOUR with your own classes and engines from the RDS pricing pages.
How do you run it?
npm install @aws-sdk/client-rds @aws-sdk/client-cloudwatch
npm install --save-dev tsx typescript
# No client connections in 14 days
AWS_PROFILE=readonly AWS_REGION=us-east-1 npx tsx find-idle-rds-instances.ts
# 30 days, treating a single health-check connection as idle
AWS_PROFILE=readonly AWS_REGION=us-east-1 npx tsx find-idle-rds-instances.ts --days 30 --max-conns 1
Sample output
┌─────────┬─────────────────┬─────────────────────┬─────────────────┬──────────┬─────────────┬─────────┬─────────────────┬───────────┬───────────────┬──────────┐
│ (index) │ Instance │ Engine │ Class │ Multi-AZ │ Cluster │ Replica │ Max conns (14d) │ Avg CPU % │ Instance $/mo │ Verdict │
├─────────┼─────────────────┼─────────────────────┼─────────────────┼──────────┼─────────────┼─────────┼─────────────────┼───────────┼───────────────┼──────────┤
│ 0 │ 'orders-prod' │ 'postgres' │ 'db.m6g.large' │ 'yes' │ '' │ '' │ 212 │ '23.4' │ '232.14' │ 'in use' │
│ 1 │ 'reporting-old' │ 'mysql' │ 'db.m5.large' │ 'no' │ '' │ '' │ 0 │ '2.1' │ '124.83' │ 'IDLE' │
│ 2 │ 'qa-db' │ 'postgres' │ 'db.t4g.medium' │ 'no' │ '' │ '' │ 0 │ '3.8' │ '47.45' │ 'IDLE' │
│ 3 │ 'analytics-1' │ 'aurora-postgresql' │ 'db.r6g.large' │ 'no' │ 'analytics' │ '' │ 0 │ '4.9' │ '-' │ 'IDLE' │
└─────────┴─────────────────┴─────────────────────┴─────────────────┴──────────┴─────────────┴─────────┴─────────────────┴───────────┴───────────────┴──────────┘
Idle instances with a known price: about $172.28/month in instance hours (storage and backups extra).
Report only: nothing was stopped or deleted.
Names and figures are illustrative. orders-prod is a Multi-AZ PostgreSQL db.m6g.large: 0.159 × 2 × 730 = $232.14. The two priced idle instances add up to 124.83 + 47.45 = $172.28 a month. analytics-1 belongs to an Aurora cluster, which isn’t in the price table.
Should you stop or delete an idle RDS instance?
It depends on whether anyone will need it again. The options, with what keeps billing:
| Option | What you still pay for | Watch out for |
|---|---|---|
| Stop temporarily | Provisioned storage (including Provisioned IOPS), backups, and a public IPv4 address if publicly accessible | RDS starts it again automatically after 7 consecutive days |
| Snapshot, then delete | Snapshot storage only | Restoring creates a new DB instance; old snapshots need their own cleanup |
| Downsize | A smaller instance class | Only makes sense if it’s lightly used, not idle |
Stopping is not a long-term fix because of the 7-day automatic restart (unlike EC2, where the script to find EC2 instances stopped for weeks shows instances can stay stopped, and billed for storage, indefinitely), and some instances can’t be stopped at all: read replicas, instances that have read replicas, and RDS for SQL Server in a Multi-AZ deployment. Aurora is managed at the cluster level. For a database nobody has touched in weeks, a final snapshot followed by deletion is usually the cleaner answer; the FinOps Foundation’s Usage Optimization capability treats removing resources that are no longer used as a core practice. Check with the owner first, and search application configs for the endpoint. If an idle database has to stay for now, make sure it isn’t reachable from the internet in the meantime; the script to find publicly accessible RDS instances checks the flag, routes and security groups.
Troubleshooting
- Every instance shows
no data. Check that the profile’s region matches the instances, and that the account running the script owns them. - A database you know is busy shows
IDLE. Check the window: a monthly batch job won’t appear in 14 days. Try--days 45. AccessDeniedonGetMetricData. RDS-only policies often leave out CloudWatch. The walkthrough to troubleshoot AWS IAM access denied errors helps find the gap.- Unexpected costs keep rising. Use the report to break last month’s AWS cost down by service to confirm RDS is where the money goes. If the application tier also has a cache, the script to find idle ElastiCache clusters applies the same CloudWatch check to Valkey, Redis OSS and Memcached.
Ask ChatWithCloud instead
ChatWithCloud can also find idle RDS instances. It answers “Which RDS instances had no connections in the last two weeks?” by writing AWS SDK for JavaScript v2 code for RDS and CloudWatch, running it on your machine with your AWS profile and explaining the result, much like the workflow in the guide to troubleshoot AWS infrastructure with an AI CLI. It runs generated code without a confirmation step, so ask for the report, not “stop them”. Connect ChatWithCloud with a read-only AWS profile and review the ChatWithCloud security model first.
Frequently asked questions
How do I check if an RDS database is being used?
Look at the DatabaseConnections metric with the Maximum statistic over two weeks or more. Zero means no client connected. CPU and IOPS alone can be misleading because of background work.
Am I charged for a stopped RDS instance?
Not for instance hours. You still pay for provisioned storage, backup storage and a public IPv4 address if the instance is publicly accessible.
Can I stop an RDS instance for more than 7 days?
Not directly. RDS starts a stopped instance automatically after 7 consecutive days. For longer, take a snapshot and delete the instance, or stop it again on a schedule.
Does deleting an RDS instance delete its snapshots?
Manual snapshots are kept until you delete them, and they keep billing. Review them later with the RDS snapshot cleanup script.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud