Cloud infrastructure
Right-sizing an overprovisioned production PostgreSQL workload
A production database cost about $988 a month while averaging 0.8% CPU and barely using its 30,000 provisioned IOPS. I right-sized compute and storage performance, bringing the RDS bill down to about $320.
- Outcome
- $988 → $320/moobserved RDS spend, ~68% lower
- Read time
- 4 min
- AWS RDS
- PostgreSQL
- Capacity planning
- Cloud cost optimization
- Production risk
- Graviton
Summary
I audited a production PostgreSQL workload that cost about $988 a month. We inherited the project from another team after the client decided they wanted a more experienced team to take over.
When I first saw the database setup, I assumed it must be serving a lot of users and data. Eight vCPUs, 32 GB of RAM, 600 GB of storage, and 30,000 provisioned IOPS sounded like it could be an interesting sizing problem.
Then I opened the metrics. The instance averaged 0.8% CPU, usually had around three database connections, and barely touched the IOPS we were paying for. Only about 93 GB of the 600 GB storage allocation was in use.
I split the bill into compute, storage, IOPS, and throughput, then checked each part against the actual workload. I moved it to a smaller Graviton-based instance and reduced the storage performance without chasing the absolute minimum. Once the migration settled, the monthly RDS cost fell from $987.50 to $319.72. That is about $668, or 68%, lower.
What I expected and what the metrics showed
The database was one of the largest individual items on the AWS bill. Any part of its configuration could have been reasonable on its own. Together, it looked like a database built for a serious amount of traffic.
I pulled several weeks of CloudWatch metrics and RDS Performance Insights. CPU averaged 0.8%. The database handled about three concurrent connections in steady state. Reads and writes stayed in the low tens of IOPS instead of reaching the thousands. Freeable memory had plenty of room in every window I checked.
The database had far more capacity than it was using.
Where the money was going
The instance size was only one part of the bill. RDS charged separately for compute, allocated storage, provisioned IOPS, and provisioned throughput.
RDS cost
=
compute (instance-hour rate)
+ storage (allocated GB-month)
+ provisioned IOPS (per-IOPS-per-month)
+ provisioned throughput (per-MB/s-month)
The 30,000 provisioned IOPS stood out immediately. The workload used reads and writes in the low tens, which was basically background noise compared with what had been provisioned. Resizing only the instance would have left a large part of the waste untouched.
Storage was oversized too. We had allocated 600 GB for roughly 93 GB of data. I did not include a large storage reduction in the first change because reducing allocated RDS storage is not always straightforward. I kept that as separate work and focused first on the changes that were high impact and easier to reverse.
Why I did not choose the smallest instance
With CPU below 1%, it would have been easy to pick the smallest instance on the spreadsheet. I did not want to size a production database from an average alone.
A scheduled job could spike CPU at 3 AM. An export could hit IO during a backup window. A database rollback is also very different from restarting a stateless worker. If we got this wrong, recovery could mean restoring a snapshot instead of retrying a few requests.
The metrics proved that the upper limit we were paying for made no sense. They did not prove the lowest possible instance would be safe.
I used the measurements as a sizing range. I checked peaks as well as averages for compute, memory, connections, IOPS, throughput, and latency. Then I chose a target a couple of steps above the mathematical minimum.
Choosing the target
I chose a smaller Graviton-based instance with enough headroom for scheduled work, traffic growth, and deployments. Moving managed PostgreSQL from x86 to Graviton was straightforward for the application, but I still checked extension compatibility and version support first. Finding a broken extension at 2 AM was not worth squeezing out another small saving.
I also cut provisioned IOPS substantially and lowered throughput to match the workload we were actually seeing. The savings came from treating compute and storage performance as separate decisions.
There was a more aggressive version of the plan that projected over $900 in monthly RDS savings. It used the smallest viable instance and pushed every resource to its observed floor. I did not take that route. I was trying to remove waste without creating a different production problem.
The bill after the change
I checked the result against the actual bill instead of stopping at a Cost Explorer estimate. Once the migration settled, the RDS line item was about $319.72 a month, down from $987.50. The reduction was roughly $668, or 68%.
For the next several weeks, I watched CPU, freeable memory, connections, read and write latency, IOPS, queue depth, and storage usage. I also monitored application-side p95 and p99 query latency. I saw no sign that we had cut too far. The remaining headroom handled the normal spikes.
What else I found
The database was part of a wider infrastructure review. I also found stale resources nobody remembered provisioning, old deployments still consuming compute, inconsistent security-group rules, and replaced services that had never been decommissioned.
That changed how I approach this kind of cleanup. If I am unsure about a workload, I stop it before deleting it. I preserve a snapshot when needed, change one thing at a time, and verify both production behavior and the next bill before moving on.