advanced22 min

Indexes, Composite Indexes, and Reading EXPLAIN

How an index turns an O(n) table scan into an O(log n) lookup, when adding one is actually the wrong call, and how to read EXPLAIN to find out instead of guessing.

What you'll learn

  • Explain what an index is and why it speeds up lookups on the indexed column
  • Determine correct column order for a composite index given a query's WHERE/ORDER BY pattern
  • Read a basic EXPLAIN output and identify whether a query used a Seq Scan or an Index Scan

Prerequisites

Explanation

This lesson's DDL and EXPLAIN output are real PostgreSQL, shown for reading — SQLite's query planner and EXPLAIN output format differ enough from PostgreSQL's that reading real PostgreSQL output here is what this module's guided local lab has you verify directly.

Without an index, finding rows matching a condition (WHERE course_id = 5) requires PostgreSQL to check every single row in the table — a sequential scan, O(n) in the table's size, using this course's earlier terminology exactly. An index on course_id is a separate, ordered structure (by default, a B-tree — conceptually similar to the balanced search trees from the earlier DSA-adjacent reasoning this course assumes, though implemented very differently) that lets PostgreSQL locate matching rows in roughly O(log n), the same fundamental speedup a sorted structure provides over an unsorted scan. CREATE INDEX idx_enrollment_course_id ON enrollment(course_id); builds exactly this structure.

Indexes are not free — this is the honest half of the story too often left out. Every index must be updated on every INSERT, UPDATE, or DELETE touching the indexed column, which means indexes trade faster reads for slower writes, plus real, ongoing storage space. A table that's written to constantly but rarely queried by a given column is a genuinely poor candidate for indexing that column — the write-side cost is paid on every single write, whether or not the read-side benefit is ever actually used. This is precisely why "index every column, just in case" is bad advice: it's a real cost paid unconditionally, for a benefit that only exists if that column is actually queried often enough to matter.

A composite index covers multiple columns together, and column order matters — an index on (learner_id, course_id) efficiently serves a query filtering on learner_id alone, or on both learner_id AND course_id together, but does not efficiently serve a query filtering on course_id alone, because the index's ordering is by learner_id first — exactly the way a phone book sorted by last-name-then-first-name doesn't help you find everyone with a given first name. The general rule: put the column used in equality filters (WHERE learner_id = ...) before columns used in range filters or sorting, and match the column order to your most common, performance-critical query pattern.

EXPLAIN shows the query planner's chosen execution plan without actually running the query; EXPLAIN ANALYZE actually runs it and reports real timing alongside the plan. The single most important thing to look for in a plan's output is whether a query used a Seq Scan (every row checked — the O(n) case) or an Index Scan (the index was used — the O(log n) case) on the table and column in question. Genuinely worth stating plainly, since it's a common overclaim: an index does not automatically guarantee better performance for every query — for a very small table, or a query expected to match a large fraction of the table's rows, PostgreSQL's planner can correctly choose a sequential scan over an available index, because scanning sequentially is sometimes actually faster than the overhead of consulting the index structure for many matches; EXPLAIN is how you find out what the planner actually decided, rather than assuming.

Example

Real PostgreSQL EXPLAIN output, shown for reading -- format and planner behavior differ from SQLite's, which is why this lesson doesn't use the browser SQL runner.

-- Before an index on enrollment(course_id):
EXPLAIN SELECT * FROM enrollment WHERE course_id = 5;

--                          QUERY PLAN
-- ------------------------------------------------------------
--  Seq Scan on enrollment  (cost=0.00..25.88 rows=6 width=24)
--    Filter: (course_id = 5)
-- (every row in the table is checked against the filter)

CREATE INDEX idx_enrollment_course_id ON enrollment(course_id);

-- After the index:
EXPLAIN SELECT * FROM enrollment WHERE course_id = 5;

--                                    QUERY PLAN
-- ---------------------------------------------------------------------------
--  Index Scan using idx_enrollment_course_id on enrollment
--    (cost=0.15..8.32 rows=6 width=24)
--    Index Cond: (course_id = 5)
-- (the index is consulted directly instead of scanning every row)

Guided exercise

Guided exercise

Write parseScanType(explainOutput) that returns 'seq-scan' if the string contains 'Seq Scan', 'index-scan' if it contains 'Index Scan', or 'unknown' otherwise.

Checks: correctly identifies a Seq Scan · correctly identifies an Index Scan · returns unknown for an unrecognized plan type

Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.

Loading editor…

Stuck? Get a hint.

Independent exercise

Independent exercise

Write bestCompositeIndexOrder(equalityColumns, rangeOrSortColumns) implementing the column-order rule: equality-filter columns come first (in the order given), followed by range/sort columns (in the order given). Return a single array combining both, in the correct order. Then write indexServesQuery(indexColumns, queryFilterColumns) returning true only if queryFilterColumns is a PREFIX of indexColumns (matching the phone-book analogy: an index can serve a query filtering on its leading columns, not an arbitrary subset).

Checks: orders equality columns before range/sort columns · an index correctly serves a query on its leading column · an index correctly does NOT efficiently serve a query on only its trailing column · an index correctly serves a query matching its full column prefix

Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.

Loading editor…

Stuck? Get a hint.

Common mistakes

  • Adding an index to every column 'just in case' -- every index has a real, ongoing write-time and storage cost; an index that's rarely used for reads is a cost paid for no real benefit.
  • Creating a composite index in the wrong column order for the actual query pattern -- an index on (course_id, learner_id) does not efficiently serve a query filtering only on learner_id, exactly the mirror image of the phone-book analogy.
  • Assuming an index guarantees a faster query without checking EXPLAIN -- for a small table or a query matching a large fraction of rows, PostgreSQL's planner can correctly choose a sequential scan over an available index, because the index's overhead isn't always worth paying.

Knowledge check

Knowledge check

1. Why isn't 'add an index to every column, just to be safe' good general advice?
2. A composite index exists on (learner_id, course_id). Does it efficiently serve a query filtering ONLY on course_id?
3. What does it mean if EXPLAIN shows 'Seq Scan' for a query filtering on an indexed column?

Takeaway

An index turns an O(n) scan into a roughly O(log n) lookup on the indexed column, but costs real write-time overhead and storage — add one deliberately, order composite index columns to match your actual query pattern (equality columns first), and use EXPLAIN to confirm whether an index is actually being used rather than assuming.

Summary

Indexes speed up reads on the indexed column at the cost of slower writes and storage. Composite index column order matters — a query's filter columns must form a prefix of the index's columns to use it efficiently. EXPLAIN shows the planner's chosen strategy (Seq Scan vs. Index Scan) without guessing; EXPLAIN ANALYZE additionally reports real timing.

References

Your notes

Notes save automatically.

Finished this lesson?

Mark it complete to track your progress and schedule a future review.