Performance

Database Query Performance and Indexing: The Rules That Actually Matter

Most database performance incidents get diagnosed as an indexing problem and closed with an index. This guide is about the other 80% - the query shape, round-trip count, and containment decisions that no index can fix - and where each one actually breaks in a growing codebase.

8 min read · 8 Principles ·
Fifteen mint-green cylinders arranged in a 3 by 5 grid on a matching green surface.

TL;DR

  • Indexes only help if they cover the columns your queries actually filter, join, and sort on - foreign keys and query predicates need one from the migration that introduces them, not from a later incident.
  • A function, cast, or leading wildcard on an indexed column hides that index from the query planner even when the index exists and is correctly built.
  • A query issued once per row in a loop, or a write issued once per row, multiplies network round trips by the size of the result set - no index fixes that.
  • Every collection query on a request path needs an explicit row limit, and deep pagination needs a keyset predicate instead of OFFSET, because both costs grow with data volume regardless of indexing.
  • Selecting unused columns and aggregating in application code both move more data across the network than the request needs - name the columns, and let the database compute the sum.
  • An explicit connection pool size, acquisition timeout, and statement timeout turn one slow query into a contained failure instead of an outage that takes down every other request sharing the pool.
  • No single code reviewer can hold six disciplines across thousands of query call sites spread over dozens of services - query performance has to be checked as a property of the whole codebase, not of any one pull request.

What Database Query Performance Discipline Actually Protects

Ask five engineers why a query got slow and four will blame the database. That's usually wrong. The person who wrote SELECT * or skipped the LIMIT wasn't cutting a corner - they were doing the fastest, most defensible thing available to someone who could only see their own endpoint. A query that returns in 40ms in development gives no signal about what it costs at ten million rows.

None of the seven principles below are really about carelessness. They're about the fact that indexing is the visible 20% of query performance work, and the other 80% - round trips, result size, containment - is invisible from any seat that only ever tests against a small dataset.

1. Indexes Must Match Real Query Patterns

A join that used to return in 40ms starts taking 4 seconds as a table crosses a few million rows, and the query hasn't changed - only the data has grown into a foreign key column nobody indexed. Index every foreign key column in the migration that creates it, and index every column that appears in a WHERE or ORDER BY clause in the migration that introduces the query - not in a follow-up migration written during an incident. The primary key index covers lookups by ID. It covers nothing else.

✕ a foreign key column added with no index
CREATE TABLE orders (
  customer_id BIGINT REFERENCES customers(id)
);
-- no index on customer_id
✓ indexed in the same change, built without a lock
CREATE INDEX CONCURRENTLY idx_orders_customer_id
  ON orders (customer_id);

Every index added here slows writes to that table and costs storage - index the columns a query actually filters, joins, or sorts on, and always build with CONCURRENTLY. A plain blocking build holds a write lock for the full duration.

2. Wrapped Columns Make Indexes Invisible

A team indexes email, confirms the index exists in the schema, and still watches the login endpoint run a full table scan under load. The query is WHERE LOWER(email) = ? — a reasonable line to write, and one that makes the index on email invisible to the planner, because the planner sees a function call, not a bare column it can use for an access path. Never apply a function, cast, or transformation to the indexed side of a predicate.

✕ a function on the indexed column hides it from the planner
SELECT id FROM users
WHERE LOWER(email) = 'sam@acme.com';
✓ normalize the parameter, not the column
SELECT id FROM users
WHERE email = LOWER('sam@acme.com');

The same rule breaks a different way with a leading wildcard: LIKE '%refund%' can't anchor to a B-tree index, but LIKE 'refund%' can. Genuine substring search should reach for a trigram index instead of a leading-wildcard workaround.

3. Per-Row Queries Multiply Round Trips

A dashboard that lists 50 orders makes 51 database round trips: one to fetch the orders, and one more per order to lazily load its customer. Nobody wrote a loop that looks alarming — an ORM did it automatically the first time someone touched order.customer inside a for loop. The same tax hits the write side: inserting one event at a time pays a full round trip per row instead of per batch.

✓ one round trip for the whole batch, not one per row
INSERT INTO events (user_id, kind)
VALUES (1, 'click'), (2, 'click'), (3, 'click');

Vlad Mihalcea's canonical writeup of the N+1 query problem covers the ORM side of this same mistake in detail — the fix there is an eager-loading directive, but the underlying defect is identical: one query per row instead of one query for the set.

Eager-loading every association by default just relocates the N+1 problem into one oversized query. Scope eager loads to what the specific code path needs, the same way a batch size should match the write volume, not exceed it.

4. Unbounded Queries Grow Into Outages

A paginated list endpoint is fast on page 1 and unusably slow on page 200, because OFFSET 4000 tells the database to read and discard four thousand rows before it can hand back the twenty the client asked for. The same shape shows up with no pagination at all: an endpoint with no LIMIT, quietly fine until a customer with 200,000 records hits it and the request times out or exhausts memory.

✓ cost stays constant regardless of page depth
SELECT id, status FROM orders
WHERE created_at < :last_seen
ORDER BY created_at DESC LIMIT 20;

This is the keyset pagination technique — sometimes called the seek method — and it replaces OFFSET's linear cost with a WHERE condition on the last value the client already saw.

Keyset pagination can't jump straight to an arbitrary page number the way OFFSET can. If the product genuinely needs 'go to page 47,' that's a real constraint to negotiate, not a detail to quietly drop.

5. Stop Shipping Data You Don't Need

An endpoint that only displays a customer's name and order status still pulls every column on the row, including a JSON blob nobody renders, because SELECT * was the fastest thing to type. A few tables away, a report sums a year of transactions by fetching every row into application memory and adding them up in a loop - work the database was already built to do in one pass, without putting the raw rows on the wire.

✕ transfers every column, aggregates after the fact
SELECT * FROM orders WHERE customer_id = :cid;
-- summed in application code after fetching every row
✓ names what's used, aggregates where the data lives
SELECT id, status, total_cents FROM orders WHERE customer_id = :cid;
SELECT SUM(amount) FROM payments WHERE customer_id = :cid;

Pick any query on a hot path and check whether it could qualify for an index-only scan if it named its columns instead of selecting everything — usually the same query worth checking for app-side aggregation too.

6. Timeouts Contain the Blast Radius

One slow query holds a connection open. Ten slow queries at once hold ten connections open. If the pool has no ceiling, the database runs out of connections; if the pool has a ceiling but no acquisition timeout, every other request queues silently behind those ten until the whole service looks down - even though nine services out of ten never touched the slow query.

Does every connection pool in your services have an explicit maximum size and acquisition timeout, or is it running on whatever the driver defaults to? Check the statement timeout on the same connections while you're there.

PgBouncer's own configuration reference and Postgres's documentation on statement_timeout are the two settings worth having memorized — one bounds the wait for a connection, the other bounds how long a query gets to hold one.

7. No Reviewer Scales to Every Query

None of the six principles above is controversial, and no engineer who reads this guide will disagree with a single line of it. The problem shows up anyway, because a codebase with 150 engineers doesn't have six query patterns - it has thousands of call sites, spread across services and ORMs, each hiding its own version of the same six mistakes behind a slightly different API.

Count how many distinct call sites, across any three services, issue raw SQL or ORM queries outside a shared query layer. If that number is in the hundreds and no single team owns all of them, the discipline above is already drifting somewhere nobody can see from a code review.

This is the principle that makes the rest of this guide's argument land, because nobody who introduced any single violation did anything a reviewer would have flagged. Query performance decays as an aggregate property of the codebase - which is why it has to be checked as one.

How Pandorian Enforces This Guide

Every principle above becomes a specific, checkable condition once it's expressed as a rule instead of a habit someone has to remember. Pandorian enforces this guide across three surfaces:

Caught at PR level

Every pull request is checked against the diff before merge. A wrapped predicate, a missing LIMIT, a query sitting inside a loop - these surface with the specific line, the rule, and a suggested fix where one is mechanical.

Full scans

An on-demand scan across the whole codebase establishes the day-one baseline: the unindexed foreign keys from three years of migrations, the SELECT * queries written before anyone owned this standard, the N+1 call sites nobody had reason to look for.

Scheduled scans

The same full scan, run on a cadence, catches drift introduced outside any pull request - a config change that dropped the statement timeout, a dependency upgrade that reset a connection pool default.

A Taste of the Guideline Package

Four of the checks that ship in this package - each runs at the PR level, on full or scheduled scans, or several at once.

RULE WHAT IT FLAGS
PRF-61350
A predicate that wraps an indexed column in a function or cast - LOWER(email) = ?, DATE(created_at) = ?, CAST(id AS text) = ?.
DAT-74059
A foreign key column declared in a migration with no corresponding index.
PRF-29716
A database session or connection pool configuration with statement timeout unset or explicitly disabled.
PRF-39481
A query, or a lazily-loaded association access, executed inside a loop over another query's results.

FAQ

What is database query performance discipline?

+

Database query performance discipline is the practice of enforcing, at the code level, the access patterns that keep query cost predictable as data volume grows: bounded result sets, indexed predicates, no queries inside loops, and no unbounded scans on the request path. It treats query shape as a reviewable property of the code rather than something discovered later from a slow query log.

The reason it needs enforcing is that almost every query that fails at scale passed review at small scale. Correctness and cost diverge only once the table gets big.

What is the N+1 query problem?

+

The N+1 query problem is issuing one query to fetch a list, then one additional query per item in that list, usually through an ORM's lazy loading. Fetching 200 orders and then each order's customer produces 201 round trips instead of two. It is invisible in a unit test with three rows and catastrophic in production.

The fix is eager loading, a join, or a batched second query keyed by the collected IDs. The harder part is noticing, because the code reads naturally and the ORM makes the extra queries silently.

What is a covering index?

+

A covering index is an index that contains every column a query needs, so the database can answer entirely from the index without reading the table rows. In Postgres this enables an index-only scan; in SQL Server it is usually built with INCLUDE columns. The payoff is large on wide tables where the row is far bigger than the columns actually selected.

The cost is equally real. A covering index is bigger, slows writes more than a narrow one, and only helps the specific query shapes it covers, so it is a targeted optimization rather than a default.

Can you have too many indexes?

+

Yes. Every index is paid for on every insert, update and delete that touches its columns, consumes storage and cache that competing workloads need, and adds a candidate the planner must evaluate. Write-heavy tables with a dozen indexes routinely spend more time maintaining indexes than serving reads.

The useful framing is not a count but a ratio. An index earns its place if a real query shape uses it often enough to offset the write cost it imposes. Indexes added speculatively, or left behind after the query that justified them was deleted, are pure overhead, and most databases can report which ones have never been scanned.

How do I find the queries that will break first at scale?

+

Look for shape, not current latency. Find queries whose predicate columns have no supporting index, collection endpoints with no server-enforced page size, any query issued inside a loop or a per-item ORM accessor, and SELECT * on tables with large columns. Each of these is fine today and non-linear later.

Then run one measurement: for your top endpoints, compare rows examined against rows returned. A large and growing ratio is the earliest reliable signal that a query is scanning its way toward an incident.

Can't an APM tool or the query planner handle this?

+

An APM tool tells you which query is slow now, in production, after it shipped. The query planner optimizes the query you wrote, and cannot rewrite an N+1 into a batch or invent a page limit you never set. Both are detection after the fact, at the point where fixing it means a hotfix and a migration under pressure.

Enforcement moves the check left: query shape rules applied to every pull request, plus full scans that inventory the unbounded queries and unindexed predicates already sitting in the codebase.

Get the Guideline Package

You'll get this as a guideline MD file - hand it straight to your own coding agent or CI setup. Or skip the DIY step: give these same guidelines real enforcement superpowers, with PR checks and scans live on your repos, through Pandorian.

From Principles to Enforcement

Every rule in this guide is easy to agree with in a design review and easy to violate six sprints later, one locally-correct decision at a time. Pandorian turns boundary discipline like this into CI guardrails - scoped to the repos, teams or services you choose.