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.
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.
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.
CREATE TABLE orders ( customer_id BIGINT REFERENCES customers(id) ); -- no index on customer_id
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.
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.
SELECT id FROM users WHERE LOWER(email) = 'sam@acme.com';
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.
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.
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.
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.
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.
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.
SELECT * FROM orders WHERE customer_id = :cid; -- summed in application code after fetching every row
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.
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.
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.
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:
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.
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.
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.
Four of the checks that ship in this package - each runs at the PR level, on full or scheduled scans, or several at once.
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.
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.
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.
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.
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.
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.
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.
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.