You will take a deliberately slow analytics query against a large orders dataset and optimise it methodically: measure the baseline plan, identify the dominant cost, design targeted indexes, and confirm each change with EXPLAIN ANALYZE. By the end you will have driven a multi-second sequential scan down to a fast index-driven plan, with evidence at every step.
The dataset is a million-row orders table joined to customers, filtered by date and status and sorted by total — a realistic dashboard query. Rather than guessing at indexes, you will let the query plan dictate exactly which index to build, practising the measure-fix-verify loop that defines real optimisation work.
Analogy🏏Cricket
🏏 Think of it like cricket: Just as setting up a new league means first writing the registration rules and eligibility checks, then entering the teams and players, then running the fixtures, you will first define the constrained schema, then load data, then query it. The insight is that the rules come before the play — get the eligibility and scoring regulations right first, and the matches that follow stay orderly because the framework already enforces fair play. Run the sequence backwards to see why the order is non-negotiable: admit teams first and write the eligibility rules afterwards, and you discover mid-season that three registered players were never eligible — now every result they touched is in dispute and unwinding it means re-examining a season of records. That is retrofitting constraints onto a table already full of violating rows: the ALTER fails until you hand-clean the data it was supposed to prevent. Founding the league in the right order also changes how boldly you can operate later: fixtures can be scheduled aggressively because the framework guarantees every listed player is legal, just as queries and application code can stay simple when the schema has already promised the data is sound.
🏏 Showing the Cricket analogy — a Cricket version isn’t available for this concept yet.
Prerequisites
Completion of lessons 11–14, or equivalent familiarity with index types, EXPLAIN ANALYZE, and partition pruning.
A running PostgreSQL 15+ instance and a client such as psql.
Permission to create tables and indexes and to run ANALYZE.
Roughly 200 MB of free space for the generated sample data.
Comfort reading an EXPLAIN ANALYZE plan and distinguishing Seq Scan, Index Scan, and Bitmap Heap Scan.
Setup & Project Structure
Create a customers table and a large orders table, then generate sample data with generate_series so the orders table holds about a million rows with realistic skew on status and a spread of dates. Run ANALYZE afterwards so the planner has accurate statistics to work from.
Analogy🏏Cricket
🏏 Think of it like cricket: before a tournament you set up a dedicated ground and a clean scoring system rather than borrowing a crowded club pitch. Just as you create a fresh database so your exercise work is isolated, a groundsman prepares a separate net facility so practice never disturbs the main square. Just as you will build four related tables — team, player, match, and appearance — a league secretary maintains four linked registers: the clubs, the registered players, the fixtures, and the record of who actually took the field in each fixture. Just as an appearance ties a specific player to a specific match, that turnout register is the join that connects players to the games they played. And just as keeping everything in one schema keeps the practice tidy, running one competition under one governing body keeps all records consistent. The payoff: a clean, isolated setup lets you apply type and constraint choices deliberately and see exactly how they behave.
🏏 Showing the Cricket analogy — a Cricket version isn’t available for this concept yet.
Keep everything in a scratch schema you can drop afterwards. The goal is a table big enough that a sequential scan is visibly slow, so the impact of each index is unmistakable in the timings.
Run the target dashboard query under EXPLAIN (ANALYZE, BUFFERS) before adding any indexes. With no supporting index, expect a Seq Scan on orders, a sort for the ORDER BY, and a total time of hundreds of milliseconds to seconds. Record the execution time and the plan shape as your baseline.
Read the plan bottom-up and name the dominant cost: almost certainly the sequential scan of a million rows feeding a sort. This is your evidence — the plan, not intuition, tells you the orders table scan and the sort are what you must eliminate.
Analogy🏏Cricket
🏏 Think of it like cricket: Just as you clock a bowler's exact economy rate before changing anything so any improvement is provable, you record the query's exact time and plan before touching it. The insight is that a documented baseline turns a vague 'it got faster' into 'it went from 1,400 ms to 12 ms' — a fact you can stand behind rather than a feeling. The baseline must capture the mechanics, not just the stopwatch: save the full EXPLAIN ANALYZE output — node shapes, row estimates, buffer counts — because 'it went from 1,400 ms to 12 ms' is the headline, while 'the Seq Scan over 8 million rows became a 40-row index scan' is the explanation that survives scrutiny. Beware the practice-net illusion: the second time you clock the same bowler, the measurement itself has warmed him up — caches make every repeat run look better — so time the query more than once and compare like with like, warm against warm. The saved baseline pays off twice more: it is the before in the review you show others, and it is the reference that catches the regression when next month the plan quietly flips back.
🏏 Showing the Cricket analogy — a Cricket version isn’t available for this concept yet.
The query filters on status = 'pending' (a small, skewed slice) and a recent placed_at range, then sorts by total. Build a partial index restricted to pending orders, keyed to support the date filter, so the planner scans a tiny structure instead of the whole table. Re-run the EXPLAIN ANALYZE and compare.
Because 'pending' is a small fraction of rows, a partial index on WHERE status = 'pending' is far smaller than a full index and is exactly aligned with the query. Watch the plan switch from Seq Scan to an index or bitmap scan and the execution time drop sharply.
Analogy🏏Cricket
🏏 Think of it like cricket: Just as a shortlist of only the in-form batters is faster to consult than the entire squad register when form is all you ever check, a partial index on just the pending orders is faster than indexing all million rows. The insight is that indexing precisely the slice you query — and nothing else — gives the planner a sharp, tiny tool perfectly fitted to the question. The economics run deeper than scan speed: the shortlist is not merely smaller to read — it is cheaper to keep, since the thousands of completed orders churning through the system never touch it at all, while a full-register index pays a write for every change to any row. It fits in the coach's pocket, too: a tiny index stays resident in memory where the million-row version competes for cache. The binding contract is exact wording: the shortlist covers 'in-form batters', so a question about 'batters returning from injury' sends you back to the full register — the query's WHERE must provably imply the index's WHERE, or the planner ignores the sharp tool entirely, and the practice exercise is proving in EXPLAIN that it actually got picked.
🏏 Showing the Cricket analogy — a Cricket version isn’t available for this concept yet.
With the orders filter fast, examine what remains: the join to customers and the ORDER BY total. The join on customers.id already uses the primary key, so confirm it is a fast Index Scan or Nested Loop over the few filtered rows. If the sort still appears as a cost, consider an index that supplies total order within the pending slice.
Add total to the partial index ordering so the LIMIT can stop early without a separate sort, and verify the Sort node disappears from the plan. This is the final squeeze: turning a filter-then-sort into a single ordered index scan that returns the top 20 directly.
Analogy🏏Cricket
🏏 Think of it like cricket: Just as a batting order already arranged by current form lets you pick the top three without re-ranking everyone each time, an index that already stores the rows in total order lets the query take the top 20 without a sort. The insight is that pre-arranging the data in the order you need eliminates the sorting work entirely — the ranking was done once, at index-build time, not on every query. The mechanics of the saved work: a top-N query over unordered data must inspect every row to know the top 20 — a full sort, or at best a heap of candidates — while an index matching the ORDER BY lets execution walk the index from one end and stop dead after 20 rows, a Limit over an Index Scan with no Sort node at all, the pre-ranked batting order read top-down. The fine print is exactness: the index must match the ORDER BY's columns and directions — a form ranking sorted ascending serves 'worst three' instantly but 'best three' only by walking backwards, which B-Trees can do, though mixed directions across columns cannot be served without matching the index definition. The proof, as always, is the plan: the Sort node visibly gone.
🏏 Showing the Cricket analogy — a Cricket version isn’t available for this concept yet.
Confirm the full story end to end: the baseline plan showed a Seq Scan and a Sort costing hundreds of milliseconds or more, and the optimised plan shows an index scan with no separate sort, completing in a handful of milliseconds. Re-run each EXPLAIN ANALYZE and check that the dominant node changed and the execution time fell by one to two orders of magnitude.
Analogy🏏Cricket
🏏 Think of it like cricket: before a match counts, you check the ground, the kit, and the Laws all behave as intended. Just as you inspect the table definitions with \d, the ground authority reviews the official team sheets and pitch report to confirm everything is set up as designed. Just as you verify that constraint-violating inserts fail, an umpire confirms that an illegal action — a bowler exceeding their over limit, an unregistered player trying to bat — is correctly rejected rather than slipping through. Just as you check that queries return correct, deterministically ordered results, the scorers confirm the batting order and totals come out the same every time, not shuffled at random. And just as you run the constraint and referential checks to prove the database enforces what you intended, the officials run through the rulebook to prove the game will hold to its Laws. The payoff: verifying the enforcement, not just the happy path, is what proves the schema is genuinely doing its job.
🏏 Showing the Cricket analogy — a Cricket version isn’t available for this concept yet.
Warning: Always run ANALYZE after creating an index or loading data before judging a plan. A new index the planner has no fresh statistics for may be ignored, leading you to wrongly conclude the index 'didn't help'. The index choice depends on statistics, so stale stats can hide a perfectly good optimisation behind a misleading sequential scan.
Extension Challenge: Range-partition the orders table by month (Lesson 14), recreate the partial index on each partition, and re-run the 30-day query. Compare the plan: with pruning, only the most recent partitions are scanned, and combined with the partial index you get both partition elimination and a tiny per-partition index — observe how the two techniques compound.
Capture a baseline plan with EXPLAIN (ANALYZE, BUFFERS) before changing anything.
Let the plan identify the dominant cost (here, a Seq Scan feeding a Sort) and target that node.
A partial index on a small, skewed predicate (status='pending') is tiny and precisely matches the query.
Ordering the index by the sort column lets a top-N LIMIT skip the separate Sort node entirely.
Run ANALYZE after building indexes so the planner has fresh statistics to choose them.
Verify every change by re-running EXPLAIN ANALYZE and confirming the plan and timing improved.