SQL
Intermediate 12 min read

Indexes & query performance

In one line

An index is a sorted lookup structure (usually a B-tree) that lets the database jump straight to matching rows in O(log n) instead of scanning the whole table, at the cost of extra storage and slower writes.

What it is

Without an index, finding WHERE email = 'a@x.com' means reading every row: a sequential (full table) scan. An index keeps a copy of one or more columns in sorted order with a pointer to each row, so the database can binary-search-like descend a tree to the match in a few page reads.

The default index type in PostgreSQL and in MySQL's InnoDB engine is the B-tree (strictly a B+ tree): a shallow, wide tree where each node is a disk page holding hundreds of keys, so even 100 million rows need only about 3 to 4 levels. B-trees support equality, ranges (<, >, BETWEEN), prefix LIKE 'abc%' (in PostgreSQL this needs the C collation or a text_pattern_ops index) and ORDER BY on the indexed columns.

A composite index on (a, b) is sorted by a, then by b within each a, like a phone book sorted by last name then first name. A covering index contains every column a query needs, so the table itself is never read (an index-only scan). In InnoDB the table is stored inside the primary key B-tree (a clustered index), and secondary indexes store the primary key; in PostgreSQL tables are heaps and every index points to a row location.

Why interviewers ask

Performance questions come up in almost every backend and data interview: 'this query is slow, what do you do?'. Interviewers want to hear that you read the plan with EXPLAIN, know how B-trees work, design composite indexes in the right column order, and understand why an index is sometimes not used and what indexes cost on writes.

How it works

  1. 1The planner estimates the cost of each possible plan from table statistics (row counts, value distribution) and picks the cheapest: a sequential scan, an index scan, a bitmap index scan (PostgreSQL, for many matches) or an index-only scan.
  2. 2An index lookup starts at the root page and follows pointers down to a leaf page. With a fan-out of a few hundred keys per page, each level multiplies the reachable rows by a few hundred, so depth grows as O(log n).
  3. 3Leaf pages are linked in key order, so a range condition finds the first match and then walks forward along the leaves until the range ends.
  4. 4For each match, a plain index scan then fetches the row from the table (a random read). If a large fraction of the table matches, a sequential scan is cheaper, so the planner may ignore the index. That is selectivity at work.
  5. 5A composite index (a, b, c) can be used for conditions on a; a and b; or a, b and c (the leftmost prefix). Put equality-filtered columns first and the range or sort column last: for WHERE a = 1 AND b > 10, (a, b) is ideal, while (b, a) is weaker.
  6. 6Every INSERT, UPDATE of an indexed column and DELETE must also update each index, so more indexes mean slower writes and more disk and memory use.

Worked example · Using an index on (customer_id, created_at)

  1. orders(id, customer_id, created_at, total) has 10 million rows; customer 42 has 200 of them. Query: SELECT id, total FROM orders WHERE customer_id = 42 AND created_at >= '2026-01-01' ORDER BY created_at.
  2. No index: a sequential scan reads all 10 million rows, keeps the matches, then sorts them. EXPLAIN ANALYZE shows Seq Scan with 'Rows Removed by Filter: 9999xxx' and maybe hundreds of milliseconds or more.
  3. Create the index: CREATE INDEX idx_orders_cust_created ON orders (customer_id, created_at). Picture its leaf entries in order: (41, 2026-03-02), (42, 2025-11-10), (42, 2026-01-05), (42, 2026-02-14), (42, 2026-04-01), (43, 2025-12-30).
  4. The lookup descends the tree to the first entry with customer_id = 42 and created_at >= 2026-01-01, which is (42, 2026-01-05), skipping (42, 2025-11-10).
  5. It walks forward: (42, 2026-02-14), (42, 2026-04-01), then (43, ...) ends the range. The rows come out already ordered by created_at, so no sort step is needed.
  6. A query on created_at alone cannot seek this index, because created_at is only sorted within each customer_id. It would need its own index starting with created_at.
  7. Adding total to the index (CREATE INDEX ... (customer_id, created_at) INCLUDE (total, id) in PostgreSQL 11+) makes it covering, allowing an index-only scan that never touches the table.

Picture it

An index is the index at the back of a textbook. To find 'photosynthesis' you look it up alphabetically and jump to page 212 instead of reading the whole book. It costs a few extra pages, and every time the author adds content the index has to be updated too. A composite index is a phone book: great for 'Smith, John', useless for 'everyone named John'.

Reference code

-- orders(id, customer_id, created_at, total)

-- Composite index: equality column first, range/sort column second
CREATE INDEX idx_orders_cust_created
  ON orders (customer_id, created_at);

-- PostgreSQL: build without blocking writes, and make it covering
-- CREATE INDEX CONCURRENTLY idx_orders_cust_created_cov
--   ON orders (customer_id, created_at) INCLUDE (total);

-- Check the plan (actually runs the query)
EXPLAIN ANALYZE
SELECT id, total
FROM orders
WHERE customer_id = 42
  AND created_at >= DATE '2026-01-01'
ORDER BY created_at;

-- Index-friendly rewrite of a function on a column
-- Slow:  WHERE EXTRACT(YEAR FROM created_at) = 2026
SELECT COUNT(*) FROM orders
WHERE created_at >= DATE '2026-01-01'
  AND created_at <  DATE '2027-01-01';

The index lets the example query seek directly to customer 42's 2026 orders and return them already sorted. EXPLAIN ANALYZE should show an Index Scan (or Bitmap Index Scan) instead of a Seq Scan. The last query shows the range rewrite that lets an index on created_at be used.

Key patterns

Composite index column order

When: Queries filter on several columns and sort

How: Equality columns first, then the range or ORDER BY column. Order matters because of the leftmost-prefix rule.

Covering index

When: A hot query reads only a few columns

How: Include all needed columns in the index (PostgreSQL INCLUDE, or extra key columns in MySQL) so the table is not read. In PostgreSQL index-only scans also need the visibility map to be up to date (VACUUM).

Expression (functional) index

When: WHERE LOWER(email) = ... or WHERE date(created_at) = ...

How: Index the expression itself: CREATE INDEX ON users (LOWER(email)). MySQL supports functional key parts from 8.0.13. Better still, rewrite date filters as ranges on the raw column.

Partial index

When: Queries only ever target a small subset, like unprocessed jobs

How: PostgreSQL: CREATE INDEX ON jobs (created_at) WHERE status = 'pending'. Small, fast to maintain. MySQL has no partial indexes.

Read the plan

When: Any slow query

How: EXPLAIN ANALYZE (PostgreSQL, and MySQL 8.0.18+). Look for sequential scans on large tables, big gaps between estimated and actual rows, sorts that spill to disk, and nested loops over many rows.

Real-world use

Login and user lookup

Every web app has a unique index on users.email so login is a single B-tree lookup; the UNIQUE constraint is itself implemented as a unique index in PostgreSQL and MySQL.

Instagram-style feeds and timelines

Queries like 'latest 20 posts by this user' rely on a composite index (user_id, created_at DESC), which returns rows already in the right order with no sort.

Full-text and JSON search in PostgreSQL

GIN indexes power full-text search (tsvector) and fast containment queries on jsonb and arrays; apps such as GitLab use PostgreSQL GIN and trigram indexes for searching issues and code metadata.

Time-series and logs

BRIN indexes in PostgreSQL store only the min and max per block range, which is tiny and works well on huge append-only tables where rows arrive in timestamp order.

In a project

The 'recent orders' page of an online store has grown slow (p95 of 1.8 seconds) as the orders table reached 30 million rows. You are asked to fix it without new hardware.

  1. Capture the slow query from pg_stat_statements (or MySQL's slow query log) and run EXPLAIN (ANALYZE, BUFFERS) on it with real parameters.
  2. The plan shows a sequential scan filtering on store_id and status, followed by a sort on created_at. The query is WHERE store_id = $1 AND status = 'open' ORDER BY created_at DESC LIMIT 50.
  3. Create a composite index on (store_id, status, created_at) with CREATE INDEX CONCURRENTLY so the table is not locked for writes during the build (MySQL InnoDB builds most secondary indexes online by default).
  4. Re-run EXPLAIN: the plan is now an index scan that reads the first 50 matching entries in order and stops, with no sort.
  5. Drop two old unused indexes found via pg_stat_user_indexes (idx_scan = 0) to win back write speed.

Impact: p95 latency for the page fell from 1.8 seconds to about 15 ms, database CPU dropped noticeably at peak, and order insert time did not regress because unused indexes were removed at the same time.

Trade-offs

Use it when

  • Columns used in selective WHERE conditions, JOIN keys and foreign keys
  • Columns used for ORDER BY ... LIMIT on large tables
  • Uniqueness must be enforced (unique index or constraint)
  • Read-heavy tables where the same queries run thousands of times

Avoid it when

  • Small tables where a scan of a few pages is already fast
  • Low-selectivity columns alone (a boolean where half the rows are true): the planner will scan anyway
  • Write-heavy tables where every extra index slows inserts and updates
  • Duplicate or overlapping indexes: an index on (a) is redundant if (a, b) exists

Cost: B-tree lookup O(log n) page reads (3 to 4 levels for hundreds of millions of rows); range scan O(log n + k) for k matches; full scan O(n). Each index adds O(log n) work to every insert and delete and to updates of its columns.

Common mistakes

  • Indexing every column 'just in case'. Each index slows writes, uses memory and disk, and composite queries still need the right composite index.
  • Wrapping the indexed column in a function or cast (LOWER(email), DATE(created_at), col + 1 = 5), which prevents a plain index from being used.
  • Getting composite index order backwards, or expecting an index on (a, b) to help a query that only filters on b.
  • Expecting LIKE '%term%' to use a B-tree index. A leading wildcard cannot seek; use full-text search or a trigram (pg_trgm) GIN index.
  • Optimising by guesswork instead of reading EXPLAIN ANALYZE, and forgetting that stale statistics (run ANALYZE) can make the planner choose badly.

Interview questions

Quick revision

  • No index: full scan O(n). B-tree: O(log n), about 3 to 4 levels even for huge tables.
  • B-tree handles =, <, >, BETWEEN, prefix LIKE 'abc%' and ORDER BY.
  • Composite (a, b): works for a, or a and b; not b alone. Equality first, range last.
  • Covering index = index-only scan, no table reads (PostgreSQL INCLUDE).
  • Functions on columns defeat plain indexes: use expression indexes or range rewrites.
  • Indexes cost write speed, disk and memory: drop unused ones.
  • EXPLAIN shows the plan; EXPLAIN ANALYZE runs it and shows real timings.
  • InnoDB: table clustered by primary key. PostgreSQL: heap table + separate indexes.

Check yourself

1. With an index on (a, b, c), which WHERE clause can use it to seek efficiently?

2. users.email has a normal B-tree index. Why may WHERE LOWER(email) = 'a@x.com' not use it?

3. What is the main cost of adding many indexes to a table?

4. What is a covering index?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free