Database Indexing and Query Optimization for Python Developers
Introduction Fixing N+1 queries with select_related / prefetch_related or selectinload (see the previous post ) gets you down to a small, sane number of queries per request. The next bottleneck is what each query costs once the table has millions of rows — and that is almost always about indexing. An index turns "scan every row" into "look it up directly." Skip it, and a query that's instant in development takes seconds once real data volume shows up in production. How Indexes Work: The B-Tree Intuition Without an index, a WHERE clause forces a sequential scan : the database reads every row and checks the condition — O(n) , cost grows linearly with table size. An index is a separate, sorted structure (almost always a B-tree ) mapping column values to row locations. Because it's sorted and balanced, finding a value is a tree walk: O(log n) . On a 10-million-row table, that's the difference between reading 10 million rows and roughly 23 tree nodes. This isn't free: Writes get slower — every INSERT / UPDATE / DELETE on an indexed column also updates the index. Storage grows — each index is a sorted copy of (part of) the data. An index trades write cost and storage for read speed. Indexing a column you rarely filter or sort on is pure cost, no benefit. Reading Query Plans: EXPLAIN ANALYZE Postgres' EXPLAIN ANALYZE shows what the planner actually did, not an estimate. Before an index , filtering orders by customer_id : EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 48291 ; Seq Scan on orders (cost=0.00..21453.00 rows=42 width=96) (actual time=0.021..118.442 rows=41 loops=1) Filter: (customer_id = 48291) Rows Removed by Filter: 1199959 Planning Time: 0.112 ms Execution Time: 118.471 ms Seq Scan means Postgres read all ~1.2 million rows and discarded all but 41. actual time is real elapsed time — 118ms for one lookup. After CREATE INDEX idx_orders_customer_id ON orders (customer_id); : Index Scan using idx_orders_customer_id on orders (cost=0.42..8.53 rows=42 wid