Cost Engineering
AWS
The Frugal Approach to AWS RDS Costs
Craig Conboy

With usage-billed database services like AWS RDS, cost optimization starts with your code. It's not just that applications query the database; applications spend. Every query, every connection, every transaction contributes to your bill—directly through I/O and egress, and indirectly by forcing bigger instances than your workload actually needs.

Taking an application- and code-centric approach to cost reduction means understanding what your code asks of the database before and after each request. Here's a practical walk-through of what to look for and how to optimize RDS costs at the code and configuration level.

Cost Trap Efficiency Pattern
Uncached repeated reads Cache static and slowly-changing data in the application
Expensive queries run repeatedly Cache aggregate and dashboard query results
N+1 query patterns Eager-load relations; one query, not N+1
Row-at-a-time writes and commits Batch writes; one transaction, not N
SELECT * and unfiltered fetches Select only the columns you use
Unbounded queries with no LIMIT Paginate; filter in SQL, not in application memory
Missing indexes on hot queries Index the columns your WHERE clauses actually hit
No connection pooling Pool connections instead of opening per request
Oversized connection pools Size pools to measured concurrent demand
Single statements wrapped in transactions Let autocommit handle one-statement reads
Speculative writes ending in rollback Use upserts instead of insert-then-catch
Transactions held open across external calls Keep API calls outside BEGIN/COMMIT
Retry storms on failing queries Classify errors; back off with jitter
Over-provisioned or idle instances Right-size to observed load; decommission the idle
Legacy storage and old engine versions Move gp2 to gp3; upgrade before Extended Support bites

Attribute the Costs

Start with your bill. Break down costs by instance, application, and environment to understand where spend concentrates. RDS charges across several dimensions: instance-hours (a db.r6g.xlarge runs ~$0.52/hour, ~$375/month, before Multi-AZ doubles it), storage and provisioned IOPS (~$0.115/GB-month for gp3), I/O on Aurora (~$0.20 per million requests), backup storage beyond the free allotment, and data transfer (~$0.09/GB out to the internet, ~$0.01/GB each way across AZs).

Here's what makes databases different from most usage-billed services: the biggest line item—the instance—is priced by capacity, not activity. Wasteful code doesn't show up as a bigger per-query charge; it shows up as sustained CPU and I/O pressure that forces the next instance size up. A pile of N+1 queries is invisible on the bill until it's the reason you're on a 4xlarge.

Attribute costs down to specific query patterns in your codebase. RDS Performance Insights (free tier: 7 days of retention) shows you which queries dominate database load; correlate those against the code that issues them. Which endpoints re-read static data on every request? Which loops commit row by row? This granular attribution reveals which of the 15 efficiency patterns below deliver the highest impact.


Tackling Query & Read Costs

Most database spend traces back to queries that didn't need to run.

Cache what doesn't change.

Don't re-run expensive aggregates.

Kill N+1 patterns.

Index the hot paths.

Cache static and slowly-changing reads

Cost impact: Instance load and I/O

Fetching user preferences, feature flags, or configuration on every request means the database answers the same question thousands of times an hour:

# Bad: read preferences on every request
def get_dashboard(user_id):
    prefs = db.query("SELECT * FROM preferences WHERE user_id = %s", user_id)
    return render(prefs)

# Good: cache with a sensible TTL
def get_dashboard(user_id):
    prefs = cache.get(f"prefs:{user_id}")
    if prefs is None:
        prefs = db.query("SELECT * FROM preferences WHERE user_id = %s", user_id)
        cache.set(f"prefs:{user_id}", prefs, ttl=300)
    return render(prefs)

Benefit: 30-70% reduction in read load for cache-friendly access patterns.

Cache expensive query results

Cost impact: Instance load

Aggregate queries (COUNT, SUM, multi-table JOINs) are the heaviest thing you can ask a database to do, and dashboards love to ask on every page load. Cache the result and refresh it on a schedule—a summary that's 60 seconds stale is indistinguishable from live for almost every business purpose.

Benefit: 50-90% reduction in compute for reporting and dashboard workloads.

Eliminate N+1 queries

Cost impact: Instance load

ORM lazy loading turns one logical fetch into a query per row:

# Bad: 1 query for orders + N queries for items (lazy loading)
orders = session.query(Order).filter_by(user_id=user_id).all()
for order in orders:
    print(order.items)      # each access issues a new SELECT

# Good: one query with eager loading
orders = (session.query(Order)
    .options(selectinload(Order.items))
    .filter_by(user_id=user_id)
    .all())

A page rendering 50 orders just went from 51 queries to 2. Multiply by traffic and this single pattern is often the difference between instance sizes.

Benefit: 80-99% reduction in query count on affected paths.

Add the missing index

Cost impact: Instance load and IOPS

A frequent query filtering on an unindexed column forces a sequential scan that grows linearly with the table. The tell: a new WHERE clause shipped without a matching migration. Performance Insights will show the query climbing the load rankings as the table grows:

-- The new query path
SELECT * FROM shipments WHERE city = $1;

-- The migration that should have shipped with it
CREATE INDEX CONCURRENTLY idx_shipments_city ON shipments (city);

Benefit: 50-99% reduction in latency and I/O for the affected query—and one less reason to upsize.


Tackling Write & Transaction Costs

How you write matters as much as how much you write.

Batch writes; one transaction, not N

Cost impact: Instance load and IOPS

Two compounding versions of the same mistake: inserting rows one statement at a time, and wrapping each one in its own COMMIT—so an N-row load becomes N round-trips and N transaction-log flushes:

# Bad: N inserts, N commits, N log flushes
for record in records:
    session.add(Row(**record))
    session.commit()

# Good: one batch, one commit
session.bulk_insert_mappings(Row, records)
session.commit()

For very large loads, checkpoint every few thousand rows—one commit per batch, not per row.

Benefit: 60-95% reduction in write cost; 30-80% less commit-related I/O.

Keep transactions honest

Cost impact: Instance load and connection capacity

Three transaction habits that quietly burn money:

Single statements in explicit transactions. A lone SELECT doesn't need BEGIN/COMMIT—autocommit gives one statement correct isolation for free, and the wrapper triples the round-trips. ORMs with implicit "session" semantics are the usual culprit.

Speculative writes ending in rollback. INSERT-then-catch-the-duplicate wastes the full write I/O and leaves vacuum debt behind:

-- Bad: insert, fail, rollback (all that I/O for nothing)
BEGIN; INSERT INTO users ...; -- duplicate key error → ROLLBACK

-- Good: let the database resolve the conflict
INSERT INTO users (id, email) VALUES ($1, $2)
ON CONFLICT (id) DO NOTHING;

Transactions held open across external calls. A payment API call inside BEGIN/COMMIT means the connection sits idle-in-transaction for the full round-trip—blocking vacuum, holding locks, and eating a pool slot:

# Bad: Stripe round-trip inside the transaction
with db.begin():
    order = db.execute(select_for_update)
    charge = stripe.charges.create(...)   # 800ms with a lock held
    db.execute(insert_charge, charge.id)

# Good: call out first, transact briefly
charge = stripe.charges.create(...)
with db.begin():
    db.execute(insert_charge, charge.id)

Benefit: Fewer round-trips, less wasted I/O, an effective pool that's the size you paid for.

Tame retries

Cost impact: Instance load (amplified at the worst time)

A retry decorator with no error classification and no backoff turns every incident into a load test. Five immediate retries on a struggling database quintuples pressure exactly when it can least afford it—and retrying a syntax error five times just fails five times slower.

# Bad: retry everything, immediately
@retry(stop=stop_after_attempt(5))
def fetch(): ...

# Good: retry transient errors only, with backoff and jitter
@retry(
    retry=retry_if_exception_type(OperationalError),
    wait=wait_random_exponential(multiplier=0.5, max=10),
    stop=stop_after_attempt(3),
)
def fetch(): ...

Benefit: Avoids load amplification during incidents; sticky errors surface instead of being masked.


Tackling Data Transfer Costs

Select only what you use

Cost impact: Egress and instance load

SELECT * on wide tables ships every column to the application to use two of them, and an unfiltered fetch ships every row to filter in memory:

// Bad: whole table over the wire, filtered in the app
const rows = await db.query('SELECT * FROM orderitem');
const mine = rows.filter(r => r.product_id === id);

// Good: the database's whole job is filtering
const mine = await db.query(
  'SELECT id, qty, price FROM orderitem WHERE product_id = $1 LIMIT 100',
  [id]
);

The unbounded version has a second failure mode beyond cost: it works fine in staging with 10,000 rows and OOMs in production with 10 million. Always paginate anything that grows.

Benefit: 30-70% egress reduction; transfer drops from table-size to page-size.


Tackling Connection Costs

Pool connections—and size the pool honestly

Cost impact: Instance memory and CPU

Opening a connection per request burns CPU on both ends (TLS handshake, auth, process setup) and is the classic cause of connection-limit incidents. Pool them—but then size the pool to measured demand, because the opposite failure costs too: every reserved slot consumes server memory against max_connections, whether it's used or not.

// Bad: max copied from a blog post, multiplied by 20 pods
const pool = new Pool({ max: 50 });   // peak measured demand: 5

// Good: measured peak plus headroom
const pool = new Pool({ max: 10 });

Twenty pods at max: 50 is standing permission for 1,000 simultaneous connections against a database that can comfortably serve a fraction of that. If many services share one database, RDS Proxy multiplexes them properly.

Benefit: 20-50% less connection-handling overhead; reclaimed instance memory—sometimes enough to downsize.


Tackling Instance & Storage Costs

Everything above reduces the load; this is where you collect, because the instance line only shrinks when someone right-sizes it.

Right-size the instances

Cost impact: Instance-hours

Enable AWS Compute Optimizer (it's free, opt-in, and one org-level command) and act on what it finds: instances flagged over-provisioned at single-digit CPU, volumes provisioned for 20,000 IOPS sustaining 2,000, and—the quiet budget killer—instances flagged idle. A pre-production replica running 24×7 with zero connections bills exactly like one doing work. Decommission it with a final snapshot; note that merely stopping keeps billing storage and backups, and RDS restarts stopped instances after 7 days anyway.

The flip side counts too: for an instance flagged under-provisioned, the cheapest fix is usually in the sections above—caching and N+1 elimination can erase the need to upsize at all.

Benefit: Right-sizing typically cuts the affected instance's bill 30-60%; deleting idle instances cuts theirs 100%.

Retire legacy storage and engine versions

Cost impact: Storage and surcharges

Two stragglers worth a quarterly sweep:

gp2 volumes. gp3 costs about the same per GB in RDS but includes a 3,000 IOPS / 125 MB/s baseline—predictable I/O without burst-credit anxiety, switchable live with no restart.

Extended Support surcharges. Run a major engine version past its end of standard support and AWS automatically bills Extended Support—roughly $0.10 per vCPU-hour, no opt-out. On a 4-vCPU instance that's ~$290/month for the privilege of not upgrading, the rate roughly doubles in year three, and AWS force-upgrades you at the end anyway. Check your bill for ExtendedSupport usage types; the surcharge is often larger than the migration project everyone's been deferring.

Benefit: Better I/O for the same money; elimination of a surcharge that only grows.


Closing Thoughts

Cost-effective RDS requires two steps. First, let observed costs guide what needs optimization. Attribute your bill down to instances and the query patterns driving them—Performance Insights tells you which queries dominate load, your code tells you why. Which endpoints re-read static data, which loops commit row by row, which WHERE clauses have no index, which instances are idle. This granular attribution reveals which of the 15 efficiency patterns above matter most for your workloads. Second, apply the optimizations that address your cost concentrations: cache repeated and expensive reads (30-90% load reduction), eliminate N+1 patterns (80-99% fewer queries), batch writes and keep transactions brief, select and paginate instead of shipping tables, pool connections at honest sizes, then collect the winnings by right-sizing instances, retiring gp2, and upgrading before Extended Support compounds.

You don't lose performance or reliability—most of these make the application faster. Databases hide their cost story behind a capacity-priced line item; the discipline is connecting instance size back to the code that demanded it, and optimizing based on measured impact.

Looking for help with cost optimizations like these? Book a Demo or Explore the Sandbox to see Frugal in action. Frugal attributes AWS RDS costs to your code, finds inefficient usage, provides Frugal Fixes that reduce your bill, and helps keep you out of cost traps for new and changing code.

Back to Top
background Image
Robo Image

Take Frugal for a live test drive

Explore how Frugal scans your code and cloud services to find waste, recommend optimizations, and generate ready-to-use fixes in a secure, read-only environment.
background Image
robo image