RDS is the line nobody rightsizes, because nobody wants to touch the database
In most AWS accounts the RDS line is second or third on the bill, and it is the one that has not moved in two years. EC2 gets Karpenter and Spot. S3 gets lifecycle rules. The databases got sized once during a migration, by someone who no longer works here, and every instance has been running 24/7 at 4% CPU ever since. Nobody rightsizes it because the downside of a bad database change is an outage with data loss, and the upside is a few hundred dollars a month per instance. That asymmetry is exactly why this is an agent job: the evidence gathering is tedious and repetitive, the decisions are mostly obvious once the evidence is in front of you, and the risky part can be fenced off completely.
This post builds that agent. Deterministic Python collects 30 days of evidence per instance: CPU, connections, IOPS, burst balance, free storage, storage type, Multi-AZ, engine version, instance family, tags, and manual snapshot age. An LLM classifies each instance into one of six findings and writes the explanation a human reviewer needs. Changes that are safe and online ship as Terraform pull requests. Changes that interrupt a database ship as tickets with a dollar figure attached. The agent's IAM role cannot modify, stop, reboot, or delete anything, and that is enforced with an explicit deny, not a promise.
It is the database sibling of the AWS waste reclamation agent, which deliberately skipped RDS because the blast radius is different.
Where RDS money actually hides
| Leak | Signal | Cost (us-east-1 list, PostgreSQL/MySQL) |
|---|---|---|
| Idle non-prod instance | DatabaseConnections max = 0 for 14+ days | Full instance-hour price, 730 hours a month |
| Multi-AZ in dev/staging | MultiAZ: true, env tag is not prod | Exactly double the instance price |
| gp2 storage | StorageType: gp2 | Same $0.115/GB-month as gp3, but burst-credit throttling under 1 TiB |
| Over-provisioned io1/io2 | Provisioned IOPS far above observed ReadIOPS + WriteIOPS peak | $0.10 per provisioned IOPS-month, versus $0.02 above baseline on gp3 |
| Previous-generation family | db.m4, db.r4, db.t2, db.m5, db.r5 | Graviton m6g/r6g/m7g is 10 to 20% cheaper per vCPU |
| Extended Support | PostgreSQL 11/12/13, MySQL 5.7 | $0.10 per vCPU-hour in years 1 to 2, $0.20 in year 3, on top of the instance |
| Manual snapshot sprawl | SnapshotType: manual, no tags, older than 90 days | $0.095/GB-month beyond the free backup allowance |
Two of those rows surprise people every time. Extended Support is the big one: a db.r5.2xlarge on PostgreSQL 12 has 8 vCPUs, so it quietly costs an extra 8 × 730 × $0.10, which is $584 a month for the privilege of not upgrading. It shows up in Cost Explorer as a separate usage type with ExtendedSupport in the name, and most teams have never filtered for it. The second is gp3: it costs the same per gigabyte as gp2 on RDS, gives you a flat 3,000 IOPS baseline under 400 GiB and 12,000 at or above it, and the migration is an online storage modification. There is no reason to be on gp2 in 2026, yet a large share of instances created before 2023 still are.
Get the account-level number first so you can sanity-check the agent later:
aws ce get-cost-and-usage \
--time-period Start=2026-09-01,End=2026-10-01 --granularity MONTHLY \
--metrics UnblendedCost \
--filter '{"Dimensions":{"Key":"SERVICE","Values":["Amazon Relational Database Service"]}}' \
--group-by Type=DIMENSION,Key=USAGE_TYPE \
--query 'sort_by(ResultsByTime[0].Groups, &Metrics.UnblendedCost.Amount)[-12:].[Keys[0], Metrics.UnblendedCost.Amount]' \
--output table
Usage types starting with InstanceUsage and Multi-AZUsage are compute, RDS:GP2-Storage and RDS:PIOPS are storage, RDS:ChargedBackupUsage is snapshots past the free allowance, and anything containing ExtendedSupport is the upgrade tax. If Extended Support is in the top five, start there and skip the rest of this post until it is gone.
Step 1: A role that can read, and is forbidden to write
The policy pattern is the same as the CloudWatch Logs cost agent: read actions allowed, every mutating RDS action explicitly denied so that a future policy attachment cannot widen it.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadRdsAndMetrics",
"Effect": "Allow",
"Action": [
"rds:DescribeDBInstances", "rds:DescribeDBClusters",
"rds:DescribeDBSnapshots", "rds:DescribeReservedDBInstances",
"rds:ListTagsForResource", "rds:DescribeOrderableDBInstanceOptions",
"cloudwatch:GetMetricData", "pi:GetResourceMetrics",
"ce:GetCostAndUsage", "pricing:GetProducts"
],
"Resource": "*"
},
{
"Sid": "NeverMutateDatabases",
"Effect": "Deny",
"Action": [
"rds:Modify*", "rds:Delete*", "rds:Stop*", "rds:Start*",
"rds:Reboot*", "rds:Create*", "rds:Restore*", "rds:Promote*"
],
"Resource": "*"
}
]
}
The pricing:GetProducts permission matters more here than for other services. RDS prices vary by engine, deployment option, and license model, and an agent that estimates savings from a hard-coded table will be wrong for your Oracle or SQL Server instances by a factor of three. Pull the on-demand price for each instance's exact combination and cache it for the run.
Step 2: Collect the evidence deterministically
The LLM never calls AWS. Python builds one evidence record per instance and the model only sees the records. This is the same split as every agent in this series, and it is what makes the output reproducible enough to put in a pull request.
import boto3, datetime as dt
rds, cw = boto3.client("rds"), boto3.client("cloudwatch")
END = dt.datetime.now(dt.timezone.utc)
START = END - dt.timedelta(days=30)
METRICS = { # name: (stat, period_seconds)
"CPUUtilization": ("p95", 3600), "DatabaseConnections": ("Maximum", 3600),
"ReadIOPS": ("p99", 300), "WriteIOPS": ("p99", 300),
"BurstBalance": ("Minimum", 3600), "FreeStorageSpace": ("Minimum", 3600),
"FreeableMemory": ("Minimum", 3600),
}
def metrics_for(db_id):
queries = [{
"Id": f"m{i}", "MetricStat": {
"Metric": {"Namespace": "AWS/RDS", "MetricName": name,
"Dimensions": [{"Name": "DBInstanceIdentifier", "Value": db_id}]},
"Period": period, "Stat": stat}}
for i, (name, (stat, period)) in enumerate(METRICS.items())]
res = cw.get_metric_data(MetricDataQueries=queries, StartTime=START, EndTime=END)
out = {}
for q, r in zip(METRICS, res["MetricDataResults"]):
vals = r["Values"]
out[q] = {"max": max(vals), "min": min(vals), "n": len(vals)} if vals else None
return out
def evidence():
for page in rds.get_paginator("describe_db_instances").paginate():
for db in page["DBInstances"]:
tags = {t["Key"]: t["Value"] for t in db.get("TagList", [])}
m = metrics_for(db["DBInstanceIdentifier"])
yield {
"id": db["DBInstanceIdentifier"], "engine": db["Engine"],
"engine_version": db["EngineVersion"], "class": db["DBInstanceClass"],
"multi_az": db["MultiAZ"], "storage_type": db["StorageType"],
"allocated_gib": db["AllocatedStorage"], "iops": db.get("Iops"),
"deletion_protection": db["DeletionProtection"],
"status": db["DBInstanceStatus"], "created": db["InstanceCreateTime"].isoformat(),
"read_replica_of": db.get("ReadReplicaSourceDBInstanceIdentifier"),
"tags": tags, "metrics_30d": m,
}
A few collection details that decide whether the findings are trustworthy:
- Use p95 CPU and p99 IOPS, not averages. A reporting database that runs at 2% all month and 90% for four hours on the first is not a downsize candidate, and the average will tell you it is. The
Maximumstatistic onDatabaseConnectionsis the only honest idle test. BurstBalanceonly exists on gp2. A minimum under 20% in the last 30 days is a performance incident waiting to happen, and it turns the gp3 migration from a cost finding into a reliability finding. Lead with that framing in the PR; it gets approved faster.- Check the
ncount. An instance created ten days ago has ten days of data. The classifier must refuse to call anything idle with fewer than 14 days of samples. - Read replicas inherit storage settings. A replica on gp2 under a primary you are migrating needs its own modification. Collect the relationship so the PR touches both.
Manual snapshots are a second collector, with describe_db_snapshots --snapshot-type manual. Record size, creation date, tags, and whether the source instance still exists; a snapshot whose source was deleted two years ago is the easiest approval you will ever get.
Step 3: Classify with a fixed vocabulary
The model's job is to pick a finding and write the two-sentence justification, not to do arithmetic. Savings numbers come from the pricing API in Python and are attached to the record before the model sees it, so the model is explaining a number, never inventing one.
You are reviewing one Amazon RDS instance for cost waste. Return JSON only.
Choose exactly one "finding":
idle_candidate max connections 0 for >= 14 days of data, env tag not prod
multi_az_nonprod multi_az true and env tag in [dev, staging, test, sandbox]
gp2_to_gp3 storage_type gp2 (any environment, this change is online)
piops_overprovisioned storage_type io1/io2 and provisioned iops > 2x p99 read+write
extended_support engine_version in the extended-support list provided
prev_gen_family instance class family in [m4, r4, t2, m5, r5, t3] with a Graviton option
none nothing above applies
Rules:
- Never choose idle_candidate when deletion_protection is true, the env tag is
missing, the env tag is prod, or metrics_30d.DatabaseConnections.n < 336.
- If more than one applies, pick the one with the highest monthly_savings_usd
provided in the record, and list the others in "also".
- "risk" is one of: online (no interruption), maintenance_window, interruption.
- Quote the metric values you relied on in "evidence". Do not estimate costs;
use the savings numbers provided.
Return: {"id", "finding", "also": [], "risk", "evidence", "reviewer_note"}
The risk field is doing real work. It is what routes the finding in the next step: online can become a pull request, maintenance_window becomes a pull request with a scheduled apply, interruption becomes a ticket. The model is good at reading an AWS doc and remembering that gp2 to gp3 is online while an instance class change restarts the database; it is less good at being consistent about it across 80 instances unless the vocabulary is pinned.
On a run against a 64-instance account, the first pass produced one bad call worth knowing about: it labelled a db.t3.medium with zero connections as idle_candidate when the instance was a freshly promoted standby in a disaster recovery drill. The env tag said dr, which was not in the prod list and not in the non-prod list. The fix was not a smarter prompt; it was adding dr to the protected set in the deterministic rules and making a missing or unknown env tag disqualify the idle finding entirely. Ambiguous ownership is a reason to do nothing, not a reason to let the model decide.
Step 4: Route by risk, and make the safe changes boring
Online changes become Terraform PRs. gp2 to gp3 and a provisioned IOPS reduction are both storage_type and iops attribute changes on aws_db_instance. The agent opens a branch, edits the attributes, and the PR runs the same plan-stage gate as the Terraform plan review agent: the plan must show an in-place update of exactly those attributes, and any line containing forces replacement fails the check before a human ever reads it.
resource "aws_db_instance" "orders" {
# ...
storage_type = "gp3" # was gp2; online modification, ~0 downtime
allocated_storage = 500
iops = 12000 # gp3 baseline at >= 400 GiB, no extra charge
apply_immediately = true
}
Two gotchas for the PR description, because reviewers will ask. First, the instance sits in storage-optimization status for up to several hours after the change and cannot take another storage modification for six hours; the PR should say so. Second, if you set iops on gp3 under 400 GiB to anything other than the default, Terraform will try to apply a value AWS rejects, so the template drops the iops line for small volumes.
Maintenance-window changes are PRs with a scheduled apply. Multi-AZ to Single-AZ in staging is one attribute, applied at the next window. Instance class changes to Graviton are a restart and should be bundled with a note that the engine version must support the family; the agent checks DescribeOrderableDBInstanceOptions for that combination before proposing it, because nothing erodes trust faster than a PR that cannot apply.
Interruptions become tickets, never PRs. Deleting an idle instance, even with a final snapshot, is an interruption by definition. The ticket includes the 30-day connection graph, the last successful automated backup, the owning team from tags, and the exact create-db-snapshot command a human runs first. Do not route this through "stop the instance" as a soft option: RDS automatically restarts a stopped instance after seven days, and stopped instances still bill for storage and backups. Stopping is a way to discover who screams, not a cost fix.
Extended Support becomes an upgrade ticket with a monthly number. The agent cannot and should not run a major version upgrade. What it can do is compute vCPUs × 730 × the per-hour rate, name the target version, and link the team's own upgrade runbook. A dollar figure per month turns "we should upgrade Postgres sometime" into a prioritised item; that is the whole contribution.
All of it goes through the same approval gate as the rest of the ops agents here: the agent proposes, a named human merges, and the audit trail records both.
What it found, and what it got wrong
Across that 64-instance account, 30-day evidence collection took four minutes and about 9,000 CloudWatch metric datapoints, which is well inside the free tier. The classification pass cost under a dollar in tokens. The findings:
| Finding | Count | Monthly savings proposed |
|---|---|---|
| gp2_to_gp3 | 31 | Small in dollars, but 6 had BurstBalance under 10% |
| multi_az_nonprod | 9 | $1,340 |
| extended_support | 7 | $1,970 |
| idle_candidate | 5 | $610 |
| piops_overprovisioned | 2 | $880 (one io1 at 20,000 IOPS peaking at 1,900) |
| prev_gen_family | 14 | $720 |
The nine Multi-AZ staging databases were all created by one Terraform module that defaulted multi_az = true and was copied for every environment. The agent found nine instances; the real fix was one line in a module, which is a good reminder that the agent's output is evidence for an engineer, not a work queue to execute blindly.
What it got wrong: it flagged two prev_gen_family instances running SQL Server, where Graviton is not an option. The DescribeOrderableDBInstanceOptions check was added after that run and now filters those before the model sees them. It also cannot see Aurora Serverless v2 capacity waste, because that is an ACU range question with different metrics, and that is a different agent.
Limits worth stating in the PR template
- It never reads your data. The agent talks to the RDS control plane and CloudWatch only. If you want query-level evidence for a downsize, that is the job of the read-only Postgres MCP server, with a separate role and a separate approval.
- Reserved Instances are a different conversation. Compute Savings Plans do not cover RDS. Once the instance list is right-sized, RDS Reserved Instances are the next lever, and the sizing logic from the Savings Plans coverage agent applies with one change: commit to the family, not the size, since RDS RIs are size-flexible within a family for the same engine and region.
- Thirty days is the floor, not the right window. Month-end batch jobs, quarterly reports, and annual audits all live outside it. The idle rule requires the env tag to be explicitly non-prod for exactly this reason.
- Run it monthly, diff against last month. The second run is where the value is: the gp2 list should be empty, the Extended Support list should be shrinking, and anything new on the idle list is a project that just ended and forgot its database.