The B-tree index for practitioners, EXPLAIN basics, composite and partial indexes, and the write cost trade-off.
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.
Reading the output:
| Field | Meaning |
|---|---|
Seq Scan | Sequential scan — reading every row |
cost=0.00..35.50 | Estimated cost (startup..total, arbitrary units) |
rows=234 | Estimated row count |
actual time=0.012..0.487 | Real time in milliseconds (start..end) |
Rows Removed by Filter: 316 | How many rows were read but discarded |
Planning Time | Time to generate the plan |
Execution Time | Total actual execution time |
After adding an index:
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 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.
Unique Index
Dropping an Index
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.
Rule: put the equality filter column first, the range filter column second.
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.
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
Foreign Key Indexes — The Most Forgotten Optimization
PostgreSQL does not automatically create indexes on foreign key columns. This surprises many engineers. When you delete a row from the parent table, PostgreSQL must scan the child table to check for references — without an index, this is a full sequential scan.
Rule: every foreign key column should have an index. Without it, joins and deletions that reference that column are slow.
The Write Cost of Indexes
Every index you add slows down every INSERT, UPDATE, and DELETE on that table.
For a table that has 100,000 reads per second but only 10 writes per second, many indexes are fine. For a table that has 50,000 writes per second (like an event log or metrics table), every index is expensive.
This is why you should not add an index "just in case." Measure first, add indexes for proven slow queries.
Common Index Mistakes
Mistake 1: Indexing a low-cardinality column
Mistake 2: Over-indexing a write-heavy table
Mistake 3: Indexing a column used in a function
Mistake 4: Not running ANALYZE after bulk loads
Monitoring Index Usage
Practical Exercise: Indexing the Task Manager
Starting with the task manager schema from F-7 with 50,000 rows seeded:
Summary
| Concept | Key Takeaway |
|---|---|
| Sequential scan | Reads every row — fine for small tables or when most rows match |
| Index scan | Jumps directly to matching rows — fast for selective queries |
EXPLAIN ANALYZE | Always measure before and after adding an index |
CREATE INDEX CONCURRENTLY | Build index without blocking writes — use in production |
| Composite index | Column order matters: equality columns first, range columns last |
| Partial index | Only indexes rows matching a condition — smaller and faster |
| Expression index | Index a computed value (LOWER(email)) |
| Foreign key indexes | Not automatic — add manually for every FK column |
| Write cost | Every index slows down INSERT/UPDATE/DELETE |
| Low cardinality | Low-cardinality columns (status with 4 values) rarely benefit from full indexes |
| Index monitoring | pg_stat_user_indexes.idx_scan = 0 means the index is never used — drop it |
Module P-3 covers transactions and ACID in practice — what BEGIN, COMMIT, ROLLBACK actually do, isolation levels, and writing safe atomic operations for scenarios like bank transfers and inventory deduction.
Next: P-3 — Transactions and ACID in Practice →
A software engineer observes a critical production query, SELECT id, name FROM orders WHERE customer_id = 123 AND status = 'pending' ORDER BY created_at DESC;, performing a Seq Scan on a 10 million row orders table. EXPLAIN ANALYZE shows actual time in the hundreds of milliseconds. The status column has low cardinality (e.g., 'pending', 'shipped', 'cancelled', 'returned'), but 'pending' orders represent only 5% of the total rows. Which index strategy is most appropriate to optimize this query for production, minimizing write impact and maximizing read performance?
A high-traffic microservice manages user activity logs in a user_events table, experiencing 50,000 INSERT operations per second. Initially, the table had no indexes. To support analytical queries, a junior engineer proposes adding five new indexes on various columns (user_id, event_type, timestamp, session_id, ip_address) using CREATE INDEX. As a Senior Principal Software Engineer, what is the most critical concern and the recommended immediate action?
A database administrator reports that DELETE FROM projects WHERE id = 1; queries are taking an unusually long time, sometimes several seconds, even for projects with few associated tasks. The projects table is small, but the tasks table has millions of rows. The tasks table has a foreign key constraint tasks_project_fk referencing projects(id) on its project_id column. Upon running EXPLAIN ANALYZE DELETE FROM projects WHERE id = 1;, the DBA observes a Seq Scan on the tasks table. What is the most likely root cause of this performance issue and the recommended solution?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
Sign in & RegisterDiscussion
0Join the discussion