Indexes and Query Plans
7 pages · ~5 min✓ Reviewed
Builds on Window Functions.
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 BYon the key: O(log n + k) for the k rows read
- Rootentry point
- Internal nodesroute by key
- Leaf pageslinked in key order
- Range scan finds the start once, then walks the linked leaves
2. Seek vs scan
| Index seek | Full table scan | |
|---|---|---|
| Does | Jumps to a key | Reads every row |
| Reads | Few | All |
| Wins when | Small fraction of rows | Most rows needed |
WHERE customer_id = 42seeks 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,UPDATEandDELETE - Low-selectivity columns (gender, boolean) rarely help
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 on | Serves it? |
|---|---|
a | yes |
a, b | yes |
a, b, c | yes |
b alone | no |
c alone | no |
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 ScanorUsing 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.
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
| Skipped | Fix |
|---|---|
YEAR(created_at) = 2025 | range on bare column |
LOWER(email) = 'a@b.com' | functional index |
LIKE '%abc' | LIKE 'abc%' or full-text |
phone = 5551234 | phone = '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
ORacross different columns<>andNOT IN- Leftmost prefix violated:
(a,b)filtered onbalone
Check
Keep the bare column on one side, then confirm with EXPLAIN: Index Scan, not Seq Scan.
EXPLAIN and Sargable Queries
1. Reading a Plan
EXPLAIN: plan only, query not runEXPLAIN ANALYZE(Postgres, MySQL 8): runs it, adds actual time and rows
| Check | Reads as |
|---|---|
| Access type | Seq Scan / type=ALL = full scan; Index Scan / Seek / ref / range = index |
| Chosen key | index picked |
| Estimated rows | compare with actual |
| Join order | which table drives |
2. Red Flags
- Seq Scan on a large table
- Estimated rows far from actual: stale stats
Sort/Using filesorta 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'
| Slow | Sargable |
|---|---|
amount + 10 > 100 | amount > 90 |
LIKE '%x' | prefix LIKE 'x%' or full-text index |
varchar_col = 5 | varchar_col = '5' |
Covering
SELECT * blocks a covering index; name only the columns you need.
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
| Constraint | Rule | Index |
|---|---|---|
| PRIMARY KEY | NOT NULL + UNIQUE, one per table | clustered or unique index |
| UNIQUE | NULLs allowed (count varies) | creates an index |
| FOREIGN KEY | ON DELETE CASCADE / SET NULL / RESTRICT | not auto in Postgres, SQL Server |
| CHECK, NOT NULL | guard values | none |
| DEFAULT | fills omitted columns | none |
- Index FK columns yourself: faster joins and parent deletes
3. DELETE vs TRUNCATE
| DELETE | TRUNCATE | |
|---|---|---|
| Rows | one by one, WHERE ok | all rows, no WHERE |
| Cost | logged | fast |
| Rollback | yes | Postgres, SQL Server yes; MySQL, Oracle no |
| Identity | kept | reset in MySQL, SQL Server; Postgres needs RESTART IDENTITY |
No WHERE
UPDATE or DELETE without WHERE hits every row. Check with SELECT first.
Indexing and Query Tuning Cheat Sheet
1. Index Design Rule
- Equality colsseek
- ORDER BY colsno sort
- 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
ORacross columns- Stale stats: run ANALYZE
3. Tuning Loop
- EXPLAIN ANALYZE
- Spot flawsSeq Scan, filesort, bad estimates
- Fixindex or predicate
- Re-run, compare
- Covering check: Index Only Scan or 'Using index' = no table lookup
4. Constraints
| Item | Rule |
|---|---|
| PK | NOT NULL + UNIQUE |
| FK | Postgres: index the child column yourself |
| DELETE | Logged, filtered by WHERE, rollback-able |
| TRUNCATE | Fast, unfiltered; rollback in Postgres and SQL Server, not MySQL or Oracle |
| Identity | TRUNCATE resets in MySQL and SQL Server; Postgres needs RESTART IDENTITY |
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.
cityis skipped, soageis 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 INDEXonorders(customer_id).