Scaling Databases & Data Consistency · Finding the Bottleneck
Reading Query Plans
EXPLAIN ANALYZE in practice: sequential scans, bad estimates, and confirming that an index is used.
Scenario
Example app: the same invoicing SaaS. The invoice list page — WHERE account_id = $1 ORDER BY issued_at DESC LIMIT 50 — was 3 ms when the table had 200,000 rows. It now has 48 million rows and takes 380 ms for big accounts.
What you see in production
The endpoint is the #1 query by total time. Someone adds CREATE INDEX ON invoices (issued_at) and nothing improves.
The naive code
SELECT id, number, total, issued_at
FROM invoices
WHERE account_id = $1
ORDER BY issued_at DESC
LIMIT 50;
Invoice.objects.filter(account_id=account_id).order_by("-issued_at")[:50]
Reproduce it
Run SQLite's query plan on the tutorial sample shop. Without a suitable index the database scans the table and sorts with a temporary B-tree; with a composite index it searches and reads rows already in order:
EXPLAIN QUERY PLAN
SELECT order_id, order_date FROM orders WHERE customer_id = 3 ORDER BY order_date DESC LIMIT 5;
CREATE INDEX idx_orders_customer_date ON orders (customer_id, order_date);
EXPLAIN QUERY PLAN
SELECT order_id, order_date FROM orders WHERE customer_id = 3 ORDER BY order_date DESC LIMIT 5;
Output
id | parent | notused | detail 4 | 0 | 0 | SCAN orders 18 | 0 | 0 | USE TEMP B-TREE FOR ORDER BY id | parent | notused | detail 4 | 0 | 0 | SEARCH orders USING COVERING INDEX idx_orders_customer_date (customer_id=?)
Root cause
The planner can only use an index whose leading columns match the filter. An index on issued_at alone doesn't help find one account's rows; the database scans many rows and sorts them. Plans also go wrong when statistics are stale — the planner expects 50 rows and gets 2 million, choosing a nested loop that explodes.
How to read Postgres EXPLAIN (ANALYZE, BUFFERS):
Limit (cost=0.56..152.31 rows=50 width=40) (actual time=0.041..0.212 rows=50 loops=1)
-> Index Scan Backward using invoices_account_issued_idx on invoices
(cost=0.56..1456221.80 rows=479850 width=40) (actual time=0.040..0.205 rows=50 loops=1)
Index Cond: (account_id = 42)
Buffers: shared hit=54
Planning Time: 0.120 ms
Execution Time: 0.239 ms
- Read inside out: the innermost node runs first.
- Compare estimated rows vs actual rows — big gaps mean bad statistics (run
ANALYZE, raise the statistics target). Seq Scanon a large table with a selective filter,Sortwithexternal merge(spilling to disk), andloops=multiplying work are the usual suspects.Buffers: shared readmeans disk reads;shared hitmeans cache.
The fix
Index for the query's shape — equality columns first, then the sort column — and verify with the plan:
CREATE INDEX CONCURRENTLY invoices_account_issued_idx ON invoices (account_id, issued_at DESC);
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, number, total, issued_at FROM invoices
WHERE account_id = 42 ORDER BY issued_at DESC LIMIT 50;
ALTER TABLE invoices ADD INDEX account_issued (account_id, issued_at), ALGORITHM=INPLACE, LOCK=NONE;
EXPLAIN ANALYZE SELECT id, number, total, issued_at FROM invoices
WHERE account_id = 42 ORDER BY issued_at DESC LIMIT 50; -- MySQL 8.0.18+
class Invoice(models.Model):
class Meta:
indexes = [models.Index(fields=["account", "-issued_at"], name="invoices_account_issued_idx")]
# print(Invoice.objects.filter(account_id=42).order_by("-issued_at")[:50].explain(analyze=True))
@Table(name = "invoices", indexes = @Index(name = "invoices_account_issued_idx", columnList = "account_id, issued_at DESC"))
public class Invoice { ... }
Trade-offs
EXPLAIN ANALYZEexecutes the query — wrap data-changing statements in a transaction you roll back.- Plans on a small dev database differ from production; test plans on production-sized data (or a copy).
- Planner hints and forcing indexes are a last resort; fix statistics and indexes first.
Detect it in production
auto_explain(Postgres) logs plans of statements slower than a threshold; MySQL'sEXPLAIN ANALYZEand the slow log'sRows_examinedshow scans.- Watch "rows examined / rows returned" per query — a ratio of 100,000 : 50 means a missing index.
Key points
- Read plans inside out and compare estimated vs actual rows.
- Indexes help only when their leading columns match the filter; equality first, then sort/range.
- Verify every index with the plan on production-sized data.
Practice questions
An index on issued_at alone doesn't speed up `WHERE account_id = ? ORDER BY issued_at DESC LIMIT 50`. Why?
Answer
The filter is on account_id; an index can only narrow rows by its leading columns. The query needs (account_id, issued_at) so it can jump to one account and read its rows already in date order.
In EXPLAIN ANALYZE, the planner estimated 50 rows but the node returned 2 million. What does that indicate?
Answer
The statistics are stale or insufficient, so the planner chose a plan for the wrong data size. Run ANALYZE, raise the statistics target for skewed columns, or add extended statistics.
What's the risk of running EXPLAIN ANALYZE on an UPDATE in production?
Answer
EXPLAIN ANALYZE actually executes the statement, so it modifies data. Run it inside BEGIN … ROLLBACK, or use plain EXPLAIN.
Open in the app to mark lessons complete, take notes and track your progress.