Indexing and queries
B-trees, composite indexes, query plans, N+1 problem.
A slow query is almost always a missing or wrong index. Learn to read EXPLAIN, design
composite indexes for compound predicates, and spot the N+1 anti-pattern, and you'll fix
90% of the database performance complaints you'll ever see.
The big idea
An index is a sorted lookup structure the database keeps alongside the table. Without one, every query scans every row. With the right one, the database jumps straight to the rows it needs.
- RootIDs 1–10000
- Branch1–5000
- Leafpages with rows 1–2500
- Leafpages with rows 2501–5000
- Branch5001–10000
- Leafpages with rows 5001–7500
- Leafpages with rows 7501–10000
Single-column indexes
Cheap and obvious — when a column shows up alone in a WHERE, give it an index.
-- Without an index, this scans the whole users table.
SELECT * FROM users WHERE email = '[email protected]';
-- With this, it's a B-tree lookup: a few page reads.
CREATE INDEX idx_users_email ON users (email);UNIQUE constraints quietly add an index too — you don't always need a separate one.
Composite indexes — order matters
When the WHERE clause has multiple columns, a composite index gives you one fast lookup
instead of two passes.
CREATE INDEX idx_orders_user_created
ON orders (user_id, created_at DESC);
-- Uses the index efficiently:
SELECT * FROM orders WHERE user_id = 42 ORDER BY created_at DESC LIMIT 20;
-- Can't use it as efficiently — leading column missing:
SELECT * FROM orders WHERE created_at > NOW() - INTERVAL '1 day';The leftmost-prefix rule: an index on (a, b, c) helps queries that filter by a,
a + b, or a + b + c. It does not help a query filtering only by b.
Reading an EXPLAIN
The single most useful database skill is reading the query plan. Three patterns to spot:
- Seq ScanSequential scan
No index used; reads every row. Fine for small tables, alarming on big ones.
- Index ScanIndex used
B-tree jump to matching rows. Usually what you want.
- Bitmap HeapCombined index lookup
Multiple indexes combined; still good.
- Nested LoopJoin algorithm
Fine for small joins; gets slow if the outer side is large.
- Hash JoinBuild + probe
Best when one side is small enough to fit in memory.
- rows=Row estimate
If the estimate is wildly off from reality, statistics need refreshing.
EXPLAIN ANALYZE
SELECT * FROM orders WHERE user_id = 42 ORDER BY created_at DESC LIMIT 10;
-- QUERY PLAN
-- Limit (cost=0.43..15.20 rows=10 width=128) (actual time=0.04..0.07 rows=10 loops=1)
-- -> Index Scan using idx_orders_user_created on orders
-- (cost=0.43..150.00 rows=102 width=128) (actual time=0.04..0.07 rows=10)
-- Index Cond: (user_id = 42)The N+1 problem
The most common ORM trap. You fetch N parents, then issue N more queries to fetch each parent's children.
// 1 query
const users = await db.user.findMany({
include: { orders: true },
});# 1 query — SQLAlchemy eager load
users = (
session.query(User)
.options(selectinload(User.orders))
.all()
)// 1 + N queries — fast at dev scale, dies at 10k rows
const users = await db.user.findMany();
for (const u of users) {
u.orders = await db.order.findMany({ where: { userId: u.id } });
}# 1 + N queries — fast at dev scale, dies at 10k rows
users = session.query(User).all()
for u in users:
u.orders = session.query(Order).filter_by(user_id=u.id).all()Detection: log every SQL statement in dev. If you see the same query shape repeated N times, you have N+1.
When indexes hurt
Indexes are not free:
- Every
INSERT,UPDATE,DELETEupdates every index — write-heavy tables suffer. - They take disk space (often 20–50% of the table size).
- Postgres needs
VACUUMto clean up dead index entries.
Rule of thumb: start with the indexes the queries need, profile, add or drop based on evidence. Don't pre-index every column.
In practice
Steps when a query is slow:
Run EXPLAIN ANALYZE
Read the plan. Are you scanning the whole table?
Find the predicate that selects the smallest set
That's the column (or combination) the index should lead with.
Create or extend the index
Composite indexes are usually the win.
Re-run EXPLAIN ANALYZE
Confirm the plan switched to Index Scan and the actual rows are tiny.
Key takeaways
- An index is a sorted lookup — without one, the DB scans every row.
- Composite indexes obey the leftmost-prefix rule — column order matters.
- Reading EXPLAIN plans is the single most useful DB skill — Seq Scan on big tables is bad.
- N+1 is the most common ORM bug: fetch parents and children in *one* query.
- Indexes cost writes and space — add them based on profiling, not paranoia.
Checkpoint questions
Use these to test whether the lesson is clear enough to explain without rereading.
- 1How does a B-tree index help a database avoid scanning every row?
- 2Why does column order matter in a composite index?
- 3What clues in an EXPLAIN plan suggest a missing or ineffective index?
- 4How would you detect and fix an N+1 query problem?
References
External resources for going deeper after the lesson above.