SQL for Backend Engineers · Indexes and Query Plans

Indexes and EXPLAIN

Choosing an index for a query - column order, partial, expression and covering indexes - reading a plan, and the rewrites that let a query use an index.

  • 13 min read
  • 14 practice questions
  • 6 graded problems
On this page
  1. Description
  2. What an index does
  3. Reading EXPLAIN
  4. Composite indexes: equality first, then order
  5. Write predicates the index can use
  6. When Postgres ignores the index - on purpose
  7. Partial and covering indexes
  8. Clustered vs non-clustered
  9. Which index for which query?
  10. Pagination: keyset, not OFFSET
  11. Indexes aren't free
  12. Where it breaks
  13. Tradeoffs
  14. Check yourself
  15. Key takeaways
  16. Quick revise
  17. Practice

Description

The order history page was instant at launch and takes three seconds now. The table grew from ten thousand orders to ten million, and the query hasn't changed: it reads every order to find one customer's twenty. An index turns that into a handful of page reads - but only the right index, and only if the query is written so Postgres can use it.

This is the skill behind most "the app got slow" tickets, and one of the most-asked topics in backend interviews. You'll learn to pick an index and then check your choice with EXPLAIN, Postgres's own description of how it will run a query. From simple to more technical:

  1. What an index is, and how to read a plan.
  2. Composite indexes, and why column order matters.
  3. Writing filters an index can actually use.
  4. Why Postgres sometimes ignores an index on purpose.
  5. Partial and covering indexes; clustered versus non-clustered.
  6. Fast pagination with a keyset instead of OFFSET.

The examples use 2,000 customers and 20,000 orders (10 orders per customer; 1% pending, 9% canceled, the rest delivered), analyzed, so every plan below is the one PostgreSQL 18 picked for real.

What an index does

A B-tree index keeps a column's values sorted, each with a pointer to its row. Postgres can jump to a value (=), walk a range (<, BETWEEN, LIKE 'abc%' with a suitable collation), and read rows in index order - which can replace an ORDER BY. Inside, it's a shallow tree of pages: a root page points to internal pages, which point to leaf pages holding the sorted keys - and each leaf entry points to where its row lives in the table:

A B-tree index on customer_id. Finding customer 42 walks from the root to one leaf page, where all of 42's entries sit together; each entry points to a row, and the rows can be on different table pages.

Without one, finding a customer's orders means reading the whole table:

EXPLAIN (COSTS OFF)
SELECT * FROM orders WHERE customer_id = 42;
Seq Scan on orders
  Filter: (customer_id = 42)

After CREATE INDEX orders_customer_idx ON orders (customer_id);:

Bitmap Heap Scan on orders
  Recheck Cond: (customer_id = 42)
  ->  Bitmap Index Scan on orders_customer_idx
        Index Cond: (customer_id = 42)

The plan's scan nodes tell you how each table is read:

nodewhat it doestypical when
Seq Scanreads every row, applying Filterno usable index, or most rows match
Index Scanwalks the index, fetching each matching rowfew rows match, or index order is needed
Index Only Scananswers from the index aloneevery column needed is in the index
Bitmap Index Scan + Bitmap Heap Scancollects matching row locations first, then reads their pages in ordermore than a handful of rows, scattered around the table

Here the customer's 10 orders sit on 10 different pages, so Postgres gathers them in a bitmap and reads each page once.

Reading EXPLAIN

EXPLAIN shows the plan Postgres would use, with estimated costs and row counts (COSTS OFF hides them, as here). EXPLAIN ANALYZE runs the statement and adds the actual rows, loops and time per node - and since Postgres 18 it also reports buffers (pages read from cache or disk) by default. Because it runs the statement, EXPLAIN ANALYZE on an UPDATE or DELETE really changes data: wrap it in BEGIN ... ROLLBACK.

What to look for:

  • The scan on each big table - a Seq Scan with a selective Filter usually means a missing or unusable index.
  • Index Cond vs Filter - an Index Cond narrows what the index reads; a Filter throws rows away after reading them.
  • Sort nodes above a Limit - the index doesn't deliver the order you asked for.
  • Estimated vs actual rows (with ANALYZE) far apart - stale or missing statistics; run ANALYZE.

Composite indexes: equality first, then order

The history page wants one customer's orders, newest first:

SELECT id, created_at, total
FROM orders
WHERE customer_id = 42
ORDER BY created_at DESC
LIMIT 5;

With only the customer_id index, Postgres finds the customer's orders, then sorts them:

Limit
  ->  Sort
        Sort Key: created_at DESC
        ->  Bitmap Heap Scan on orders
              Recheck Cond: (customer_id = 42)
              ->  Bitmap Index Scan on orders_customer_idx
                    Index Cond: (customer_id = 42)

With CREATE INDEX orders_customer_created_idx ON orders (customer_id, created_at);, a customer's orders sit together already sorted by date, so Postgres reads the index backward and stops after five - no sort, however many orders the customer has:

Limit
  ->  Index Scan Backward using orders_customer_created_idx on orders
        Index Cond: (customer_id = 42)

The rule for column order: columns compared with = first, then the one you range over or sort by. An index on (created_at, customer_id) would scatter one customer's orders across the whole index. Traditionally a B-tree was only useful when the query constrained its leading column. Postgres 18 adds skip scan: when the leading column has few distinct values, it can jump through each of them. Here an index on (status, created_at) - three statuses - serves a query that only filters on created_at:

CREATE INDEX orders_status_created_idx ON orders (status, created_at);
EXPLAIN (COSTS OFF)
SELECT id FROM orders
WHERE created_at >= '2026-03-01' AND created_at < '2026-03-02';
Bitmap Heap Scan on orders
  Recheck Cond: ((created_at >= '2026-03-01 00:00:00+00'::timestamp with time zone) AND (created_at < '2026-03-02 00:00:00+00'::timestamp with time zone))
  ->  Bitmap Index Scan on orders_status_created_idx
        Index Cond: ((created_at >= '2026-03-01 00:00:00+00'::timestamp with time zone) AND (created_at < '2026-03-02 00:00:00+00'::timestamp with time zone))

Skip scan is a bonus for few-valued leading columns, not a reason to ignore column order - with thousands of distinct leading values it would mean thousands of jumps.

Write predicates the index can use

An index on a column serves conditions on the column itself. Wrap the column in a function or a cast and the sorted order no longer applies:

CREATE INDEX customers_email_idx ON customers (email);
EXPLAIN (COSTS OFF)
SELECT id FROM customers WHERE lower(email) = 'user42@example.com';
Seq Scan on customers
  Filter: (lower(email) = 'user42@example.com'::text)

Either compare the column directly (store emails lowercased), or index the expression - CREATE INDEX customers_email_lower_idx ON customers (lower(email));:

Bitmap Heap Scan on customers
  Recheck Cond: (lower(email) = 'user42@example.com'::text)
  ->  Bitmap Index Scan on customers_email_lower_idx
        Index Cond: (lower(email) = 'user42@example.com'::text)

The same trap hides in date filters. With an index on created_at:

SELECT id, total FROM orders WHERE created_at::date = '2026-01-15';
Seq Scan on orders
  Filter: ((created_at)::date = '2026-01-15'::date)

Rewrite it as a half-open range on the column, and the index serves it:

SELECT id, total FROM orders
WHERE created_at >= '2026-01-15' AND created_at < '2026-01-16';
Index Scan using orders_created_idx on orders
  Index Cond: ((created_at >= '2026-01-15 00:00:00+00'::timestamp with time zone) AND (created_at < '2026-01-16 00:00:00+00'::timestamp with time zone))

You can't fix this one with an expression index: casting a timestamptz to date depends on the session's time zone, and an index expression must be immutable - CREATE INDEX ON orders ((created_at::date)) fails with functions in index expression must be marked IMMUTABLE. Other predicates an index can't serve: LIKE '%term' (a leading wildcard), comparing a column to a different type (WHERE id::text = '42'), and arithmetic on the column (WHERE total * 1.2 > 100 - move it to the other side).

When Postgres ignores the index - on purpose

With an index on status, the planner reads the whole table for 'delivered' (90% of rows) but uses the index for 'pending' (1%):

Seq Scan on orders
  Filter: (status = 'delivered'::text)
Index Scan using orders_status_idx on orders
  Index Cond: (status = 'pending'::text)

Fetching most rows one by one through an index is slower than reading the table in order, and the planner knows the distribution from the statistics ANALYZE collects. An unused index isn't always a bug - check how many rows match before adding one. On a tiny table, a sequential scan is always cheapest.

Partial and covering indexes

A partial index indexes only the rows a hot query needs. A queue that always asks for pending orders:

CREATE INDEX orders_pending_idx ON orders (created_at)
    WHERE status = 'pending';
EXPLAIN (COSTS OFF)
SELECT id FROM orders
WHERE status = 'pending'
ORDER BY created_at
LIMIT 10;
Limit
  ->  Index Scan using orders_pending_idx on orders

It holds 1% of the rows - less than a tenth of the size of an index on created_at for every order - and stays small as delivered orders pile up. The query must repeat the index's condition (status = 'pending') for Postgres to use it.

A covering index adds columns with INCLUDE, so a query can be answered from the index without visiting the table:

CREATE INDEX orders_customer_total_idx ON orders (customer_id) INCLUDE (total);
EXPLAIN (COSTS OFF)
SELECT customer_id, total FROM orders WHERE customer_id = 42;
Index Only Scan using orders_customer_total_idx on orders
  Index Cond: (customer_id = 42)

An index-only scan still checks the table's visibility map; on a table with many recent changes, VACUUM is what keeps it from falling back to the table.

Clustered vs non-clustered

Interviews ask about this constantly, and the answer depends on the database:

  • Non-clustered index: a separate sorted structure whose entries point to rows stored somewhere else. Following a pointer means one more page read.
  • Clustered index: the table's rows are stored inside the index, in its order. There can be only one per table, since rows can only be stored in one order.
In PostgreSQL every index points into a heap whose rows are in no particular order. In MySQL InnoDB (and SQL Server tables with a clustered index) the primary-key B-tree holds the rows themselves, and a secondary index stores primary-key values, so using it means a second lookup.
PostgreSQLMySQL InnoDBSQL Server
Where rows liveA heap, in insertion orderInside the primary-key B-tree (always clustered)In the clustered index if the table has one, otherwise a heap
A secondary index points toThe row's physical locationThe row's primary-key valueThe clustering key, or the row's location for a heap
"Clustered" optionCLUSTER table USING index sorts the table once; later rows aren't kept in orderAutomatic, on the primary keyCREATE CLUSTERED INDEX, maintained on every write

What it means in practice: in InnoDB, a range of primary keys is cheap (the rows are next to each other) and the primary key's size matters (every secondary index stores it). In PostgreSQL, every index is non-clustered, so a range scan that returns many rows can touch one table page per row - which is why the planner switches to a bitmap or a sequential scan. The deep dive measures exactly how much physical order matters.

Which index for which query?

The queryIndex
WHERE a = ?(a)
WHERE a = ? ORDER BY b (or AND b > ?)(a, b) - equality first, then range or sort
WHERE lower(email) = ?Expression index (lower(email))
WHERE status = 'pending' on a tiny fraction of rowsPartial index ... WHERE status = 'pending'
SELECT a, b WHERE a = ?, read very oftenCovering index (a) INCLUDE (b)
Keyset pagination(filter columns, sort column, unique id)
A condition most rows matchNone - the planner will scan anyway
jsonb containment, arrays, full-text searchGIN (see the deep dive and the JSON module)
Huge append-only time seriesBRIN on the timestamp

Pagination: keyset, not OFFSET

LIMIT 20 OFFSET 100000 makes Postgres produce and throw away 100,000 rows to show 20, and pages shift when rows are added. Keyset pagination remembers the last row shown and asks for the rows after it. With a unique tiebreaker (id) and a row comparison, one index serves every page:

CREATE INDEX orders_customer_created_id_idx ON orders (customer_id, created_at, id);
EXPLAIN (COSTS OFF)
SELECT id, created_at
FROM orders
WHERE customer_id = 42
  AND (created_at, id) < ('2026-02-01', 9000)
ORDER BY created_at DESC, id DESC
LIMIT 5;
Limit
  ->  Index Only Scan Backward using orders_customer_created_id_idx on orders
        Index Cond: ((customer_id = 42) AND (ROW(created_at, id) < ROW('2026-02-01 00:00:00+00'::timestamp with time zone, 9000)))

(created_at, id) < (x, y) compares pairs in order - earlier created_at, or the same one with a smaller id - so orders that share a timestamp are neither skipped nor repeated, and the whole comparison is an Index Cond. The cost: no jumping to page 500, only next and previous.

Indexes aren't free

Every index is updated on every insert, and on every update of an indexed column; it takes disk and memory; and building one locks writes unless you use CREATE INDEX CONCURRENTLY (slower, can't run inside a transaction). Index what your queries filter, join and sort by - including foreign-key columns - and drop indexes that nothing uses: pg_stat_user_indexes.idx_scan counts how often each has been used since statistics were last reset.

Where it breaks

  • Wrong column order in a composite index - equality columns first, then range or sort.
  • A function or cast on the indexed column (lower(email), created_at::date) - rewrite the predicate or index the expression.
  • Expecting an index for a condition most rows match - the planner is right to scan.
  • OFFSET for deep pages - use keyset pagination with a unique tiebreaker.
  • EXPLAIN ANALYZE on a write - it runs; wrap it in a rolled-back transaction.
  • Indexing every column "just in case" - each slows writes; drop the unused ones.

Tradeoffs

  • One composite index vs several single-column ones: a composite built for the query beats combining separate indexes; separate ones serve more varied queries less well.
  • Partial vs full index: a partial index is small and fast but only serves queries that repeat its condition.
  • Covering (INCLUDE) vs lean: index-only scans save table reads at the cost of a bigger index to maintain.
  • Keyset vs OFFSET: keyset is fast at any depth and stable under inserts; OFFSET allows jumping to page N.

Going further. The deep dive (next tab) measures how physical row order changes the cost of the same index scan - 21 page reads versus 1,008 - shows what CLUSTER does and undoes, compares PostgreSQL with InnoDB and SQL Server clustering in depth, and covers the planner's cost model, index-only scans and the visibility map, B-tree deduplication, the other index types, and the real cost of every index.

Check yourself

  1. The page query filters customer_id = ? and sorts by created_at DESC LIMIT 20. Which index avoids the sort, and why not (created_at, customer_id)?
Show answer

(customer_id, created_at): one customer's orders are together and already in date order, so Postgres reads the last 20 backward. With created_at first, that customer's orders are scattered across the whole index.

  1. There's an index on created_at, yet WHERE created_at::date = '2026-01-15' scans the whole table. Why, and what's the fix?
Show answer

The index is sorted by created_at, not by created_at::date, so it can't serve a condition on the cast. Rewrite as created_at >= '2026-01-15' AND created_at < '2026-01-16'.

  1. Why does keyset pagination compare (created_at, id) rather than just created_at?
Show answer

Several rows can share a created_at. Comparing only the timestamp skips rows tied with the last one shown (with <) or repeats them (with <=); the unique id makes the position exact.

Key takeaways

  • A B-tree index serves =, ranges and ORDER BY on its columns; EXPLAIN shows whether a query uses it, and EXPLAIN ANALYZE runs it and shows what really happened.
  • Composite indexes: equality columns first, then the range or sort column. Postgres 18's skip scan helps when the leading column has few values.
  • Keep indexed columns bare in predicates - no functions or casts - or index the expression (it must be immutable).
  • The planner skips indexes for conditions most rows match; that's usually right.
  • Partial indexes for hot subsets, INCLUDE for index-only scans, keyset pagination instead of deep OFFSETs - and every index has a write cost.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

Which index for which query?

The queryIndex
WHERE a = ?(a)
WHERE a = ? ORDER BY b (or AND b > ?)(a, b) - equality first, then range or sort
WHERE lower(email) = ?Expression index (lower(email))
WHERE status = 'pending' on a tiny fraction of rowsPartial index ... WHERE status = 'pending'
SELECT a, b WHERE a = ?, very hotCovering index (a) INCLUDE (b)
Keyset pagination(filter columns, sort column, unique id)
A condition most rows matchNone - the planner will scan anyway
jsonb @>, arrays, full-textGIN
Ranges and overlaps, exclusion constraintsGiST
Huge append-only time seriesBRIN

Reading a plan

NodeMeans
Seq Scan + FilterRead every row; a selective filter here = a missing or unusable index
Index ScanWalk the index, fetch each row - few rows, or order needed
Index Only ScanAnswered from the index (plus the visibility map)
Bitmap Index Scan + Bitmap Heap ScanCollect locations, read each page once - many scattered rows
Index Cond vs FilterNarrows what the index reads vs discards rows after reading
Sort above LimitThe index doesn't deliver the order you asked for

EXPLAIN = the plan; EXPLAIN ANALYZE runs it (actual rows, time, buffers by default in 18) - wrap writes in BEGIN ... ROLLBACK.

Snippets worth knowing by heart

CREATE INDEX orders_customer_created_idx ON orders (customer_id, created_at);
CREATE INDEX customers_email_lower_idx ON customers (lower(email));
CREATE INDEX orders_pending_idx ON orders (created_at) WHERE status = 'pending';
CREATE INDEX orders_customer_total_idx ON orders (customer_id) INCLUDE (total);
CREATE INDEX CONCURRENTLY ...;                     -- no write lock; not inside a transaction

-- keyset pagination
WHERE customer_id = 42 AND (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC LIMIT 20;

-- a day, index-friendly
WHERE created_at >= '2026-01-15' AND created_at < '2026-01-16'

-- unused indexes
SELECT indexrelname, idx_scan FROM pg_stat_user_indexes ORDER BY idx_scan;

The traps

  • Wrong column order - (created_at, customer_id) scatters one customer's rows across the index.
  • A function or cast on the column (lower(email), created_at::date, id::text = '42', total * 1.2 > 100) hides it from the index.
  • LIKE '%term' - a leading wildcard can't use a B-tree.
  • Expecting an index for 90% of the rows - the planner is right to scan.
  • A partial index the query doesn't repeat the condition of - it isn't used.
  • OFFSET 100000 reads and discards 100,000 rows; pages shift under inserts.
  • Indexing everything - every index slows every insert.

Staff-level points, one line each

  • Cost is pages, not rows: two scans of ~1,000 rows read 21 pages (rows inserted together) vs 1,008 (rows scattered). pg_stats.correlation (1.00 vs 0.00) feeds the planner.
  • CLUSTER sorts the table once by one index (made one range 53× cheaper, another 48× dearer), takes an ACCESS EXCLUSIVE lock, and decays as rows change; pg_repack re-clusters online.
  • InnoDB tables are the primary-key B-tree; secondary indexes store the PK value, so a lookup walks two trees (unless covering), and a wide or random PK bloats every index and splits the table's own pages.
  • SQL Server: one optional clustered index; plans show the second lookup as Key Lookup / RID Lookup. Oracle: heap tables or index-organized tables.
  • PostgreSQL: heaps only, every index points to a location - simple secondary lookups and MVCC-friendly, but non-HOT updates touch every index.
  • random_page_cost defaults to 4 (spinning disks); on SSDs ~1.1 makes the planner use indexes more.
  • Index-only scans skip the heap only for pages the visibility map (kept by VACUUM) marks all-visible - watch Heap Fetches.
  • B-tree deduplication (13+): a 3-value status index is 1,912 kB vs 8,216 kB without it. Fan-out in the hundreds = 3-4 levels for tens of millions of keys.
  • PostgreSQL 18 skip scan lets (status, created_at) serve a filter on created_at alone when status has few values.
  • CREATE INDEX CONCURRENTLY scans twice, waits for running transactions, and leaves an INVALID index on failure. Check idx_scan on replicas before dropping "unused" indexes.

Say it in an interview

  • "(customer_id, created_at) lets Postgres read one customer's newest orders backward and stop at the limit - no sort."
  • "The cast on created_at hides the column from the index; I'll write a half-open range."
  • "InnoDB clusters on the primary key, so a secondary-index lookup is two tree walks unless the index covers the query."
  • "It reads the same rows but 48 times the pages - the rows aren't physically together."

Practice

This skill comes with 14 multiple-choice questions that tell you which misconception a wrong answer reveals and 6 graded problems. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice Indexes and EXPLAIN free

Index the Order History Page

MediumProSQL problem

The order history page runs this query for the signed-in customer, and it's getting slow:

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Make the Daily Report Use Its Index

MediumProSQL problem

Finance's daily report reads the view daily_orders: the orders placed on the day stored in report_settings (a UTC day - the database runs in UTC).

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Foreign Keys Without Indexes

MediumProSQL problem

A customer in Austin opens My orders and waits. Here's the query behind that page:

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

The Support Dashboard Reads Every Order

MediumProSQL problem

The support team in Denver keeps a dashboard open all day. It shows the oldest pending orders since March 1, twenty at a time, so nobody waits too long for help:

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Keyset Pagination for the Activity Feed

HardProSQL problem

The activity feed shows an account's events newest first - by created_at, then by id, both descending - five at a time. It used OFFSET, which got slower with every page. Switch it to keyset pagination.

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Practice Indexes and EXPLAIN free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime