Query Performance Bottlenecks
"The database is slow" is almost never true as stated — it's usually one
specific query doing more work than it needs to, an index that doesn't
exist or doesn't get used, or the application making far more round trips
than the task requires. This article covers how to read EXPLAIN output,
when indexes actually help, common anti-patterns, and the tooling to find
the slow query in the first place.
Reading EXPLAIN / EXPLAIN ANALYZE
EXPLAIN shows the query planner's chosen execution plan and its cost
estimates without running the query; EXPLAIN ANALYZE actually runs it and
shows real timings and row counts alongside the estimates — the second is
what you want when diagnosing an already-slow query.
Seq Scan on orders (cost=0.00..18334.00 rows=12 width=97)
(actual time=0.021..142.558 rows=11 loops=1)
Filter: (customer_id = 42 AND status = 'pending')
Rows Removed by Filter: 999989
Planning Time: 0.112 ms
Execution Time: 142.601 ms
What to read out of this:
- Seq Scan vs. Index Scan vs. Index-Only Scan. A
Seq Scanreads every row in the table and filters afterward — fine for a small table, a red flag on a large one being filtered down to a handful of rows, as above (1 million rows scanned to find 11). AnIndex Scanuses an index to jump straight to matching rows, then fetches the full row from the table. AnIndex-Only Scanis the fastest of the three: every column the query needs is present in the index itself, so it never touches the table at all. - Cost estimates (
cost=0.00..18334.00) are the planner's predicted relative cost (startup cost..total cost, in arbitrary planner units, not milliseconds) — useful for comparing plans, not for reading as real time. rows=12(estimated) vs.rows=11 actual— a small gap like this is normal. A large mismatch (planner expects 12 rows, actually gets 400,000) is one of the most common root causes of a bad plan: the planner chooses a plan that would be efficient for the row count it expects, and that choice turns out wrong for the row count it actually gets. This usually means table statistics are stale —ANALYZE tablenamerefreshes them.
Index Only Scan using idx_orders_customer_id on orders
(cost=0.42..8.65 rows=11 width=4)
(actual time=0.018..0.021 rows=11 loops=1)
Index Cond: (customer_id = 42)
Heap Fetches: 0
Planning Time: 0.083 ms
Execution Time: 0.041 ms
Same table, indexed column, only selecting the indexed column: 142ms drops to 0.04ms. That gap is the entire reason indexing matters.
When an Index Helps (and When It Doesn't)
Selectivity is the key factor: an index only helps when it narrows the
result down to a small fraction of the table. An index on a status column
with only two possible values (active/inactive, roughly 50/50 split)
gives the planner little to work with — it may reasonably choose a sequential
scan anyway, since "half the table" isn't meaningfully cheaper to fetch via
an index than via a scan.
-- Low selectivity — an index here often won't get used
CREATE INDEX idx_orders_status ON orders (status); -- 'pending'/'shipped' only
-- High selectivity — a great index candidate
CREATE INDEX idx_orders_customer_id ON orders (customer_id); -- many distinct values
Composite index column order matters. A multi-column index is only
usable left-to-right — an index on (customer_id, status) speeds up
queries filtering on customer_id alone or on customer_id AND status, but
does nothing for a query filtering on status alone.
CREATE INDEX idx_orders_customer_status ON orders (customer_id, status);
-- Uses the index (customer_id is the leading column)
SELECT * FROM orders WHERE customer_id = 42;
SELECT * FROM orders WHERE customer_id = 42 AND status = 'pending';
-- Cannot use this index (status is not the leading column)
SELECT * FROM orders WHERE status = 'pending';
Pro Tip
Put the column used in equality filters (= value) before columns used
in range filters (> value, BETWEEN) in a composite index — an
equality-then-range order lets the index narrow down by the equality
column first, then scan a tight range within it; range-then-equality
can't narrow the same way.
Covering indexes include every column a query needs (in the SELECT,
WHERE, and ORDER BY), so the query never touches the table at all — the
index-only scan case above. Postgres's INCLUDE clause adds columns to the
index for this purpose without making them part of the sort order:
An index isn't free, though — every index adds write overhead (each insert/
update maintains every index on the table) and storage. An index that no
query actually uses is pure cost; periodically checking for unused indexes
(pg_stat_user_indexes in Postgres) is worth doing on a mature schema.
The N+1 Problem (Database-Access-Pattern Level)
At the database level, N+1 is simply: one query to fetch a list of N
records, followed by N more individual queries — one per record — to
fetch related data, instead of one query (a join, or a single WHERE id IN
(...)) that gets it all at once.
# N+1: 1 query for orders, then 1 query per order for its customer
orders = db.execute("SELECT * FROM orders LIMIT 50").fetchall()
for order in orders:
customer = db.execute(
"SELECT * FROM customers WHERE id = %s", (order.customer_id,)
).fetchone()
# Fixed: 2 queries total, regardless of how many orders there are
orders = db.execute("SELECT * FROM orders LIMIT 50").fetchall()
customer_ids = [o.customer_id for o in orders]
customers = db.execute(
"SELECT * FROM customers WHERE id = ANY(%s)", (customer_ids,)
).fetchall()
This is exactly the pattern an ORM's lazy-loaded relationship attribute
tends to trigger silently — looping over order.customer.name for 50 orders
issues 50 separate queries unless the relationship was eagerly loaded up
front. The API-shaped version of this same problem — a GraphQL resolver or a
REST endpoint fanning out into per-item requests — is covered separately in
API Pitfalls: Over-, Under-Fetching & N+1;
the root cause and fix are the same idea, just one layer up the stack.
Common Anti-Patterns
SELECT * pulls every column, including ones the query doesn't need —
this defeats covering/index-only scans (the index can't cover columns it
doesn't know are needed) and wastes network bandwidth on wide tables with
large text/blob columns nobody asked for.
Missing indexes on foreign keys. Postgres does not automatically index foreign key columns (MySQL/InnoDB does, for the FK itself). An unindexed FK column means every join or lookup on it is a sequential scan, and — because of how row-level locking interacts with FK constraint checks — it can also make deletes on the referenced table slower and more lock-contentious than necessary.
-- Postgres: this FK gets no automatic index — add one explicitly
ALTER TABLE orders ADD COLUMN customer_id INTEGER REFERENCES customers(id);
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
Functions applied to indexed columns. Wrapping an indexed column in a
function in the WHERE clause usually prevents the index from being used at
all, because the index stores the raw column value, not the function's
output.
-- Can't use a plain index on created_at — the function runs on every row
SELECT * FROM orders WHERE DATE(created_at) = '2026-08-15';
-- Rewritten as a range — uses a plain index on created_at
SELECT * FROM orders
WHERE created_at >= '2026-08-15' AND created_at < '2026-08-16';
-- Or: index the expression itself (Postgres)
CREATE INDEX idx_orders_created_date ON orders (DATE(created_at));
Unbounded OFFSET pagination. LIMIT 20 OFFSET 100000 still has to
count through (and discard) 100,000 rows before returning the next 20 — cost
grows linearly with how deep into the result set you page, which is exactly
where pagination gets used most (infinite scroll on a large, popular
dataset).
-- Gets slower and slower as offset grows
SELECT * FROM orders ORDER BY id LIMIT 20 OFFSET 100000;
-- Keyset (cursor) pagination — constant cost regardless of "how deep"
SELECT * FROM orders WHERE id > 100000 ORDER BY id LIMIT 20;
Keyset pagination trades away "jump to page N" for "here's the next page after this cursor," which is the right trade for most feeds and infinite scroll UIs, and the wrong one for a UI that genuinely needs numbered page links.
Connection Pool Exhaustion Isn't a Query Problem
A symptom that looks exactly like "the database is slow" but has nothing to do with query plans: every connection in the application's pool is checked out (in use, or leaked and never returned), so new requests queue up waiting for a connection instead of waiting on a query. From the outside — request latency spiking — it's indistinguishable from a slow query until you look at what's actually being waited on.
Common causes: pool size too small for actual concurrency, a code path that
opens a session/connection and never closes it (missing try/finally or
context manager), or — the ironic case — genuinely slow queries holding
connections checked out for longer than usual, which then causes
exhaustion as a secondary effect. The fix for a too-small pool is sizing it
correctly for real concurrency; the fix for a leak is finding and closing
the leaked connection, not adding more pool capacity to paper over it.
Tooling
pg_stat_statements (Postgres) — an extension that tracks every
distinct query shape the server has executed, aggregated with total/mean
execution time and call count. The single best starting point for "what's
actually slow" on a running Postgres instance, since it doesn't require
guessing which query to EXPLAIN — it ranks them for you.
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
Slow query log (MySQL) — logs any query exceeding a configured
threshold, for offline analysis (mysqldumpslow, pt-query-digest).
Both tools answer the same question — "which query is actually costing the most time in aggregate" — which is a different (and usually more useful) question than "is this one query I'm staring at slow," since the biggest win is often a moderately-slow query called 10,000 times an hour, not the rare 3-second outlier.
Summary
EXPLAIN ANALYZEshows real timings against the plan; watch for Seq Scan on large tables, and a large estimated-vs-actual row mismatch (often a stale-statistics problem, fixed withANALYZE).- Indexes help in proportion to selectivity; composite index column order is left-to-right only; covering indexes avoid touching the table at all.
- N+1 at the database level is one query for a list plus N queries for
related rows — fix with a join or a single
WHERE id IN (...); see API Pitfalls: Over-, Under-Fetching & N+1 for the same problem one layer up. - Common anti-patterns:
SELECT *, unindexed foreign keys, functions wrapping indexed columns, andOFFSET-based pagination that gets slower the deeper a user pages. - Connection pool exhaustion presents as "the database is slow" but is actually an application-side resource problem, not a query problem.
pg_stat_statements(Postgres) and the slow query log (MySQL) find the actually-expensive query in aggregate, rather than guessing.
Related Articles
- ACID — isolation levels affect how much locking a query does under concurrency, which shows up as latency too.
- Locking, Deadlocks, and Concurrency — long-running queries widen the window for lock contention.
- Redis as a Caching Layer — caching a hot read is often a cheaper fix than optimizing the query further.
- SQL vs. NoSQL — an unindexed
foreign key is a relational-specific trap; MongoDB's
$lookuphas its own, different performance characteristics.