DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • Useful System Table Queries in Relational Databases
  • Common Mistakes to Avoid When Writing SQL Code
  • SQL Commands: A Brief Guide
  • Cloud SQL Guidelines for Cloud Database Administration

Trending

  • 12 Factor Framework for Building Secure and Compliant Cloud Applications
  • Designing Secure REST APIs With Spring Boot
  • A Zero-Trust Implementation Framework for Cloud Migrations: Lessons From Enterprise Deployments
  • GraphRAG Retrieval Is Three Decisions: Granularity, Mechanism, and Paradigm
  1. DZone
  2. Data Engineering
  3. Databases
  4. Database Bottlenecks Nobody Talks About: Optimizing SQL Queries Beyond Indexing

Database Bottlenecks Nobody Talks About: Optimizing SQL Queries Beyond Indexing

Indexes aren't enough. Learn how stale statistics, lock contention, and smarter SQL optimization keep databases fast, scalable, and production-ready.

By 
Muhammad Awais user avatar
Muhammad Awais
·
Aug. 07, 26 · Analysis
Likes (0)
Comment
Save
Tweet
Share
173 Views

Join the DZone community and get the full member experience.

Join For Free

Every performance guide starts the same way. "Add an index." And yes, indexes matter. But I've spent years fixing production databases, and here's the truth: indexing is the easy 20%. The hard 80% is everything nobody writes blog posts about.

I once spent three days chasing a query that had a perfect index. The index wasn't the problem. The problem was that the database's own statistics were lying to it.

This article is about that other 80%.

Why This Problem Keeps Coming Back

Most teams treat database performance as a one-time task. Add indexes during launch week. Move on.

But databases are not static. Data grows. Traffic patterns shift. Your "small lookup table" from six months ago now has four million rows.

The query that ran in 2ms during testing can quietly become a 4-second query in production. Nobody notices until users complain.

Here's the uncomfortable part: indexing advice assumes your query planner always makes good decisions. It doesn't. Query planners are guessing machines. They guess based on statistics, and statistics go stale.

Why Developers Struggle With This

Most backend engineers learn SQL as a language, not as an execution engine. You write SELECT * FROM orders WHERE customer_id = 123, it returns rows, and that feels like magic.

But behind that query is a planner making dozens of decisions:

  • Should it use an index or scan the whole table?
  • Should it join tables in this order or that order?
  • Should it use a hash join or a nested loop?

Developers rarely see this decision-making. So when performance drops, the first (and often only) fix is "add an index." Sometimes that helps. Often it doesn't touch the real issue.

The Real Problem: Stale Statistics

Most relational databases (Postgres, MySQL, SQL Server) use cost-based optimizers. These optimizers don't know your data. They estimate it using statistics — sampled snapshots of your table's shape.

If those statistics are outdated, the optimizer makes bad guesses. It might think a column has 10 distinct values when it actually has 10 million.

Here's a real example from a Postgres system I worked on:

SQL
 
-- Table: events (48 million rows)

EXPLAIN ANALYZE
SELECT *
FROM events
WHERE event_type = 'checkout_completed'
  AND created_at > NOW() - INTERVAL '7 days';


The plan showed a sequential scan, even though we had an index on event_type. Why? The table statistics thought checkout_completed made up 40% of rows. In reality, it was 0.3%.

The fix wasn't a new index. It was this:

SQL
 
ANALYZE events;


One command. Query time dropped from 6.2 seconds to 90 milliseconds.

Lesson: An index is only useful if the planner trusts it's worth using.

Common Mistakes Developers Make

Let's go through the mistakes I see over and over, across different companies and different stacks.

1. Trusting SELECT *

Pulling every column, even ones you don't need, forces the database to read more data pages than necessary. On wide tables, this alone can double query time.

2. Ignoring the N+1 Query Pattern

This one is everywhere in ORM-heavy codebases.

Python
 
# Bad: 1 query for orders + N queries for customers

orders = Order.objects.all()

for order in orders:
    print(order.customer.name)  # triggers a new query each time


Python
 
# Good: 1 query total

orders = Order.objects.select_related("customer").all()

for order in orders:
    print(order.customer.name)


If you have 500 orders, the bad version runs 501 queries. The good version runs 1.

3. Deep Pagination With OFFSET

SQL
 
-- Gets slower as the offset grows

SELECT *
FROM products
ORDER BY id
LIMIT 20
OFFSET 100000;


The database still has to scan and discard 100,000 rows before returning your 20. On a page 5,000 request, this crawls.

Better approach — keyset pagination:

SQL
 
SELECT *
FROM products
WHERE id > 100000
ORDER BY id
LIMIT 20;


This uses the index directly. No wasted scanning.

Pagination Method Performance at Page 10 Performance at Page 5000 Complexity
OFFSET/LIMIT Fast Very slow Low
Keyset (cursor-based) Fast Fast Medium
Precomputed pages Fast Fast High (needs caching)


4. Doing Math on Indexed Columns

SQL
 
-- Index on created_at is useless here

SELECT *
FROM orders
WHERE DATE(created_at) = '2026-07-20';


Wrapping a column in a function usually breaks the database's ability to use its index.

SQL
 
-- This keeps the index usable

SELECT *
FROM orders
WHERE created_at >= '2026-07-20'
  AND created_at < '2026-07-21';


Small rewrite. Big difference.

How Modern Systems Actually Solve This

Real production systems don't rely on a single trick. They layer several defenses.

Plain Text
 
Client Request
      │
      ▼
API Layer
      │
      ▼
Query Cache (Redis)
      ├── cache hit? return here
      ▼
Connection Pool (PgBouncer)
      │
      ▼
Read Replica (for reads) ──── Primary DB (for writes)
      │
      ▼
Query Planner + Statistics
      │
      ▼
Storage Engine


Each layer exists to reduce pressure on the layer below it. Miss the cache, and you hit the pool. Miss the primary's write load, and reads go to a replica.

Connection Pooling Matters More Than People Think

Opening a raw database connection is expensive. It involves a TCP handshake, authentication, and memory allocation on the database side.

Without pooling, a burst of traffic can create hundreds of connections in seconds. Postgres, for example, starts choking well before 500 connections.

Plain Text
 
# pgbouncer.ini

[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb

[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25


With transaction pooling mode, PgBouncer hands out a real database connection only for the duration of a transaction, then returns it to the pool. This lets 1,000 app connections share just 25 real ones.

Lock Contention: The Silent Killer

This is the bottleneck that almost nobody talks about, because it doesn't show up in slow query logs the same obvious way.

Here's what happened to us. A "quick" query started timing out during peak hours:

SQL
 
UPDATE inventory
SET stock = stock - 1
WHERE product_id = 42;


Individually, this query was fast. But during a flash sale, hundreds of these updates hit the same row at the same time.

Each transaction had to wait for the previous one to release its row lock. The queries weren't slow. They were queued.

Plain Text
 
Time  Transaction A          Transaction B          Transaction C
0ms   LOCK row 42            waiting...              waiting...
5ms   UPDATE + COMMIT        LOCK row 42             waiting...
6ms                          UPDATE + COMMIT         LOCK row 42
7ms                                                   UPDATE + COMMIT


How we fixed it:

  • Moved to an eventual-consistency model for stock counts (queue-based decrement)
  • Used SELECT ... FOR UPDATE SKIP LOCKED for job-queue-style tables
  • Batched decrements instead of doing them one row at a time
SQL
 
-- Instead of 100 individual UPDATE statements

UPDATE inventory
SET stock = stock - sub.qty
FROM (
    VALUES
        (42, 3),
        (43, 1),
        (44, 7)
) AS sub(product_id, qty)
WHERE inventory.product_id = sub.product_id;


One batched statement instead of a hundred lock acquisitions.

Isolation Levels: A Trade-off, Not a Setting You Ignore

Most engineers leave the isolation level at whatever the database defaults to. That's usually fine — until it isn't.

Isolation Level Prevents Performance Cost Common Use Case
Read Uncommitted Nothing much Lowest Rarely used, risky
Read Committed Dirty reads Low Default in Postgres, most web apps
Repeatable Read Non-repeatable reads Medium Financial reports, reconciliation
Serializable Phantom reads Highest Banking transactions, inventory locks


Higher isolation means more correctness guarantees. It also means more locking, more retries, and lower throughput.

Don't default to Serializable "to be safe." You'll pay for it in throughput, and most apps don't need it.

Query Plan Reading: A Skill Most Engineers Skip

If you only remember one thing from this article, remember this: learn to read EXPLAIN ANALYZE output. It tells you the truth. Everything else is a guess.

SQL
 
EXPLAIN ANALYZE

SELECT o.id, c.name
FROM orders o
JOIN customers c
    ON o.customer_id = c.id
WHERE o.status = 'pending';


Sample output to watch for:

SQL
 
Hash Join  (cost=120.50..3400.22 rows=850 width=64)
          (actual time=12.100..340.556 rows=42000 loops=1)
  Hash Cond: (o.customer_id = c.id)
  -> Seq Scan on orders o  (cost=0.00..2900.00 rows=850)
                           (actual time=0.020..300.100 rows=42000 loops=1)


Notice the gap: the planner estimated 850 rows. The actual count was 42,000. That's a 49x miss.

When estimated and actual rows differ by a wide margin, that's your signal. Stale statistics, bad indexes, or a query shape the planner can't reason about well.

Denormalization: Sometimes the Right Move

Normalization is taught as the "correct" way to design schemas. In practice, strict normalization can hurt performance on read-heavy systems.

We had a dashboard query joining six tables to compute one number: total revenue per region.

SQL
 
SELECT r.name, SUM(o.total)
FROM orders o
JOIN customers c
    ON o.customer_id = c.id
JOIN regions r
    ON c.region_id = r.id
JOIN order_items oi
    ON oi.order_id = o.id
JOIN products p
    ON oi.product_id = p.id
JOIN categories cat
    ON p.category_id = cat.id
GROUP BY r.name;


This ran in 4 seconds. Dashboard needed sub-second response.

We added a summary table, updated by a nightly job:

SQL
 
CREATE TABLE revenue_by_region (
    region_name TEXT PRIMARY KEY,
    total_revenue NUMERIC,
    updated_at TIMESTAMP
);


Dashboard query became:

SQL
 
SELECT region_name, total_revenue
FROM revenue_by_region;


From 4 seconds to 8 milliseconds. The trade-off: data is now up to 24 hours stale.

This only works if your business can tolerate staleness. For real-time fraud detection, this approach would be wrong. Know your consistency requirements before you denormalize.

Performance Considerations Checklist

Before shipping a query to production, run through this:

✔ Did you check EXPLAIN ANALYZE, not just EXPLAIN? 

✔ Are your table statistics current (ANALYZE run recently)? 

✔ Does the query avoid functions wrapped around indexed columns? 

✔ Are you selecting only the columns you need? 

✔ Is pagination using keyset instead of large OFFSET values? 

✔ Are batch writes used instead of row-by-row loops? 

✔ Is the isolation level appropriate for the use case, not just the default? 

✔ Have you tested this query against production-sized data, not a dev sample?

Security Considerations

Performance work sometimes creates security gaps. Watch for these:

  • Dynamic query building for "flexible filters" often leads to string concatenation, which opens SQL injection risk. Use parameterized queries even for performance-tuned raw SQL.
  • Read replicas used for reporting sometimes get looser access controls because "it's just a read replica." That's still your data.
  • Caching layers (Redis, Memcached) can leak sensitive data if you cache full row objects without checking what's in them.

Scaling Challenges

As systems grow, new problems appear that indexing can't fix:

Plain Text
 
Single DB Instance
       │
       ▼
Growing write load
       │
       ▼
Read Replicas (helps reads, not writes)
       │
       ▼
Still hitting write limits
       │
       ▼
Sharding (splits writes across nodes)
       │
       ▼
Cross-shard joins become painful


Sharding solves write throughput but creates a new problem: joins across shards don't work the way they used to. You end up doing joins in application code, which is slower and more error-prone than letting the database do it.

This is why teams delay sharding as long as possible. It's a last resort, not a first optimization.

What We Learned

A few honest lessons from years of doing this:

  • Statistics decay silently. Schedule ANALYZE (or your database's equivalent) as a routine job, not an afterthought.
  • The slowest part of a query is often not the query itself. It's lock waiting, connection exhaustion, or network round trips.
  • ORMs hide problems well. They also hide the N+1 pattern extremely well. Turn on query logging in staging and actually read it.
  • Caching isn't free. Cache invalidation bugs have cost us more debugging time than the queries we were trying to avoid.
  • Nobody reads execution plans until something breaks. Read them earlier. It's a habit, not a rescue tool.

When Not to Use These Techniques

Not every optimization belongs in every system.

  • Don't denormalize a table that changes every second the sync job will never catch up.
  • Don't add read replicas if your write load, not read load, is the actual bottleneck.
  • Don't reach for sharding if a bigger instance and better indexing would solve it for the next two years.
  • Don't tune isolation levels down for "performance" on a system handling money movement.

Optimization without a clear bottleneck measurement is just guessing with extra steps.

Final Thoughts

Indexing is the first lesson in database performance, not the last one. The real bottlenecks stale statistics, lock contention, bad pagination, and isolation level mismatches don't show up in a "10 SQL Tips" listicle.

They show up at 2 AM, during a traffic spike, when your on-call phone rings.

The next challenge for most teams isn't learning these techniques. It's building the habit of checking for them before a query becomes a production incident. That habit reading EXPLAIN ANALYZE, tracking replication lag, watching lock wait times matters more than any single trick in this article.

Database MySQL Relational database sql

Opinions expressed by DZone contributors are their own.

Related

  • Useful System Table Queries in Relational Databases
  • Common Mistakes to Avoid When Writing SQL Code
  • SQL Commands: A Brief Guide
  • Cloud SQL Guidelines for Cloud Database Administration

Partner Resources

×

Comments

The likes didn't load as expected. Please refresh the page and try again.

  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook