Module P-2·17 min read

The B-tree index for practitioners, EXPLAIN basics, composite and partial indexes, and the write cost trade-off.

Written by Jatin Jain Saraf · Architect of SupraScan

P-2 — Indexes: When and How to Add Them

Who this module is for: You have built schemas and written queries. Now a query that was fast with 100 rows is slow with 100,000. This module explains what indexes actually do (without diving into the B-tree internals — that is Phase 3), how to tell if you need one, how to create and use them correctly, and the cost that most tutorials never mention: every index makes writes slower.


What an Index Actually Does

When you run SELECT * FROM products WHERE price = 49.99, PostgreSQL has two options:

Sequential scan — read every single row from the table and check whether price = 49.99. If you have 1,000,000 rows, it reads all 1,000,000.

Index scan — jump directly to the rows where price = 49.99 using a pre-built lookup structure. If 3 rows match, it reads roughly 3 rows plus the index overhead.

An index is a separate data structure maintained by PostgreSQL that maps column values to the physical locations of rows. Think of it like a book's index: instead of reading every page to find "PostgreSQL", you look it up in the index and go directly to the pages listed.

The core tradeoff: an index makes SELECT faster but makes INSERT, UPDATE, and DELETE slower — because every write must update both the table and the index. Adding an index to every column is not a good strategy.


Reading EXPLAIN — Your Diagnostic Tool

Before adding an index, measure. EXPLAIN shows the query execution plan PostgreSQL would use.

-- Show the plan without executing the query
EXPLAIN SELECT * FROM tasks WHERE status = 'todo';
 
-- Show the plan AND execute, with actual timing
EXPLAIN ANALYZE SELECT * FROM tasks WHERE status = 'todo';
 
-- Full diagnostic output
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM tasks WHERE status = 'todo';

Reading the output:

Seq Scan on tasks  (cost=0.00..35.50 rows=234 width=128)
                             (actual time=0.012..0.487 rows=234 loops=1)
  Filter: (status = 'todo')
  Rows Removed by Filter: 316
Planning Time: 0.142 ms
Execution Time: 0.532 ms
FieldMeaning
Seq ScanSequential scan — reading every row
cost=0.00..35.50Estimated cost (startup..total, arbitrary units)
rows=234Estimated row count
actual time=0.012..0.487Real time in milliseconds (start..end)
Rows Removed by Filter: 316How many rows were read but discarded
Planning TimeTime to generate the plan
Execution TimeTotal actual execution time

After adding an index:

Index Scan using idx_tasks_status on tasks
  (cost=0.29..8.31 rows=234 width=128)
  (actual time=0.018..0.089 rows=234 loops=1)
  Index Cond: (status = 'todo')
Planning Time: 0.234 ms
Execution Time: 0.112 ms

The Seq Scan became an Index Scan. Execution time dropped from 0.532ms to 0.112ms — about 5× faster on a small table. On a million-row table, the difference would be far more dramatic.


Creating Indexes

Basic Index

-- Create an index on a single column
CREATE INDEX idx_tasks_status ON tasks (status);
 
-- Index name convention: idx_{table}_{column(s)}
-- Descriptive names help you understand what each index is for

CREATE INDEX CONCURRENTLY — No Table Lock

Regular CREATE INDEX locks the table from writes for the duration of the build. On a large production table, this can take minutes and block your application.

-- Build the index without locking the table (safe for production)
CREATE INDEX CONCURRENTLY idx_tasks_status ON tasks (status);
-- Takes longer to build, but reads and writes continue normally
-- Use this for any table with live traffic

Unique Index

-- Enforces uniqueness AND provides fast lookups
CREATE UNIQUE INDEX idx_users_email ON users (email);
-- Equivalent to: UNIQUE constraint (which creates an index automatically)

Dropping an Index

DROP INDEX idx_tasks_status;
DROP INDEX CONCURRENTLY idx_tasks_status;  -- without locking writes

Composite Indexes — Column Order Matters

A composite index on (a, b) can be used for queries filtering on a alone, or a AND b together. It cannot efficiently answer queries on b alone.

CREATE INDEX idx_tasks_project_status ON tasks (project_id, status);
 
-- ✅ Uses the index (leading column: project_id)
SELECT * FROM tasks WHERE project_id = 1;
 
-- ✅ Uses the index (both columns)
SELECT * FROM tasks WHERE project_id = 1 AND status = 'todo';
 
-- ❌ Does NOT use the index efficiently (missing leading column)
SELECT * FROM tasks WHERE status = 'todo';
-- PostgreSQL will do a sequential scan or a separate index if one exists

Rule: put the equality filter column first, the range filter column second.

-- For a query like: WHERE status = 'todo' AND price > 50
-- Correct composite index:
CREATE INDEX idx_products_status_price ON products (status, price);
-- status = 'todo' narrows to a small set, then price > 50 filters within it

Partial Indexes — Index Only the Rows You Query

A partial index only includes rows matching a WHERE condition. It is smaller, faster to build, and uses less memory than a full index.

-- Only index open tasks (the ones you query most often)
CREATE INDEX idx_tasks_open ON tasks (project_id, created_at)
WHERE status IN ('todo', 'in_progress');
-- Completed and cancelled tasks are excluded — they are rarely queried
 
-- Only index active users
CREATE INDEX idx_users_active_email ON users (email)
WHERE deleted_at IS NULL;

For a table where 95% of rows are in terminal states (done, archived), a partial index on the active 5% is dramatically smaller and faster.


Expression Indexes — Index a Computed Value

-- Case-insensitive email lookups
CREATE INDEX idx_users_email_lower ON users (LOWER(email));
 
-- The query must use the same expression to use this index:
SELECT * FROM users WHERE LOWER(email) = LOWER('Alice@Example.com');
-- ✅ Uses idx_users_email_lower
 
SELECT * FROM users WHERE email = 'alice@example.com';
-- ❌ Does NOT use it (different expression)

Foreign Key Indexes — The Most Forgotten Optimization

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.