Without an index, finding a row means checking every row — a full table scan. An index is a separate, ordered data structure that lets the database jump straight to the rows that match, at the cost of extra storage and slower writes to keep it up to date.
The Problem: Linear Search Doesn’t Scale
Imagine a users table with ten million rows and a query like WHERE email = 'a@b.com'. Without an index, the database has no choice but to read every row and check the email column on each one — an O(n) operation that gets slower in direct proportion to table size. Double the rows, double the scan time.
-- Without an index: sequential scan across the full table
EXPLAIN ANALYZE
SELECT * FROM users WHERE email = 'a@b.com';
-- Seq Scan on users (cost=0.00..185000.00 rows=1 width=120)
-- Filter: (email = 'a@b.com'::text)
-- Rows Removed by Filter: 9999999
An index turns that into an O(log n) lookup, because the underlying structure is organized so the database can eliminate most of the table with each comparison, the same way you’d find a word in a dictionary by repeatedly halving the range instead of reading every page.
B-Trees: The Default Structure
Most relational databases index with a B-tree (technically a B+tree) by default. It keeps keys sorted and balanced, so every lookup takes roughly the same number of steps regardless of where the value falls, and range queries (WHERE created_at > '2026-01-01') are efficient because the leaves are linked in sorted order.
CREATE INDEX idx_users_email ON users (email);
EXPLAIN ANALYZE
SELECT * FROM users WHERE email = 'a@b.com';
-- Index Scan using idx_users_email on users
-- (cost=0.42..8.44 rows=1 width=120)
-- Index Cond: (email = 'a@b.com'::text)
The cost estimate dropping from 185,000 to roughly 8 is the entire point: the planner switched from reading the whole table to walking a tree with a handful of levels.
Composite Indexes and Column Order
An index on multiple columns is only useful as a prefix match — an index on (last_name, first_name) speeds up queries filtering on last_name alone or on both columns together, but does nothing for a query filtering on first_name alone. Column order matters and should follow your actual query patterns, most selective or most commonly filtered column first, unless you have a specific range-query reason to do otherwise.
CREATE INDEX idx_orders_customer_status
ON orders (customer_id, status);
-- Uses the index efficiently
SELECT * FROM orders WHERE customer_id = 42 AND status = 'pending';
SELECT * FROM orders WHERE customer_id = 42;
-- Cannot use this index at all
SELECT * FROM orders WHERE status = 'pending';
Covering Indexes
A covering index includes every column a query needs, so the database can answer entirely from the index without touching the underlying table row at all — this is usually called an “index-only scan.”
CREATE INDEX idx_orders_covering
ON orders (customer_id, status) INCLUDE (total_amount);
SELECT status, total_amount
FROM orders
WHERE customer_id = 42;
-- Index Only Scan, no heap fetch needed
Index Types at a Glance
| Index type | Best for | Weak at |
|---|---|---|
| B-tree | Equality and range queries, sorting | Full-text search |
| Hash | Pure equality lookups | Range queries, ordering |
| GIN | Full-text search, arrays, JSONB containment | Range queries |
| BRIN | Huge tables with naturally sorted data (timestamps) | Random access patterns |
The Cost: Every Write Pays For It
Indexes aren’t free. Every INSERT, UPDATE, or DELETE has to update every index on the affected columns, not just write the row. A table with six indexes pays that cost six times on every write, plus the storage each index consumes on disk.
Reading an Execution Plan
The fastest way to know whether an index is actually being used is to ask the query planner, not to guess. EXPLAIN ANALYZE shows the real plan and real timings, and the two signals worth scanning for first are Seq Scan (probably missing an index) versus Index Scan or Index Only Scan (index is doing its job), and the estimated row count versus actual rows, which flags stale statistics when they diverge wildly.
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders
WHERE customer_id = 42 AND status = 'pending'
ORDER BY created_at DESC
LIMIT 20;
Run this before and after adding an index, on production-scale data if at all possible — indexes that look great on a thousand-row dev database don’t always tell you what happens at ten million rows.
Takeaway
An index trades write cost and storage for read speed by maintaining a sorted, searchable structure alongside the table — usually a B-tree, letting lookups go from O(n) to O(log n). Match column order in composite indexes to your actual query filters, consider covering indexes for hot, narrow read paths, and audit for unused indexes periodically, since every index you don’t need is pure overhead on every write.