Handbooks / SQL / Chapter 5

Indexes and Query Plans

7 pages · ~5 min✓ Reviewed

Builds on Window Functions.

SQLPage 1

B-tree Index Mental Model

1. Tree and leaves

  • Sorted, balanced tree of key values pointing to rows
  • Point lookup: O(log n)
  • Range scan or ORDER BY on the key: O(log n + k) for the k rows read
  1. Rootentry point
  2. Internal nodesroute by key
  3. Leaf pageslinked in key order
  • Range scan finds the start once, then walks the linked leaves

2. Seek vs scan

Index seekFull table scan
DoesJumps to a keyReads every row
ReadsFewAll
Wins whenSmall fraction of rowsMost rows needed
  • WHERE customer_id = 42 seeks the tree once the column is indexed
sql
CREATE INDEX idx_orders_cust ON orders(customer_id);
SELECT * FROM orders WHERE customer_id = 42;

seeks the tree instead of scanning the table

3. Common mistakes

  • Indexing every column: each index costs disk space
  • Every index slows INSERT, UPDATE and DELETE
  • Low-selectivity columns (gender, boolean) rarely help
SQLPage 2

Composite and Covering Indexes

1. Order and the Leftmost Prefix

  • Index on (a, b, c) is sorted by a, then b within a, then c within b
  • Usable from the left only: the leftmost prefix
Filter onServes it?
ayes
a, byes
a, b, cyes
b aloneno
c aloneno

2. Column Order

  • Equality columns first, range column last
  • Break ties by higher selectivity
  • Columns after a range condition cannot narrow the seek
sql
CREATE INDEX idx_orders_cust_status_date
ON orders(customer_id, status, created_at);

two equalities, then the range column

3. Covering Indexes

  • Covering: every column the query needs is in the index
  • Plan shows Index Only Scan or Using index: no table lookup
  • Extra columns: INCLUDE (Postgres, SQL Server) or trailing key columns (MySQL)
sql
CREATE INDEX idx_cov ON orders(customer_id) INCLUDE (total);
CREATE INDEX idx_cov ON orders(customer_id, total);

first: Postgres, SQL Server; second: MySQL

Redundant index

Creating both (a) and (a, b) is a mistake: the composite already serves queries on a, so (a) is redundant.

SQLPage 3

When Indexes Are Not Used

1. Predicate hides the column

  • Function on the column hides the sorted key
  • Leading %: LIKE 'abc%' is a prefix range; '%abc' is not
  • Type mismatch: VARCHAR vs number casts every row
SkippedFix
YEAR(created_at) = 2025range on bare column
LOWER(email) = 'a@b.com'functional index
LIKE '%abc'LIKE 'abc%' or full-text
phone = 5551234phone = '5551234'
sql
WHERE created_at >= '2025-01-01'
  AND created_at <  '2026-01-01'
CREATE INDEX idx_email_lower
  ON users (LOWER(email));

rewrite the predicate, or index the expression

2. Scan is cheaper

  • Query returns ~10-20%+ of rows: full scan wins
  • Random lookups cost more than one sequential read
  • Tiny table: a scan is already cheap
  • Stale stats: run ANALYZE / UPDATE STATISTICS

3. More skips

  • OR across different columns
  • <> and NOT IN
  • Leftmost prefix violated: (a,b) filtered on b alone
Check

Keep the bare column on one side, then confirm with EXPLAIN: Index Scan, not Seq Scan.

SQLPage 4

EXPLAIN and Sargable Queries

1. Reading a Plan

  • EXPLAIN: plan only, query not run
  • EXPLAIN ANALYZE (Postgres, MySQL 8): runs it, adds actual time and rows
CheckReads as
Access typeSeq Scan / type=ALL = full scan; Index Scan / Seek / ref / range = index
Chosen keyindex picked
Estimated rowscompare with actual
Join orderwhich table drives

2. Red Flags

  • Seq Scan on a large table
  • Estimated rows far from actual: stale stats
  • Sort / Using filesort a better index could remove

3. Sargable Predicates

  • Sargable = can seek an index: keep the bare column on one side
sql
-- slow: function on column
WHERE YEAR(created_at) = 2025
-- seeks the index
WHERE created_at >= '2025-01-01'
  AND created_at < '2026-01-01'
SlowSargable
amount + 10 > 100amount > 90
LIKE '%x'prefix LIKE 'x%' or full-text index
varchar_col = 5varchar_col = '5'
Covering

SELECT * blocks a covering index; name only the columns you need.

SQLPage 5

DML and Constraints

1. DML and Index Cost

  • INSERT INTO t(cols) VALUES (...)
  • UPDATE t SET c = v WHERE ...
  • DELETE FROM t WHERE ...
  • Run UPDATE and DELETE in a transaction with a WHERE
  • INSERT and DELETE touch all indexes; UPDATE touches those with a changed column
  • Write-heavy table → keep few indexes

2. Constraints

ConstraintRuleIndex
PRIMARY KEYNOT NULL + UNIQUE, one per tableclustered or unique index
UNIQUENULLs allowed (count varies)creates an index
FOREIGN KEYON DELETE CASCADE / SET NULL / RESTRICTnot auto in Postgres, SQL Server
CHECK, NOT NULLguard valuesnone
DEFAULTfills omitted columnsnone
  • Index FK columns yourself: faster joins and parent deletes

3. DELETE vs TRUNCATE

DELETETRUNCATE
Rowsone by one, WHERE okall rows, no WHERE
Costloggedfast
RollbackyesPostgres, SQL Server yes; MySQL, Oracle no
Identitykeptreset in MySQL, SQL Server; Postgres needs RESTART IDENTITY
No WHERE

UPDATE or DELETE without WHERE hits every row. Check with SELECT first.

SQLPage 6

Indexing and Query Tuning Cheat Sheet

1. Index Design Rule

  1. Equality colsseek
  2. ORDER BY colsno sort
  3. Range collast
  • Leftmost prefix: (a,b,c) serves a, a+b, a+b+c, not b alone
  • ORDER BY after a range column cannot remove the sort

2. Why an Index Is Skipped

  • Function on column: YEAR(d) = 2025
  • Leading %: LIKE '%x'
  • Type mismatch: VARCHAR vs number
  • Low selectivity
  • OR across columns
  • Stale stats: run ANALYZE

3. Tuning Loop

  1. EXPLAIN ANALYZE
  2. Spot flawsSeq Scan, filesort, bad estimates
  3. Fixindex or predicate
  4. Re-run, compare
  • Covering check: Index Only Scan or 'Using index' = no table lookup

4. Constraints

ItemRule
PKNOT NULL + UNIQUE
FKPostgres: index the child column yourself
DELETELogged, filtered by WHERE, rollback-able
TRUNCATEFast, unfiltered; rollback in Postgres and SQL Server, not MySQL or Oracle
IdentityTRUNCATE resets in MySQL and SQL Server; Postgres needs RESTART IDENTITY
SQLPage 7

Check yourself

An index on (country, city, age) and the query WHERE country = 'IN' AND age = 30. How much of the index narrows the seek?
  • Only country, the leftmost prefix.
  • city is skipped, so age is only checked on the entries read.
phone is an indexed VARCHAR. Spot the bug: WHERE phone = 5551234.
  • MySQL, SQL Server: the column is cast on every row, so the index is skipped.
  • PostgreSQL: it fails with operator does not exist: character varying = integer.
  • Fix: compare with a string, '5551234'.
In Postgres, orders.customer_id is a foreign key to customers. Deleting a customer is slow. Why?
  • Postgres does not auto-index the child FK column, so each delete scans orders.
  • Fix: CREATE INDEX on orders(customer_id).