Database Indexing: What Every Developer Should Know
Understanding how database indexes work, when to use them, and how to avoid common indexing mistakes.
March 4, 2026 · 7 min · 1321 words · Rob Washington
Table of Contents
Your query is slow. You add an index. It gets faster. Magic, right?
Not quite. Indexes are powerful but misunderstood. Used well, they turn seconds into milliseconds. Used poorly, they slow down writes, waste storage, and sometimes make queries slower.
An index is a separate data structure that helps the database find rows without scanning the entire table. Think of it like a book’s index—instead of reading every page to find “PostgreSQL,” you look it up in the index and jump directly to page 247.
Without an index:
1
2
SELECT*FROMusersWHEREemail='alice@example.com';-- Database scans all 10 million rows: ~5 seconds
With an index on email:
1
2
SELECT*FROMusersWHEREemail='alice@example.com';-- Database looks up index, jumps to row: ~5 milliseconds
-- Single column index
CREATEINDEXidx_users_emailONusers(email);-- Multi-column (composite) index
CREATEINDEXidx_orders_user_dateONorders(user_id,created_at);-- Unique index (enforces uniqueness)
CREATEUNIQUEINDEXidx_users_email_uniqueONusers(email);-- Partial index (only indexes subset of rows)
CREATEINDEXidx_orders_pendingONorders(created_at)WHEREstatus='pending';
-- ✅ Uses index (leftmost column)
SELECT*FROMordersWHEREuser_id=123;-- ✅ Uses index (both columns, left to right)
SELECT*FROMordersWHEREuser_id=123ANDcreated_at>'2026-01-01';-- ✅ Uses index for user_id, then scans for date range
SELECT*FROMordersWHEREuser_id=123ANDcreated_atBETWEEN'2026-01-01'AND'2026-02-01';
This index does NOT help:
1
2
3
4
5
-- ❌ Cannot use index (missing leftmost column)
SELECT*FROMordersWHEREcreated_at>'2026-01-01';-- ❌ Cannot use index efficiently
SELECT*FROMordersWHEREcreated_at>'2026-01-01'ANDuser_id=123;
Rule of thumb: Put equality conditions first, range conditions last.
Every index slows down writes. When you INSERT, UPDATE, or DELETE:
Modify the table row
Update every index on that table
1
2
3
-- Table with 5 indexes: every INSERT updates 6 data structures
INSERTINTOorders(user_id,product_id,quantity,price,status)VALUES(123,456,2,29.99,'pending');
Measure the tradeoff:
Read-heavy workload (100:1 read:write)? Index liberally
Write-heavy workload (1:10 read:write)? Index sparingly
-- Don't do this
CREATEINDEXidx_1ONusers(email);CREATEINDEXidx_2ONusers(email,name);CREATEINDEXidx_3ONusers(email,name,created_at);-- idx_2 and idx_3 can serve queries that idx_1 would serve
-- Mostly useless: boolean has only 2 values
CREATEINDEXidx_users_activeONusers(is_active);-- Better: partial index
CREATEINDEXidx_users_inactiveONusers(created_at)WHEREis_active=false;