SQL Course L3 Track / Module 05
Indexing ~2h · capstone
SQL · L3 Track · Module 05 · Final

Indexing & Query
Reasoning, making it fast

You can already write correct SQL. This capstone is about the next question that matters: why is it slow, and how would you fix it? We build the mental model of indexes, learn to read a query plan with EXPLAIN ANALYZE, and turn "my query hangs" into a repeatable diagnosis.

~2h · capstone needs Modules 00–04 PostgreSQL 16
0
Section 00 · Framing

From correct to fast

Across the first five modules you learned to produce the right answer: select and filter, join tables, group and aggregate, and slide window functions over ordered rows. On a toy schema with six employees, every one of those queries returns instantly. That is the trap. The same query against millions of rows can take milliseconds or minutes depending on a single decision: whether the database can use an index or has to read the entire table.

This module is the one that makes you sound senior. Anyone can memorize JOIN syntax. The line that separates L3 from L1 is being able to look at a slow query, say "let me check the plan," read an EXPLAIN ANALYZE output, and explain precisely why it is slow and what you would change. Meet Maya, a new analyst at our small company (the same departments, employees, and orders schema you have used all along). Her toy queries always returned instantly, until the orders table grew into tens of millions of rows. That is where every idea below earns its keep, and where we will follow her.

The whole module in one line

An index is a separate, sorted data structure the database keeps alongside a table so it can find rows without reading all of them. Everything else here (when it helps, when it does not, and how to prove it) flows from that single idea.

1
Section 01 · The problem

The full table scan

Maya's first slow query is simple: she wants one employee's orders.

a simple lookup
SELECT * FROM orders WHERE emp_id = 42;

How does the database actually find those rows? Without help, it does the only thing it can: it starts at the first row and reads every single row in the table, testing emp_id = 42 on each one and keeping the matches. This is a sequential scan (Postgres calls it a Seq Scan). If the table has 20 million rows, the database inspects all 20 million to find maybe a dozen matching orders.

The cost of a sequential scan grows linearly with table size: this is O(n) work. For a small table that is completely fine and often the fastest option, since reading a few hundred rows straight off disk beats any cleverness. That is why Maya never noticed it before. But as the row count climbs into the millions, "look at everything" becomes catastrophic, and it gets worse every time the table grows.

Why this is the right starting point

Every performance conversation begins here. An index is only ever interesting because the alternative is scanning the whole table. So the real question is never "should I add an index?" in the abstract. It is "is this query forcing a full scan of a big table, and can I avoid it?"

2
Section 02 · Mental model

The B-tree index

Think of the index at the back of a physical textbook. To find every page that mentions "deadlock," you do not read the whole book. You flip to the alphabetical index, jump straight to D, and it hands you the page numbers. The index is a sorted structure that maps a value to where the real content lives. A database index is exactly this idea, made out of a tree.

The default index type in PostgreSQL is a B-tree: a sorted, balanced tree. Each node holds indexed values in sorted order plus pointers. Internal nodes point to child nodes, and leaf nodes point to the actual table rows. Because it stays balanced, every leaf sits at the same depth, so any lookup touches the same small number of nodes from root to leaf.

Root one node"emp_id < 5000? go left, else right"
Branch a few nodesnarrows the range further, still sorted
Leaf sorted valuesholds emp_id values + pointers to the real rows
Heap the tablefollow the pointer to fetch the full row

The payoff is the depth. A balanced tree finding a value is O(log n), not O(n). For a table of 20 million rows, that is roughly four hops from root to leaf instead of 20 million comparisons. That is the entire reason indexes exist: they convert "read everything" into "navigate a sorted tree." Because the tree is sorted, the same structure also serves range scans (>, BETWEEN) and ordered reads, not just exact matches.

You already have one index

Defining a column as PRIMARY KEY (or UNIQUE) automatically creates a B-tree index on it. That is why looking up orders by order_id is instant even on a huge table: the index was there from the moment you created the table. Plain foreign-key columns like emp_id get no index automatically; you have to add them yourself. This is the gap that bit Maya, since her filter was on emp_id.

3
Section 03 · Syntax

Creating an index

Creating one is a single statement. You name the table and the column(s) to index:

create a B-tree index on a column
-- speed up lookups and joins on emp_id
CREATE INDEX idx_orders_emp ON orders(emp_id);

-- a naming convention helps: idx_<table>_<column(s)>
CREATE INDEX idx_orders_date ON orders(order_date);
CREATE INDEX idx_orders_emp ON orders(emp_id); keyword  ·  index name  ·  keyword  ·  table  ·  column(s) to sort by

Once idx_orders_emp exists, the database can use it for any query whose work matches the sorted shape of the index. A single-column B-tree on emp_id can accelerate all of these:

Equality

WHERE emp_id = 42, walk straight to that value in the tree.

Range

WHERE emp_id BETWEEN 10 AND 99, the tree is sorted, so scan the slice.

Joins

Joining orders to employees on emp_id can look each one up via the index.

ORDER BY

ORDER BY emp_id can read the index in order, skipping a sort step.

Notice the pattern: an index helps when your query needs the data in the order the index stores it, whether to find a value, a range of values, or to return them sorted. That single observation drives everything in the rest of this module.

4
Section 04 · The trade-off

Indexes are not free

If indexes only made queries faster, you would index every column and be done. The reason you do not is the central trade-off of this entire module.

The core trade-off

An index speeds up reads but slows down writes. Every INSERT, UPDATE, and DELETE must also update every index on the affected columns, because the index has to stay in sync with the table. Indexes also consume disk space and memory. So you do not index everything. You index for your actual query patterns.

Walk through it concretely. Say orders has five indexes. Inserting one new order is no longer a single write. The database appends the row to the table and inserts the new key into all five B-trees, each of which may need to rebalance. A write-heavy table drowning in indexes can become slower to write than it ever gained in read speed.

This reframes the question. "Should I add an index?" is really a cost-benefit decision: how often is this column queried (the benefit) versus how often is this table written, and how much disk can I spend (the cost). An index on a column nobody filters by is pure overhead, since it slows every write and helps no read. Unused indexes are a real, common problem, not a hypothetical.

Rule of thumb

Index the columns that appear in WHERE clauses, JOIN conditions, and ORDER BY on your large, frequently-queried tables. Do not reflexively index every column "just in case." Each index is a standing tax on every write.

5
Section 05 · The key skill

EXPLAIN & EXPLAIN ANALYZE

Maya never has to guess whether a query uses an index. She can ask the database to show her its plan. This is the single most practical performance skill in the whole course.

EXPLAIN shows the query plan the planner intends to run, with cost estimates, without executing it. EXPLAIN ANALYZE goes further: it actually runs the query and reports the real row counts and timing alongside the estimates. When you are debugging slowness, you almost always want ANALYZE.

before any index, a sequential scan
EXPLAIN ANALYZE SELECT * FROM orders WHERE emp_id = 42;

Seq Scan on orders  (cost=0.00..358000.00 rows=11 width=64)
                    (actual time=0.41..842.6 rows=12 loops=1)
  Filter: (emp_id = 42)
  Rows Removed by Filter: 19999988
Planning Time: 0.12 ms
Execution Time: 843.1 ms

Maya reads that plan top-down. Seq Scan means it read the whole table. cost=0.00..358000.00 is the planner's estimate (startup cost..total cost, in arbitrary units); rows=11 is its estimate of matches; actual time and rows=12 are what really happened. The damning line is Rows Removed by Filter: 19999988: the database inspected 20 million rows to return 12. So she adds the index and looks again.

after CREATE INDEX idx_orders_emp, an index scan
EXPLAIN ANALYZE SELECT * FROM orders WHERE emp_id = 42;

Index Scan using idx_orders_emp on orders
        (cost=0.43..39.7 rows=11 width=64)
        (actual time=0.03..0.06 rows=12 loops=1)
  Index Cond: (emp_id = 42)
Planning Time: 0.20 ms
Execution Time: 0.09 ms

Same query, same result, but 843 ms became 0.09 ms, roughly ten-thousand-fold. The plan now says Index Scan, the estimated total cost collapsed from 358000 to 39.7, and there is no "Rows Removed by Filter" line because the index went straight to the matching rows. Maya's hang is gone.

The three scan types you will see

Seq Scan

Reads the whole table. Fine on small tables; a red flag on a big one with a selective filter.

Index Scan

Navigates the B-tree to a few rows, then fetches them. What you want for selective lookups.

Bitmap Heap Scan

A middle ground: gathers many matching row locations from the index, then reads the table in disk order.

How to read any plan fast

Scan top to bottom for the word Seq Scan on a large table, then check Rows Removed by Filter and the gap between estimated rows and actual rows. A big estimate/actual mismatch often means stale statistics, so run ANALYZE orders; to refresh them. The expensive node is your target.

6
Section 06 · The silent killer

Sargability

Here is the trap that catches even people who have indexed correctly. A week later Maya adds an index, writes a WHERE on that exact column, and the query still does a Seq Scan. The cause is almost always sargability (a contraction of "Search ARGument ABLE"): whether your condition is written in a form the index can actually use.

The rule is simple and follows directly from the B-tree model. The index stores the raw column value. The moment you wrap the column in a function or arithmetic, the index no longer holds the value you are asking about, so it cannot help, and the planner falls back to a full scan.

Non-sargable: these kill the index

If the indexed column is transformed on the left side of the comparison, the index is useless. WHERE LOWER(name) = 'alice', WHERE salary + 1000 > 50000, and WHERE LIKE '%foo' (leading wildcard) all force a scan, because the index stores name, salary, and the raw string, not their transformed versions.

slow form vs. the rewrite
-- NON-sargable: function on the column → Seq Scan
SELECT * FROM employees WHERE LOWER(name) = 'alice';

-- NON-sargable: arithmetic moves the column out of reach
SELECT * FROM employees WHERE salary + 1000 > 50000;

-- SARGABLE rewrite: move the math to the constant side
SELECT * FROM employees WHERE salary > 49000;

-- SARGABLE fix for LOWER(): build an expression index
CREATE INDEX idx_emp_lname ON employees(LOWER(name));
-- now WHERE LOWER(name) = 'alice' CAN use the index

Two ways out. Rewrite the condition so the raw column stands alone (move the arithmetic to the constant side, as with salary > 49000). Or, when you genuinely need the transformation, build an expression index on the transformed value itself. CREATE INDEX ... ON employees(LOWER(name)) stores the lowercased strings, so the matching query becomes sargable again. Maya picks the rewrite, and the Seq Scan flips back to an Index Scan.

Remember LIKE from Module 00

Back in the foundations we noted that name LIKE 'A%' can use an index but LIKE '%A%' cannot. Now you know exactly why: an anchored pattern 'A%' pins a known prefix, so the B-tree can jump to the right sorted range. A leading wildcard '%A%' gives the tree no starting point (the match could be anywhere in the string), so it must scan every row. Same principle as a transformed column.

7
Section 07 · Multi-column

Composite indexes & column order

An index can cover more than one column, and the order of those columns is not cosmetic. It determines which queries the index can serve. A composite index is sorted by the first column, then by the second within each value of the first, exactly like a phone book sorted by last name, then first name.

a two-column index
CREATE INDEX idx_orders_status_date
  ON orders(status, order_date);

Because it is sorted by status first, this index follows the left-prefix rule: it can serve any query that filters on a leading prefix of its columns, but not on a later column alone.

Query filters onCan use idx(status, order_date)?
status = 'paid'Yes, leading column
status = 'paid' AND order_date > '2024-01-01'Yes, full prefix, the ideal case
order_date > '2024-01-01' onlyNo, skips the leading column
status = 'paid' ORDER BY order_dateYes, filter on first, sort on second

The phone-book intuition makes the "No" obvious: a book sorted by last-name-then-first is useless for finding everyone named "James" regardless of surname, since the Jameses are scattered across every letter. Filtering on order_date alone is the same problem.

Ordering strategy

Put equality columns first, range columns last. The index can only do one range scan at the end, so a leading equality (status = 'paid') narrows the tree to a contiguous slice, and the trailing range (order_date > ...) scans within it. Reverse the order and you lose that. As a tiebreaker, put the more selective column first. This is the index Maya builds for the dashboard's daily revenue report.

8
Section 08 · Index-only scans

Covering indexes

Normally an Index Scan is two steps: navigate the B-tree to find where the rows are, then go to the table (the "heap") to fetch them. But if the index already contains every column the query needs, the second step is pointless, since Postgres can answer entirely from the index. This is an index-only scan, and the index is called a covering index.

covering the query with INCLUDE
-- query only needs emp_id and amount
SELECT amount FROM orders WHERE emp_id = 42;

-- index that covers it: emp_id to search, amount carried along
CREATE INDEX idx_orders_emp_amt
  ON orders(emp_id) INCLUDE (amount);
-- plan now shows: Index Only Scan, no heap fetch

The INCLUDE clause carries extra columns in the index's leaf nodes without making them part of the sort key, so amount rides along just to be returned, without bloating the searchable part of the tree. The plan changes from Index Scan to Index Only Scan, skipping the heap visit entirely. It is a real win for hot, read-heavy queries, but keep it in proportion: every extra column makes the index larger and slower to maintain.

9
Section 09 · Judgement

When not to index, and how to debug

Knowing when an index will not help is as senior a skill as knowing when it will. Three cases where adding one is the wrong move:

Small tables

A few thousand rows fit in memory; a Seq Scan is already instant and often faster than index overhead.

Low selectivity

A status with only 2 values: WHERE status='paid' matches half the table. The planner reads the table anyway.

Write-heavy tables

A table that is mostly inserted/updated and rarely queried pays the write tax for little read benefit.

Selectivity is the idea under all of this. An index pays off when it lets the database skip most of the table. If a filter matches a tiny fraction of rows (high selectivity), an index is a huge win. If it matches a large fraction (low selectivity, like a boolean or a 2-value status), the planner correctly decides scanning is cheaper than bouncing between the index and the table thousands of times, and it will ignore your index even if you build it.

A repeatable workflow for any slow query

When someone hands you a slow query, do not guess. Run this loop:

1 Measurerun EXPLAIN ANALYZE, never optimize blind
2 Locatefind the expensive node: a Seq Scan on a big table or a high-cost join
3 Inspectare the WHERE / JOIN columns indexed, and is the condition sargable?
4 Actadd or fix an index: composite matching filter+sort, or rewrite the predicate
5 Re-measurerun EXPLAIN ANALYZE again and confirm the plan and time actually changed

The first and last steps matter most. People skip straight to "add an index" without measuring, add the wrong one, and never check whether it helped. Measure, change one thing, measure again. That discipline, not memorized syntax, is what turned Maya from someone who filed a ticket into someone who closes them, and it is what makes you trustworthy with a production database.

10
Section 10 · Practice

Hands-on, prove it to yourself

To see a Seq Scan beat into an Index Scan, you need a table big enough to matter. Generate one, then run each task and read the plan before and after every change. Reading about indexes does nothing; watching 843 ms become 0.09 ms in your own terminal is what makes it stick.

seed a big orders table
-- 2 million synthetic orders to make scans hurt
INSERT INTO orders (order_id, emp_id, customer, amount, order_date, status)
SELECT g,
       (random() * 1000)::int,
       'cust_' || g,
       (random() * 500)::numeric(10,2),
       DATE '2020-01-01' + (random() * 1500)::int,
       (ARRAY['paid','pending','cancelled'])[ceil(random()*3)]
FROM generate_series(1, 2000000) AS g;

ANALYZE orders;  -- refresh planner statistics
  • Run EXPLAIN ANALYZE SELECT * FROM orders WHERE emp_id = 42; before any index. Note the scan type, the execution time, and the "Rows Removed by Filter" line.
  • Create idx_orders_emp on emp_id, run the same EXPLAIN ANALYZE again, and compare. Confirm it flipped to an Index Scan and the time dropped sharply.
  • Write a non-sargable query: WHERE emp_id + 0 = 42. Confirm the plan reverts to a Seq Scan even though the index exists. Then rewrite it sargably and watch the index return.
  • Build a composite index on (status, order_date). Show it is used for WHERE status='paid' AND order_date > '2023-01-01'.
  • Now query WHERE order_date > '2023-01-01' alone. Prove the composite index is not used (left-prefix rule); it should Seq Scan or use a different index.
  • Create an expression index on LOWER(customer), then confirm WHERE LOWER(customer) = 'cust_500' uses it.
  • Run EXPLAIN (no ANALYZE) on a query and then EXPLAIN ANALYZE. Spot the difference: estimated vs. actual rows and the presence of real timing.
  • Build a covering index with INCLUDE (amount) and confirm a query selecting only amount shows an Index Only Scan.
Predict the plan first

Before each EXPLAIN ANALYZE, say out loud which scan type you expect and roughly how long it should take. When the plan surprises you, that gap is the lesson, so chase it. This is the exact habit Maya leaned on, and the one you will lean on debugging a real slow query on the job.