Cost Reduction Opportunity: How to Lower Database Costs Without Sacrificing Performance
The database is one of the most expensive components in modern system architecture. Not because databases are inherently expensive, but because the way we use them is often inefficient — and that inefficiency accumulates every hour, every day, every month on the cloud bill. Many teams race to optimize the application layer or choose smaller instances on the compute side, while the database is left to grow without evaluation. In fact, database usage patterns are one of the biggest cost drivers in systems already running at production scale.
This article discusses cost reduction opportunities concretely at the database level: from caching strategies, query optimization, cost-aware feature design, instance right-sizing, architecture choices, to data retention policies. Every section includes real examples, approach comparisons, and code patterns that can be used directly as references.
Understanding Where Database Costs Come From
Before discussing how to reduce costs, it’s important to understand where the costs come from. In managed database services like AWS RDS, Google Cloud SQL, or Azure Database, the cost structure generally consists of several components:
| Component | What Drives It | How It’s Billed |
|---|---|---|
| Instance | Number of vCPUs and RAM | Per hour, continuously running |
| Storage | Data size + logs + backups | Per GB per month |
| I/O | Number of reads/writes to disk | Per million requests (some providers) |
| Data transfer | Transfer out to the internet / other regions | Per GB |
| Backup storage | Backup retention period | Per GB per month |
| Read replicas | Each replica = a new instance cost | Per hour, per replica |
From this table, it’s clear that almost all components are directly influenced by how we use the database — not just how big our database is.
flowchart TD
A[Application Traffic] --> B[Queries to the Database]
B --> C{Operation Type}
C -- Read --> D[CPU + I/O + Memory]
C -- Write --> E[CPU + I/O + Lock]
C -- Heavy Aggregation --> F[High CPU, long duration]
D --> G[Instance Bill]
E --> G
F --> G
D --> H[I/O Bill]
E --> H
G --> I[Total Database Cost]
H --> IThe patterns most often found in systems with ballooning costs:
- Queries running thousands of times per minute even though the results rarely change
- Instances provisioned far beyond actual needs
- Old data never cleaned up, making tables ever larger
- Read replicas installed “just in case” but never truly used
- Feature designs that poll the database every few seconds
Use Caching in the Right Places
Caching is the most direct cost reduction tool with the highest return on investment. Every query successfully served from cache is one query that never touches the database — no CPU, no I/O, no locks, no connections used.
Why Caching Is So Effective
Every query to the database carries real overhead:
Without Cache:
1 request → 1 query → CPU + I/O + memory + connection
1000 requests/sec → 1000 queries/sec → full load on the database
With Cache (90% hit rate):
1000 requests/sec → 100 queries/sec → 900 requests served by cache
Database load drops 90%
With drastically reduced load, you can postpone instance scale-ups, or even downgrade existing instance tiers — the savings are immediately felt on the bill.
Cache Types and When to Use Them
| Cache Type | Example Tools | Good For | Notes |
|---|---|---|---|
| In-memory distributed | Redis, Memcached | Data shared across services, sessions, leaderboards | Needs TTL and invalidation management |
| Application-level | Caffeine (Java), Guava Cache | Per-instance data, static lookup tables | Not shared across replicas |
| Query result cache | MySQL Query Cache (deprecated), app-level | Repeated identical query results | Must be careful with invalidation |
| HTTP / CDN cache | CloudFront, Cloudflare | Public data, non-user-specific API responses | Cheapest, zero database hits |
| Materialized views | PostgreSQL, MySQL | Complex aggregation results | Periodic updates, not real-time |
Common Caching Patterns
Cache-Aside (Lazy Loading) — the most common pattern, the application checks the cache first, then goes to the database on a miss:
# ANTI-PATTERN: Always query the database
def get_product(product_id: str):
return db.query("SELECT * FROM products WHERE id = ?", product_id)
# CORRECT: Cache-aside pattern
def get_product(product_id: str):
cache_key = f"product:{product_id}"
# Check the cache first
cached = redis.get(cache_key)
if cached:
return json.loads(cached)
# Cache miss: fetch from the database
product = db.query("SELECT * FROM products WHERE id = ?", product_id)
# Store in the cache with a TTL
redis.setex(cache_key, ttl=300, value=json.dumps(product))
return product
Write-Through Cache — the cache is updated at the same time data is written:
# ANTI-PATTERN: Write directly to the DB, leaving the cache stale
def update_product(product_id: str, data: dict):
db.execute("UPDATE products SET ... WHERE id = ?", data, product_id)
# cache not updated → readers get old data
# CORRECT: Update the DB and cache together
def update_product(product_id: str, data: dict):
db.execute("UPDATE products SET ... WHERE id = ?", data, product_id)
# Invalidate or update the cache
cache_key = f"product:{product_id}"
redis.delete(cache_key) # force re-fetch from the DB on the next request
# or: redis.setex(cache_key, ttl=300, value=json.dumps(updated_product))
What Should Be Cached
GOOD CACHE CANDIDATES:
✓ Configuration data (feature flags, app settings)
✓ Product catalogs, category lists
✓ Aggregation query results (today's total sales)
✓ User profiles (data that rarely changes)
✓ Lookup tables (countries, cities, payment types)
✓ Popular search results
AVOID CACHING:
✗ Real-time financial data (balances, active transactions)
✗ Rapidly changing order statuses
✗ Very user-specific data with a large user volume
✗ Data with critical consistency (rapidly changing stock)
A cache without a clear invalidation strategy is more dangerous than having no cache. Stale data served to users can cause hard-to-trace bugs. Always define: when the cache must be invalidated, what TTL is reasonable, and what happens when a large number of cache misses occur at once (cache stampede).
Slow Query Optimization
One bad query can burden the database more than a thousand optimal queries. Not just in execution time, but in the resources consumed — higher CPU for longer, locks held longer, and buffer pools drained for irrelevant data.
Why Slow Queries Are Expensive
sequenceDiagram
participant App
participant DB as Database
participant CPU as CPU/I/O
App->>DB: Slow query (full table scan)
DB->>CPU: Read millions of rows from disk
CPU-->>DB: Data processed in memory
Note over DB,CPU: High CPU for 5-10 seconds
DB-->>App: Result
Note over App,DB: While the query runs...
App->>DB: Other queries arrive
DB->>DB: Waiting for available resources
Note over DB: Lock contention increases
Note over DB: Other queries slow down tooThis domino effect is what makes one slow query make the whole system feel slow — and pushes teams to scale up instances even though the root cause is an unoptimized query.
Tools for Monitoring Slow Queries
| Platform | Tools | How to Enable |
|---|---|---|
| MySQL | Slow Query Log | slow_query_log=ON, long_query_time=1 |
| PostgreSQL | pg_stat_statements, auto_explain | shared_preload_libraries = 'pg_stat_statements' |
| AWS RDS | Performance Insights | Enable when creating the instance or via modify |
| AWS Aurora | Performance Insights + Enhanced Monitoring | Available in the console |
| APM | New Relic, Datadog, Dynatrace | Install an agent in the application |
| Self-hosted | Percona Monitoring and Management | Deploy a PMM server |
Using EXPLAIN to Understand Queries
-- ANTI-PATTERN: Query run directly without analysis
SELECT * FROM orders
WHERE DATE(created_at) = '2024-01-15'
AND status = 'completed';
-- CORRECT: Analyze first with EXPLAIN ANALYZE
EXPLAIN ANALYZE
SELECT order_id, user_id, total_amount, created_at
FROM orders
WHERE created_at >= '2024-01-15 00:00:00'
AND created_at < '2024-01-16 00:00:00'
AND status = 'completed';
The difference between the two queries above isn’t just style. The first query uses DATE(created_at) — a function that makes the index on the created_at column unusable (non-sargable). The second query uses a range that lets the database leverage the index.
EXPLAIN output to look out for:
ANTI-PATTERNS visible in EXPLAIN output:
✗ Seq Scan (Sequential Scan) → full table scan, doesn't use the index
✗ rows=1000000 → large row estimate, potential heavy load
✗ cost=0.00..99999 → high cost
✗ Filter: (condition) → filtering happens after the scan, not at the index
SIGNS OF A GOOD QUERY:
✓ Index Scan or Index Only Scan
✓ rows close to the actual result count
✓ Bitmap Index Scan (for multiple conditions)
✓ Hash Join or Merge Join (better than Nested Loop for large data)
High-Impact Query Optimization Patterns
**1. Avoid SELECT ***
-- ANTI-PATTERN: Pulling all columns including unused ones
SELECT * FROM users WHERE id = 123;
-- CORRECT: Fetch only the needed columns
SELECT id, name, email, created_at FROM users WHERE id = 123;
The impact isn’t just bandwidth — a SELECT * query prevents the database from using an index-only scan, meaning the database must read full data pages even when all needed information is already in the index.
2. Well-Targeted Indexes
-- ANTI-PATTERN: No index for columns that are frequently filtered
SELECT * FROM orders WHERE user_id = 456 AND status = 'pending'
ORDER BY created_at DESC;
-- → Full table scan if there's no appropriate index
-- CORRECT: Composite index matching the query pattern
CREATE INDEX idx_orders_user_status_created
ON orders (user_id, status, created_at DESC);
-- → Index scan straight to the relevant data
3. Avoid Functions on Columns in WHERE
-- ANTI-PATTERN: Functions on columns block index usage
SELECT * FROM logs WHERE YEAR(created_at) = 2024;
SELECT * FROM users WHERE LOWER(email) = '[email protected]';
SELECT * FROM products WHERE CAST(price AS CHAR) LIKE '1%';
-- CORRECT: Conditions that can leverage the index
SELECT * FROM logs
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';
SELECT * FROM users WHERE email = '[email protected]';
-- (with a functional index if case-insensitivity is needed:
-- CREATE INDEX idx_users_email_lower ON users (LOWER(email));)
4. Limit with LIMIT, Don’t Fetch Everything
-- ANTI-PATTERN: Fetch all data, filter in the application
results = db.query("SELECT * FROM events WHERE type = 'click'")
recent = results[:100] # filter in the application after fetching everything
-- CORRECT: Filter and limit in the database
SELECT id, user_id, event_data, created_at
FROM events
WHERE type = 'click'
ORDER BY created_at DESC
LIMIT 100;
Cost-Aware Feature Design from the Start
The cheapest cost reduction is the one done at design time, not after the system has run for years. Every architecture decision made during development will impact the database bill for as long as the system lives.
The Core Principle: The Database Isn’t the Place for All Logic
The database is for storing and retrieving data. The database isn’t a message broker, isn’t a scheduler, isn’t a calculation engine called thousands of times per minute for the same result.
Questions to always ask before querying the database:
→ Can this data be cached?
→ Can this calculation be done on the application side?
→ Does this data really need to be real-time from the database?
→ Is there another way that doesn't need the database at all?
Dynamic Calculation vs Precomputed / Aggregated Data
One of the patterns that most often burdens the database is computing large aggregations on every request.
-- ANTI-PATTERN: Large aggregation run on every dashboard page request
SELECT
DATE(created_at) as date,
COUNT(*) as total_orders,
SUM(total_amount) as revenue,
AVG(total_amount) as avg_order_value
FROM orders
WHERE created_at >= NOW() - INTERVAL 30 DAY
GROUP BY DATE(created_at)
ORDER BY date;
-- → Full scan or partial scan of the orders table every time the dashboard is opened
If this dashboard is opened 100 times per minute, this query runs 100 times per minute — each one processing millions of rows. The solution is moving this calculation to the background:
-- CORRECT: Store aggregation results in a separate table
-- A summary table filled by a background job
CREATE TABLE order_daily_summary (
summary_date DATE PRIMARY KEY,
total_orders INT,
revenue DECIMAL(15,2),
avg_order_value DECIMAL(10,2),
updated_at TIMESTAMP
);
-- Background job (cron/worker) running every minute or every hour
INSERT INTO order_daily_summary (summary_date, total_orders, revenue, avg_order_value)
SELECT
DATE(created_at),
COUNT(*),
SUM(total_amount),
AVG(total_amount)
FROM orders
WHERE created_at >= CURDATE() - INTERVAL 1 DAY
GROUP BY DATE(created_at)
ON DUPLICATE KEY UPDATE
total_orders = VALUES(total_orders),
revenue = VALUES(revenue),
avg_order_value = VALUES(avg_order_value),
updated_at = NOW();
-- The dashboard query becomes trivial
SELECT * FROM order_daily_summary
WHERE summary_date >= CURDATE() - INTERVAL 30 DAY
ORDER BY summary_date;
-- → Simple lookup into a small table, no heavy aggregation
flowchart LR
A[Dashboard Request] --> B{Data from where?}
B -- ANTI-PATTERN --> C[Query the large orders\ntable on every request]
B -- CORRECT --> D[Query the summary table\nalready precomputed]
E[Background Job\nevery 1 min] --> F[Compute aggregation\nfrom the orders table]
F --> D
C --> G[High CPU, slow]
D --> H[Low CPU, fast]Polling vs Event-Driven
Database polling is one of the most cost-damaging patterns that often goes unnoticed.
# ANTI-PATTERN: Polling the database every N seconds
# Imagine 1000 service instances doing this simultaneously
while True:
new_orders = db.query(
"SELECT * FROM orders WHERE status = 'new' AND processed = 0"
)
for order in new_orders:
process(order)
db.execute("UPDATE orders SET processed = 1 WHERE id = ?", order.id)
time.sleep(5) # polling every 5 seconds
With 1000 instances running, the database receives 200 SELECT queries per second just to check “are there new orders?” — most of the results are empty. This is pure waste.
# CORRECT: Event-driven with a message queue
# Producer (the service receiving orders)
def create_order(order_data):
order = db.insert("INSERT INTO orders ...", order_data)
message_queue.publish("orders.new", {
"order_id": order.id,
"data": order_data
})
return order
# Consumer (the service processing orders)
@message_queue.subscribe("orders.new")
def handle_new_order(message):
order_id = message["order_id"]
process_order(order_id)
With the event-driven approach, database queries only happen when a new order genuinely needs processing — not every 5 seconds without end.
| Approach | Queries per minute (1000 instances) | Latency | Complexity |
|---|---|---|---|
| 5-second polling | 12,000 queries/min | ≤ 5 seconds | Low |
| 1-second polling | 60,000 queries/min | ≤ 1 second | Low |
| Event-driven (MQ) | Matches actual event count | < 1 second | Medium |
| WebSocket + push | Minimal | Real-time | Medium-high |
Right-Sizing Database Instances
One of the quietest but most significant sources of waste is over-provisioned database instances. Teams often choose larger instances “just in case” or because they once had a performance problem, then never re-evaluate whether that size is still relevant.
How to Evaluate the Existing Instance
flowchart TD
A[Start Instance Evaluation] --> B{CPU utilization\naverage < 30%?}
B -- Yes --> C{Memory usage\naverage < 50%?}
B -- No --> Z[Maintain or scale up\nper the needs]
C -- Yes --> D{I/O throughput\nalso low?}
C -- No --> Z
D -- Yes --> E[Candidate for\ninstance downgrade]
D -- No --> Z
E --> F{Are there\nhigh peaks?}
F -- Yes, significant --> G[Consider\nauto-scaling or\nscheduled scaling]
F -- No/Occasional --> H[Downgrade the instance\n1-2 tiers smaller]Metrics that need monitoring for at least 2-4 weeks before deciding on a downgrade:
METRICS TO EVALUATE:
□ CPU utilization — average and peak (not just average)
□ Freeable memory — is there enough buffer pool available
□ Read/Write IOPS — actual vs provisioned
□ Database connections — peak concurrent connections
□ Replication lag — if there are read replicas
□ Query execution time — any degradation under high load
Evaluating Read Replicas
Read replicas are double cost — every replica is billed at the primary instance rate. The questions to answer before keeping a read replica:
A read replica is worth keeping if:
✓ There's significant, measurable read traffic directed to the replica
✓ Heavy analytics/reporting queries run from the replica
✓ There's a disaster recovery need requiring a standby in another region
✓ Latency to the replica is lower for users in certain locations
Consider removing or reducing read replicas if:
✗ The application still mostly reads from the primary
✗ The replica is never or rarely used
✗ The team isn't sure which queries go to the replica vs primary
✗ The replica was installed "just in case" without clear routing configuration
On AWS RDS, use the Performance Insights feature to visually see where database load comes from. Combine it with Enhanced Monitoring to see CPU, memory, and I/O utilization at the OS level. This data is far more useful than deciding based on feelings or old incident experiences.
Reduce Database Load with the Right Architecture
Several architecture patterns directly reduce database load without reducing system functionality.
Read/Write Separation
Separating read and write traffic to different connections — even if still using the same database — provides better control and opens the possibility of directing reads to replicas when needed.
# ANTI-PATTERN: One connection for everything
class OrderRepository:
def get_order(self, order_id):
return self.db.query("SELECT ...") # read via the same connection
def create_order(self, data):
return self.db.execute("INSERT ...") # write via the same connection
# CORRECT: Separate read and write connections
class OrderRepository:
def __init__(self, write_db, read_db):
self.write_db = write_db # primary
self.read_db = read_db # replica or the same primary
def get_order(self, order_id):
return self.read_db.query("SELECT ...")
def get_orders_for_user(self, user_id):
return self.read_db.query("SELECT ...") # read-heavy, direct to the replica
def create_order(self, data):
return self.write_db.execute("INSERT ...") # writes always to the primary
def update_order_status(self, order_id, status):
return self.write_db.execute("UPDATE ...")
Simple CQRS for Read-Heavy Cases
CQRS (Command Query Responsibility Segregation) doesn’t have to be implemented in its full form with event sourcing. A simple version is enough for cases where read operations far outnumber writes:
Simple CQRS Model:
COMMAND side (Write):
→ Operations that change state (create, update, delete)
→ Validation, business logic
→ Write to the primary database
→ Publish events to a message queue
QUERY side (Read):
→ Read-only operations
→ Data may be slightly delayed (eventual consistency)
→ Can read from: read replicas, cache, or optimized summary tables
→ No business validation, just data transformation for display
Batch Processing Instead of Repeated Real-Time Queries
For operations that don’t need real-time, batch processing is far more efficient:
# ANTI-PATTERN: Send notifications one by one, on every transaction
def complete_order(order_id):
order = db.query("SELECT * FROM orders WHERE id = ?", order_id)
db.execute("UPDATE orders SET status = 'completed' WHERE id = ?", order_id)
# Trigger notification directly
user = db.query("SELECT * FROM users WHERE id = ?", order.user_id)
email_service.send(user.email, "Order completed", ...)
# Update user stats directly
db.execute("UPDATE user_stats SET total_orders = total_orders + 1 WHERE user_id = ?", order.user_id)
db.execute("UPDATE user_stats SET total_spent = total_spent + ? WHERE user_id = ?", order.total, order.user_id)
# CORRECT: Separate concerns, batch what isn't urgent
def complete_order(order_id):
db.execute("UPDATE orders SET status = 'completed' WHERE id = ?", order_id)
queue.publish("order.completed", {"order_id": order_id})
# Done — no additional queries
# A separate worker processing in batches
@queue.subscribe("order.completed")
def handle_order_completed_batch(messages):
order_ids = [m["order_id"] for m in messages]
# One query for all orders in the batch
orders = db.query(
"SELECT o.*, u.email FROM orders o JOIN users u ON o.user_id = u.id WHERE o.id IN (?)",
order_ids
)
# Send emails in a batch
email_service.send_batch([...])
# Update stats in one query
db.execute("""
UPDATE user_stats us
JOIN (
SELECT user_id, COUNT(*) as cnt, SUM(total_amount) as total
FROM orders WHERE id IN (?)
GROUP BY user_id
) batch ON us.user_id = batch.user_id
SET us.total_orders = us.total_orders + batch.cnt,
us.total_spent = us.total_spent + batch.total
""", order_ids)
Evaluating Data Retention and Archiving
A large table is a slow, expensive table. Every index on a large table needs more storage. Every query must pass through more data to find what’s relevant. Every backup includes data that may never be read again.
Signs of Problematic Data Retention
SIGNS WORTH INVESTIGATING:
□ Log or events tables whose size keeps growing without limit
□ Queries on large tables getting slower and slower
□ Backup windows stretching longer month over month
□ Storage cost rising faster than business growth
□ Data from last year never queried in production
Data Retention Strategies
flowchart TD
A[Data Enters the Database] --> B[Hot Data\n0-90 days\nPrimary DB]
B --> C{Data age?}
C -- 90-365 days --> D[Warm Data\nSeparate partition or\narchive table in the same DB]
C -- > 365 days --> E[Cold Data\nData warehouse\nS3 / GCS / Blob storage]
D --> F{Ever accessed?}
F -- Rarely --> E
F -- Still often --> D
E --> G{Need\nanalytical access?}
G -- Yes --> H[BigQuery / Redshift\nAthena / Databricks]
G -- No --> I[Glacier / Coldline\nCheap archive tier]A concrete implementation for MySQL/PostgreSQL — using table partitioning to make archiving easy:
-- Create a table partitioned by time
CREATE TABLE events (
id BIGINT AUTO_INCREMENT,
user_id INT,
event_type VARCHAR(50),
event_data JSON,
created_at DATETIME NOT NULL,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (YEAR(created_at) * 100 + MONTH(created_at)) (
PARTITION p202401 VALUES LESS THAN (202402),
PARTITION p202402 VALUES LESS THAN (202403),
PARTITION p202403 VALUES LESS THAN (202404),
-- ... and so on
PARTITION p_future VALUES LESS THAN MAXVALUE
);
-- Archiving becomes as easy as dropping a partition (instant, no scan)
-- Before dropping, export to S3 first
ALTER TABLE events DROP PARTITION p202401;
-- → Instant operation, doesn't burden the database, storage shrinks immediately
The Real Impact of Data Retention
| Table Size | Simple SELECT Query | Backup Duration | Storage Cost (RDS) |
|---|---|---|---|
| 10 million rows | 50ms | 15 minutes | ~$20/month |
| 100 million rows | 300ms | 2 hours | ~$200/month |
| 1 billion rows | 2-5 seconds | 12+ hours | ~$2000/month |
| With archiving | Stays 50ms (only hot data) | 15 minutes | ~$20/month + minimal cold storage |
Before deleting or archiving data, make sure there’s certainty from the legal and business side. Some industries (finance, healthcare) have regulations requiring data to be retained for certain periods in certain ways. Archiving to cold storage still fulfills storage obligations at a far lower cost than the primary database.
Summary: Database Cost Evaluation Framework
Database cost reduction isn’t a one-time project. It’s a habit and mindset built into how the team works — from feature design, code review, to periodic infrastructure evaluation.
flowchart TD
A[Database Cost Too High?] --> B[Analyze first\nbefore action]
B --> C[Check slow query log\nand Performance Insights]
B --> D[Look at instance\nCPU/memory/I/O utilization]
B --> E[Review query patterns\nin the application]
C --> F{Any slow queries?}
F -- Yes --> G[Optimize queries\nAdd the right indexes]
F -- No --> H[Next check]
D --> I{Instance over-provisioned?}
I -- Yes --> J[Right-size the instance\nEvaluate read replicas]
I -- No --> K[Next check]
E --> L{Any polling or\nrepeated heavy aggregation?}
L -- Yes --> M[Implement caching\nor event-driven]
L -- No --> N[Check data retention]
N --> O{Any old\nuseless data?}
O -- Yes --> P[Implement archiving\nand retention policy]
O -- No --> Q[Evaluate architecture\nCQRS or batch processing]The recommended priority order based on effort vs impact:
| Action | Implementation Effort | Cost Impact | Risk |
|---|---|---|---|
| Slow query optimization | Low-medium | High | Low |
| Add cache for hot data | Medium | High | Low-medium |
| Right-size the instance | Low | Medium-high | Low (with enough data) |
| Remove unused read replicas | Low | High (50% cost) | Low |
| Data retention & archiving | Medium | Medium (high long-term) | Low |
| Refactor polling to event-driven | High | Medium-high | Medium |
| Implement CQRS/batch processing | High | Medium | Medium |
A healthy database isn’t the one with the biggest specs. A healthy database is one used correctly — efficient queries, relevant data, caching in the right places, and instances matching actual needs. Teams that build this awareness from the start of development will have systems that are more scalable, more stable, and more cost-sustainable in the long term.
- Caching is the best investment — an 80-90% hit rate can reduce database load by more than 80%, enabling the use of smaller instances.
- One slow query can be expensive — a single full-table-scan query running thousands of times a day costs more than dozens of well-optimized queries.
- Evaluate instances periodically — average CPU and memory utilization below 30% for weeks is a clear signal to downgrade.
- Polling is a silent killer — replace it with event-driven or message queues for operations that don’t need real-time.
- Precompute heavy aggregations — don’t run
GROUP BYover millions of rows every time a dashboard is opened; move it to a background job.- Old data = hidden cost — set a retention policy from the start, use partitions to make archiving easy, move cold data to cheap storage.
- Right-size, don’t over-provision — use actual utilization data for at least 2-4 weeks before deciding the right instance size.
- Cost-aware design is cheaper than refactoring — every bad pattern that enters production will keep generating bills until it’s fixed.