Skip to content
Back to course
Lesson 20 of 21 · Part 2

SQL Tuning: Finding and Fixing Slow Queries

A practical workflow for database performance: profile first, then fix schemas, indexes, joins, pagination and hot tables.

intermediate8 min read2 diagramsUpdated 31 Oct 2026✓ Completed
On this page

TL;DR: Do not guess. Measure which queries use the most total time, read their plans, then fix the cheapest thing that works, which is usually a missing index or a badly written query. New hardware, caches and shards come after that.

Learning Objectives

After this module, you will be able to:

  • Use profiling and benchmarks to find the queries worth fixing
  • Read a query plan and spot a bad scan
  • Design composite and covering indexes
  • Choose column types that avoid slow or incorrect behaviour
  • Fix N+1 queries and slow pagination

Intuition

A mechanic does not replace parts because a car feels slow. They connect the diagnostic tool, find the fault and fix that. Database tuning is the same. The query that looks scary is rarely the one hurting you. Often it is a 5 ms query running 20,000 times a minute.

Theory

The tuning loop

Diagram
Change one thing at a time, otherwise you cannot tell what helped.
flowchart LR
    M["Measure the workload"] --> F["Find the top queries by total time"]
    F --> P["Read the plan"]
    P --> C["Change one thing"]
    C --> V["Verify with a benchmark"]
    V --> M

Profile to find the queries that matter. PostgreSQL has pg_stat_statements and MySQL has the slow query log and the Performance Schema. Sort by total time (average time multiplied by calls), rather than by the slowest single query.

Benchmark to test a change under load. Tools such as pgbench, sysbench and k6 work. Use data of realistic size, because a plan that looks fine on a thousand rows can collapse on a hundred million.

Reading a plan

EXPLAIN shows the plan the database chose, and EXPLAIN ANALYZE actually runs the query and reports real numbers, so be careful using it on writes.

EXPLAIN ANALYZE
SELECT id, total FROM orders
WHERE customer_id = 42 AND status = 'open'
ORDER BY created_at DESC LIMIT 20;

Look for these warning signs:

In the planWhat it usually means
Sequential or full table scan on a big tableA missing or unusable index
Estimated rows far from actual rowsStale statistics, so run ANALYZE
A sort step on many rowsAn index could provide the order
Nested loop over a huge number of rowsA missing index on the join column
Many rows removed by a filterThe index is not selective enough

Designing indexes

Index the columns used in WHERE, JOIN and ORDER BY, but every index slows writes and takes space, so each must earn its place.

  • Column order matters. A composite index on (customer_id, status, created_at) serves the query above. Put equality filters first, then the sort or range column. It cannot efficiently serve a filter on status alone.
  • Covering indexes contain every column a query needs, so the table itself need not be read.
  • Partial indexes cover only some rows, such as WHERE status = 'open', and stay small when most rows are closed. PostgreSQL supports these directly.
  • Keep filters usable. WHERE LOWER(email) = 'a@b.com' cannot use a plain index on email. Use an index on that expression, or store a normalized value. The same goes for a leading wildcard such as LIKE '%term'.
  • Remove unused indexes. They cost writes and give nothing back. Check the database’s index usage statistics.

For bulk loads, it can be faster to drop secondary indexes, load the data and then rebuild them.

Choosing column types

GuidelineWhy
Use DECIMAL for moneyFloating point cannot represent many decimal values exactly
Use BIGINT for IDs that could growA signed INT runs out at about 2.1 billion, and changing a large table’s key is painful
Use TIMESTAMPTZ or the database’s equivalent for timesAvoids time zone mistakes
Store a link rather than a large blobBig values bloat backups and memory, so put files in object storage
Use NOT NULL where a value is requiredPrevents bad data and simplifies queries

Fixing query patterns

N+1 queries are the most common problem in application code, and ORMs hide them:

# 1 query for orders, then 1 more for each order: slow
for order in orders:
    print(order.customer.name)

# 2 queries in total: load all the customers at once
customers = load_customers([o.customer_id for o in orders])

Deep pagination gets slower as the offset grows, since the database still walks past all the skipped rows. Keyset pagination stays fast at any depth:

-- Slow when the offset is large
SELECT * FROM posts ORDER BY id DESC LIMIT 20 OFFSET 100000;

-- Fast at any depth, because it starts from the last row seen
SELECT * FROM posts WHERE id < 8841 ORDER BY id DESC LIMIT 20;

SELECT * reads columns you do not need, which can prevent a covering index from working and moves more data over the network.

Expensive joins that remain slow after indexing are a candidate for denormalization, but only for reads that have shown they need it.

Splitting hot tables

  • Vertical split: move frequently updated or rarely read columns to a side table. A wide users table with a constantly changing last_seen_at is a typical case.
  • Partitioning: divide a big table by date or another key inside one database. Queries that filter on that key skip other partitions, and dropping an old partition is far cheaper than a large DELETE.

Caching inside the database

Give the database enough memory for its working set, using shared_buffers in PostgreSQL or the buffer pool in InnoDB. Be careful with built-in query result caches. MySQL removed its query cache in version 8.0, because it became a bottleneck on busy, write-heavy systems. Caching results in your application gives you more control.

Know when to stop

Diagram
Each step costs more than the one before it and adds more to operate, so work from the top.
flowchart TD
    A["The query is slow"] --> B["Fix the query or add an index"]
    B --> C["Fix the schema or denormalize"]
    C --> D["Add memory or use a bigger server"]
    D --> E["Add a cache or read replicas"]
    E --> F["Federate or shard"]

Real-World Example

An orders page times out at peak. pg_stat_statements shows that the top query takes 40% of total database time, and EXPLAIN ANALYZE shows a scan of millions of rows followed by a sort, for 20 results. Adding an index on (customer_id, created_at DESC) replaces both the scan and the sort. The team creates it with CREATE INDEX CONCURRENTLY so that writes are not blocked. A planned read replica is no longer urgent. (This scenario is illustrative.)

Trade-offs

ChangeHelpsCosts
New indexReads on those columnsSlower writes, more storage
DenormalizeComplex readsDuplicate data to keep in sync
Partition a tableFiltered queries, deleting old dataQueries without the key may get slower
Larger serverEverything, immediatelyMoney, and it only delays the problem

Common Pitfalls

Tuning without production-sized data. Plans change with size, so test against realistic volumes.

Indexing every column. Each index slows every write. Index for actual queries.

Creating an index that locks the table. A plain index build can block writes on a live table. Use PostgreSQL’s CREATE INDEX CONCURRENTLY, or check your engine’s online options.

Chasing the slowest query. A query that takes 10 seconds and runs once a day may matter less than a 5 ms query that runs constantly.

Exercise

This query powers a feed and is slow: SELECT * FROM posts WHERE author_id IN (...) AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 20 OFFSET 5000. List what you would measure and change.

Solution sketch

Check its share of total time and read the EXPLAIN ANALYZE output first. Then add a partial index on (author_id, created_at DESC) WHERE deleted_at IS NULL, select only the needed columns, and replace OFFSET with keyset pagination on (created_at, id). Large author lists remain expensive, so this may eventually need a precomputed feed.

Key Takeaways

  • Rank queries by total time, and read the plan before changing anything.
  • Most gains come from indexes and query fixes, not new infrastructure.
  • Composite index order matters, with equality filters first and then sort or range columns.
  • Look out for N+1 queries, deep OFFSET and SELECT *.
  • Verify general advice against your database and its current version.

Further Reading

Flashcards

How should you rank queries when tuning?

By total time, meaning average time multiplied by the number of calls, since frequent fast queries can cost more than rare slow ones.

What does a large gap between estimated and actual rows in a plan suggest?

Stale statistics, which can lead the planner to a bad choice. Run ANALYZE.

How should you order columns in a composite index?

Equality filters first, then the sort or range column.

What is a covering index?

An index containing all the columns a query needs, so the table does not have to be read.

What is the N+1 query problem?

Running one query for a list, then one more for each item. Fix it by fetching related rows in a batch.

Why is deep OFFSET pagination slow?

The database still walks past all the skipped rows. Keyset pagination starts from the last row seen.

Why should you be careful with built-in query caches?

They can become bottlenecks on write-heavy systems. MySQL removed its query cache in version 8.0.

References

Finished reading?

Mark it complete to track your progress through the course. Progress is saved in this browser.

This lesson is adapted from The System Design Primer by Donne Martin, used under CC BY 4.0. Changes: expanded and rewritten for this site with new explanations, examples, exercises and diagrams, and some outdated advice corrected. This adaptation is shared under CC BY-SA 4.0.