Sign in
TodayMapLearnPracticeReview
Library
14 MINadvancedDatabases & Data SystemsNot started

SQL Tuning

SQL tuning is the discipline of making slow queries fast. The toolkit is small and durable: indexes (B-tree, partial, composite), the query planner, EXPLAIN ANALYZE, connection pooling, and eliminating N+1 queries. Mastering it means thinking in sets, understanding what the planner sees, and never trusting intuition without measurement.

Why this matters

The difference between a 50ms query and a 5000ms query is rarely the database engine — it's almost always the query, the indexes, or the access pattern. Production outages from a missing index or an N+1 loop are some of the most common and most embarrassing database incidents. SQL tuning is also the area where 'I think it should be fast' fails most often — the planner does what it sees, not what you intend. Measuring with EXPLAIN is the only reliable guide.

Prerequisites
  • SQL vs NoSQL
Related
  • Index Table
  • Denormalization
  • Sharding
Used in
  • Index Table
Lesson

How it works

SQL tuning is fundamentally about two things: giving the database the data structures it needs to answer your query without scanning the whole table, and writing queries the planner can understand. The planner is smart but not psychic — it works from statistics, indexes, and the literal shape of your SQL.

The core toolkit, in order of leverage:

  1. Indexes — B-tree (default), composite, partial, expression. The single biggest performance lever. A sequential scan becomes an index lookup: O(N) → O(log N).
  2. EXPLAIN and EXPLAIN ANALYZE — read the query plan the planner generates. Without this, you're tuning blind.
  3. Query rewriting — think in sets, not loops. Replace correlated subqueries with JOINs; replace N+1 patterns with single queries.
  4. Connection pooling — PgBouncer, Pgpool, or pool inside your app. Eliminates connection setup cost.
  5. Schema and statistics — ANALYZE keeps planner statistics fresh; partitioning large tables limits scan scope.
  6. Configuration — work_mem, shared_buffers, effective_cache_size matter, but only after the above are right.

Most SQL tuning 'secrets' are the first two: have the right index, and read EXPLAIN.

Indexes are the dominant SQL performance lever. PostgreSQL (and most databases) support several flavors:

  • B-tree (default) — equality and range lookups. WHERE id = 42, WHERE created_at > '2025-01-01'.
  • Composite — multi-column. Order matters: INDEX(a, b) serves WHERE a=? AND b=? and WHERE a=? but not WHERE b=? alone. Put the most selective column first OR the one used in equality first, depending on the planner.
  • Partial — index only matching rows. CREATE INDEX … WHERE deleted_at IS NULL — smaller index, faster scans, automatic in queries that match the predicate.
  • Expression — index on a function. CREATE INDEX ON users(lower(email)) makes case-insensitive email lookups fast.
  • GIN/GIST — for full-text search, JSONB containment, geospatial.
  • Covering (INCLUDE) — CREATE INDEX … INCLUDE (col1, col2) lets the query be answered from the index alone (an 'index-only scan').

The cost of indexes: writes get slower (every INSERT/UPDATE must update every index), disk usage grows, and the planner can choose the wrong index if statistics are stale. Indexes are a read/write trade-off.

Golden rules:

  1. Index your foreign keys — JOIN and WHERE child.parent_id = ? are the most common slow paths.
  2. Index the columns in your WHERE, JOIN, ORDER BY, and GROUP BY clauses — but only the ones with high selectivity.
  3. Don't index low-selectivity columns (a boolean is_active on a table where 99% are active).
  4. Run ANALYZE after large data loads to refresh planner statistics.
The N+1 query problem

The most common performance bug in ORMs: fetch N records (1 query), then for each record, fetch a related record (N queries) → N+1 total. With N=1000, that's 1001 round trips instead of one JOIN. Symptom in logs: a thousand identical SELECT * FROM user WHERE id=? queries for a single page render. Fix: eager-load with a JOIN or IN (?, ?, …) (includes in Rails, select_related/prefetch_related in Django, Include() in Entity Framework). The N+1 is silent in dev with 10 rows and catastrophic in prod with 10,000.

Query patterns that bite:

  • Sargability — WHERE DATE(created_at) = '2025-01-15' can't use an index on created_at because the function wraps the column. Rewrite as WHERE created_at >= '2025-01-15' AND created_at < '2025-01-16'. A 'sargable' predicate is one the planner can use with an index.

  • SELECT * — fetching all columns when you need two wastes I/O and defeats covering indexes. SELECT id, name FROM … lets the planner use an index-only scan.

  • OFFSET pagination — LIMIT 100 OFFSET 100000 still scans 100,100 rows. Use keyset pagination: WHERE id > last_seen_id ORDER BY id LIMIT 100. Constant time per page.

  • COUNT(*) on big tables — PostgreSQL's COUNT(*) is a full scan; there's no row count cached. Maintain a counter table or use approximate counts.

  • Correlated subqueries — WHERE x IN (SELECT y FROM … WHERE … = outer.z) may execute per-row. Rewrite as a JOIN or EXISTS.

  • OR across columns — WHERE a=? OR b=? may force a full scan. Use UNION of two indexed queries, or composite indexes.

  • Implicit type casts — WHERE varchar_col = 42 may cast every row instead of using an index. Match the column type.

  • LIKE '%foo%' — leading wildcard can't use an index. Use full-text search (GIN/GIST) or trigram indexes (pg_trgm) for substring search.

Every one of these shows up clearly in EXPLAIN ANALYZE as a Seq Scan or a slow node. The planner tells you the problem; the fix is usually an index or a query rewrite.

Connection pooling is invisible until it isn't. PostgreSQL forks a process per connection — a few thousand connections can exhaust memory and cause the 'too many connections' death spiral. Each new connection also pays a TCP handshake, TLS handshake, and Postgres startup cost (~5-10ms).

A pooler (PgBouncer in transaction mode is the standard) keeps a small set of long-lived connections to Postgres and multiplexes application requests across them. The math: 5000 app connections → 50 Postgres connections, with the pooler handling queueing.

Application-side pooling (HikariCP for Java, sqlx for Rust, asyncpg for Python, the pg pool for Node) reduces connection setup cost but doesn't cap Postgres connections unless paired with a server-side pooler. Best practice: app-side pool of 10-30 connections per instance, plus PgBouncer in front of Postgres.

Signs you need a pooler:

  • 'Too many connections' errors under load.
  • p99 latency spikes on connection setup.
  • Postgres memory growing linearly with app instances.
  • A new service instance can't connect because the limit is hit.
Check yourself
interview

A query `SELECT * FROM orders WHERE DATE(created_at) = '2025-01-15'` is slow despite an index on `created_at`. EXPLAIN shows a Seq Scan. What's wrong and how do you fix it?

Pick one answer.

Check yourself
core

Your Rails app has a slow 'user list with orders' page. Logs show 1 query fetching 100 users, then 100 separate queries each fetching one user's orders. What's the problem and the fix?

Pick one answer.

Check yourself
interview

You're paginating a large table with `LIMIT 20 OFFSET 100000`. Page 5000 loads slowly. Why, and what's the better pattern?

Pick one answer.

Check yourself
solid

When you run `ANALYZE` on a PostgreSQL table, what changes?

Pick one answer.

Engineering mental model

Mental model. Think of SQL Tuning as a deliberate boundary in a system. The boundary exists because something becomes harder to manage when everything is done in one place: latency, scale, failure isolation, consistency, cost, or team ownership. The useful question is not “what does SQL Tuning mean?” but “what pressure makes this boundary worth introducing, and what new failure mode does it create?”

Design lens

Before choosing SQL Tuning, name the workload, the critical user path, the dominant bottleneck, the failure you are trying to absorb, and the trade-off you are willing to accept. If you cannot name those five things, the design is probably premature.

Original NO CAP systems visual for SQL Tuning.
Image unavailable. Original NO CAP systems visual for SQL Tuning.
SQL Tuning: a compact system-thinking visual.— Original NO CAP visual.
SELECT id, created_at
FROM records
WHERE tenant_id = ?
ORDER BY created_at DESC
LIMIT 50;

-- Ask: which index makes this query predictable at scale?
A minimal engineering sketch for reasoning about SQL Tuning.

Back-of-the-envelope reasoning

Numerical lens: write down traffic, payload size, read/write ratio, peak multiplier and durability target before choosing a component. The numbers should justify the architecture.

Interactive sandboxdeterministic

Interactive thought experiment: SQL Tuning

Change the variables below and predict what breaks first in SQL Tuning. The production lab can later reuse these same inputs.

System pressure6%
Try this

Change one variable at a time. Predict the failure mode first, then move the slider and see whether your mental model matches the simplified system response.

Hint

If you are stuck on SQL Tuning, start by drawing the request path and marking every network hop, stateful component, queue, cache and failure boundary. Then estimate where the system will saturate.

Check yourself
solid

You increase traffic by 10× in a system using SQL Tuning. What should you inspect first?

Pick one answer.

Check yourself
interview

Which statement is the safest engineering habit when using SQL Tuning?

Pick one answer.

Try this
interview

You have dashboards for traffic, latency, errors and saturation. You can change the architecture, but every change has operational cost.

Production scenario: your system uses SQL Tuning, traffic suddenly spikes, and p99 latency doubles. What is your first move?

Interview drill

Answer this without notes: When would you choose SQL Tuning, and when would you intentionally avoid it? Mention at least one bottleneck it addresses, one failure mode it introduces, and one alternative. Then quantify the workload you are designing for.

Engineering lens

For SQL Tuning, start with access patterns rather than brand names. Identify the dominant reads/writes, data relationships, consistency requirements, partition key, hot keys and failure behavior before choosing a storage strategy.

Numerical sanity check

A rough capacity check: required write throughput ≈ peak writes/s × average record size. At 5,000 writes/s and 2 KB average payloads, the raw incoming data stream is about 10 MB/s before indexes, replication and overhead.

Check yourself
interview

Do not optimize for a memorized definition. Reason from the workload and failure mode.

A team proposes SQL Tuning because it 'scales'. What workload characteristic would make that choice a poor fit?

Pick one answer.

Trade-offs

What you gain, what you pay

Pros
  • +Right indexes turn 10s queries into 10ms queries — biggest single lever in database performance.
  • +EXPLAIN ANALYZE gives visibility into the planner's choices — you're never tuning blind.
  • +Connection pooling allows thousands of app connections to share dozens of DB connections.
  • +Eager loading eliminates N+1 — a one-line code change can 10× a page's load time.
  • +Keyset pagination keeps deep pages fast — no OFFSET cliff.
Cons
  • −Every index slows writes — INSERT/UPDATE/DELETE maintain every index.
  • −Indexes consume disk and memory — over-indexing wastes resources.
  • −Planner choices depend on statistics that can drift — autovacuum tuning matters.
  • −Connection poolers add a hop and complexity (PgBouncer transaction mode breaks session state).
  • −Aggressive tuning can mask architectural problems (e.g., denormalizing instead of fixing the query).
  • −ORMs hide queries — N+1 problems surface late in production.
Failure modes

How this breaks in production

  • Missing index on a foreign key — JOINs and child-by-parent lookups become Seq Scans.
  • N+1 query storms — a slow page is actually a thousand tiny queries.
  • Non-sargable predicates — wrapping a column in a function disables the index.
  • OFFSET pagination cliff — page 1000 takes seconds, page 10000 never returns.
  • Stale statistics — planner chooses an index that's no longer selective.
  • Connection exhaustion — thousands of app connections exhaust Postgres memory.
  • SELECT * — defeats covering indexes and pulls unnecessary bytes from disk.
Common mistakes

Don't fall into these traps

  • •Tuning without EXPLAIN ANALYZE — guessing at the bottleneck.
  • •Adding indexes 'just in case' — slowing writes and bloating storage for no benefit.
  • •Trusting ORM-generated SQL blindly — it's often correct but suboptimal.
  • •Not running ANALYZE after big data loads — planner picks bad plans from stale stats.
  • •Indexing low-selectivity columns (a boolean on a 99%-true table).
  • •Using SELECT * when you need two columns — defeats covering indexes and wastes I/O.
  • •OFFSET pagination for infinite scroll — should be keyset.
Where you see it

Real systems using this

Every production SQL database — Postgres, MySQL, SQL Server, Oracle.On-call debugging of slow endpoints — first look is the slow query log + EXPLAIN.Schema review and migration planning — 'will this query use an index?'Capacity planning — connection pool sizing, read replica provisioning.
Teardowns

How real systems implement this

  • GitHub — Runs one of the largest MySQL deployments in the world; their engineering blog has detailed posts on query tuning, keyset pagination, and connection pooling via ProxySQL. They've moved much of their workload to Vitess (MySQL sharding middleware).
  • Shopify — Operates a massive MySQL fleet behind Vitess. Their engineering blog covers slow-query hunting, N+1 elimination across a huge Rails codebase, and pool tuning.
  • Stripe — Uses PostgreSQL extensively with PgBouncer in front. Documented patterns include careful index design for financial queries, keyset pagination for ledger iteration, and explicit planner-statistics management after large data loads.
  • Uber — Migrated parts of their schema from Postgres to MySQL due in part to Postgres connection-per-process overhead and replication concerns, then built Schemaless on top. A useful case study in how tuning pain can drive architectural shifts.
Interview prompts

Practice saying it out loud

  • Q1Walk through how you'd diagnose and fix a slow SQL query.
  • Q2What is an N+1 query and how do you fix it? Show examples in your ORM of choice.
  • Q3Why might a query with an index on the filtered column still do a Seq Scan?
  • Q4Compare OFFSET pagination and keyset pagination. When is each appropriate?
  • Q5Why does PostgreSQL fork a process per connection, and what does that imply for connection pooling?
  • Q6What's the difference between EXPLAIN and EXPLAIN ANALYZE? What does ANALYZE (the standalone command) do?
Research

Further reading & references

System Design Primer
Open source
ByteByteGo — Scale from zero to millions
ByteByteGo
System Design Tutorial
GeeksforGeeks
System Design Roadmap
roadmap.sh
Databases & Data Systems reference
Reference
Databases & Data Systems reference
Reference
Databases & Data Systems reference
Reference

Core explanations are original NO CAP material. External references are provided for deeper study and standards.

What next?

Mark as understood once the mental model clicks.

Next recommended

Index Table