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.
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:
- Indexes — B-tree (default), composite, partial, expression. The single biggest performance lever. A sequential scan becomes an index lookup: O(N) → O(log N).
- EXPLAIN and EXPLAIN ANALYZE — read the query plan the planner generates. Without this, you're tuning blind.
- Query rewriting — think in sets, not loops. Replace correlated subqueries with JOINs; replace N+1 patterns with single queries.
- Connection pooling — PgBouncer, Pgpool, or pool inside your app. Eliminates connection setup cost.
- Schema and statistics —
ANALYZEkeeps planner statistics fresh; partitioning large tables limits scan scope. - Configuration —
work_mem,shared_buffers,effective_cache_sizematter, 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)servesWHERE a=? AND b=?andWHERE a=?but notWHERE 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:
- Index your foreign keys —
JOINandWHERE child.parent_id = ?are the most common slow paths. - Index the columns in your
WHERE,JOIN,ORDER BY, andGROUP BYclauses — but only the ones with high selectivity. - Don't index low-selectivity columns (a boolean
is_activeon a table where 99% are active). - Run
ANALYZEafter large data loads to refresh planner statistics.
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 oncreated_atbecause the function wraps the column. Rewrite asWHERE 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 100000still 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 = 42may 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.
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.
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.
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.
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?”
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.
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?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 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.
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.
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.
You increase traffic by 10× in a system using SQL Tuning. What should you inspect first?
Pick one answer.
Which statement is the safest engineering habit when using SQL Tuning?
Pick one answer.
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.
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.
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.
What you gain, what you pay
- +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.
- −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.
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.
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.
Real systems using this
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.
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?
Further reading & references
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