The Quest Begins (The "Why")
I still remember the first time I watched our API crawl to a halt during a Friday‑night release. The dashboard lit up like a Christmas tree, latency spiking from 50 ms to over 2 seconds, and the on‑call pager started singing its unhappy tune. The culprit? A seemingly innocent query that fetched a user’s recent orders:
SELECT *
FROM orders
WHERE user_id = 42
AND created_at >= NOW() - INTERVAL '7 days';
We had a modest orders table — a few million rows — but every request was doing a full table scan. It felt like trying to find a specific lightsaber in a junkyard the size of a planet. I knew we needed something smarter, but I wasn’t sure where to start.
The Revelation (The Insight)
The “aha!” moment came when I sketched out how the database actually looks for rows. Think of a B‑tree index as a sorted phone book: you flip to the right section, then scan a short list instead of flipping through every page. The critical insight? A composite index that matches the exact order of your WHERE‑clause predicates turns a random‑access hunt into a guided tour.
If we create an index on (user_id, created_at), the database can:
- Jump straight to the block of rows for
user_id = 42(thanks to the leading column). - Within that block, walk the already‑sorted
created_atvalues to find the range we need.
No more scanning unrelated users, no more sorting on the fly. The query becomes an index‑only scan in many cases, and the engine can even avoid touching the heap if all needed columns are covered.
But why not just two single‑column indexes? Let’s draw the difference.
Without index (full scan)
+-------------------+-------------------+-------------------+
| user_id = 1 | user_id = 42 | user_id = 999 |
+-------------------+-------------------+-------------------+
| created_at … | created_at … | created_at … |
+-------------------+-------------------+-------------------+
Single‑column index on user_id
+-------------------+-------------------+-------------------+
| user_id = 1 | user_id = 42 | user_id = 999 |
+-------------------+-------------------+-------------------+
| (pointer to rows) | (pointer to rows) | (pointer to rows) |
+-------------------+-------------------+-------------------+
| then scan each | then scan each | then scan each |
| created_at | created_at | created_at |
+-------------------+-------------------+-------------------+
Composite index (user_id, created_at)
+-------------------+-------------------+-------------------+
| user_id = 1 | user_id = 42 | user_id = 999 |
| created_at sorted | created_at sorted | created_at sorted |
+-------------------+-------------------+-------------------+
| direct range fetch| direct range fetch| direct range fetch|
+-------------------+-------------------+-------------------+
With two separate indexes, the planner might pick one (say, on user_id) and then have to filter the created_at condition by reading many rows, or it might do an index merge that adds overhead. The composite index gives the planner a single, precise path — no guesswork, no extra work.
Wielding the Power (Code & Examples)
The Struggle – Before Indexing
-- Slow: scans millions of rows
EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE user_id = 42
AND created_at >= NOW() - INTERVAL '7 days';
Typical output (simplified):
Seq Scan on orders (cost=0.00..84523.45 rows=12 width=212)
Filter: ((user_id = 42) AND (created_at >= now() - '7 days'::interval))
The Victory – After Adding the Composite Index
-- Create the index that matches our query pattern
CREATE INDEX idx_orders_user_created
ON orders (user_id, created_at);
Now the same query:
EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE user_id = 42
AND created_at >= NOW() - INTERVAL '7 days';
Typical output after the index:
Index Scan using idx_orders_user_created on orders (cost=0.42..8.45 rows=12 width=212)
Index Cond: ((user_id = 42) AND (created_at >= now() - '7 days'::interval))
Planner cost drops from tens of thousands to single digits — lightning fast.
Common Traps to Avoid
| Trap | What Happens | How to Fix |
|---|---|---|
Indexing only one column (CREATE INDEX ON orders(user_id);) |
The engine can locate the user block quickly but must still scan all rows for that user to apply the date filter. | Add the second column (created_at) to make the index covering for the query. |
| Over‑indexing (adding indexes on every column) | Write performance tanks; each INSERT/UPDATE/DELETE now touches many index pages, increasing latency and storage. |
Index only the columns that appear together in frequent query predicates; monitor with pg_stat_user_indexes (or equivalent). |
Wrong column order (CREATE INDEX ON orders(created_at, user_id);) |
The leading column (created_at) is not selective for our query, so the index still scans a large date range before filtering by user. |
Put the most selective, equality‑checked column first (user_id), then the range column (created_at). |
A Quick “Covering Index” Bonus
If our API only needs a few fields (say, order_id, total, status), we can make the index covering:
CREATE INDEX idx_orders_user_created_cover
ON orders (user_id, created_at)
INCLUDE (order_id, total, status);
Now the query can be satisfied entirely from the index — no heap look‑up at all. It’s like having the lightsaber’s hilt already in your hand; you never need to reach for the blade.
Why This New Power Matters
With this indexing pattern in place, our rate‑limiter service (which checks a user’s recent request count against a threshold) went from ~200 ms per check to under 2 ms, even as traffic grew tenfold. The system could now handle bursty traffic without throwing HTTP 429s left and right, and our SLA graphs finally looked like a smooth Jedi glide rather than a chaotic lightsaber duel.
But the real win isn’t just speed — it’s predictability. When you know the database will use an index scan, you can reason about capacity, plan for growth, and sleep easier knowing that a sudden spike in users won’t turn your database into a black hole.
Your Turn – A Little Challenge
Grab a table you’ve been ignoring (maybe that event_log you only ever SELECT * FROM with a WHERE on tenant_id and timestamp).
- Write down the exact predicates you use most often.
- Build a composite index matching that order (
tenant_id, timestamp). - Run
EXPLAIN ANALYZEbefore and after, and note the cost difference.
If you’re feeling bold, add an INCLUDE for the columns your app actually reads and watch the planner switch to an Index Only Scan.
What was the biggest surprise you saw when the query plan changed? Drop a comment below — let’s celebrate those “I felt like a superhero” moments together!
May your indexes be ever selective, and your queries ever swift. Happy indexing! 🚀
Top comments (0)