Codetail

Article 6 of 15

Indexes & Query Optimization

O(n) full scans vs O(log n) index lookups.

20 min read

The Problem: Full Table Scans

Without an index, a database has no choice but to read every row in a table to find the ones that match your query. This is a full table scan. On a table with a million rows, every query that lacks an index reads a million rows, even if the result is a single record.

Without an index: every query is O(n)

-- Table: users (1,000,000 rows)
-- Query: find a user by email

SELECT * FROM users WHERE email = 'alice@example.com';

-- What happens internally:
-- Row 1:  email = 'bob@...'      -> skip
-- Row 2:  email = 'carol@...'    -> skip
-- ...
-- Row 847,291: email = 'alice@...' -> match!
-- Row 847,292 ... 1,000,000       -> still have to check

-- Total rows read: 1,000,000
-- Time: ~200ms on a warm SSD

An index is a separate data structure maintained alongside your table. It stores a sorted copy of the indexed column(s) along with pointers to the actual rows. Instead of scanning the table, the database searches the index, finds the pointer, and jumps directly to the matching row. The same query drops from O(n) to O(log n).

With an index: O(log n) lookup

-- Create the index once
CREATE INDEX idx_users_email ON users(email);

-- Same query, now uses the index
SELECT * FROM users WHERE email = 'alice@example.com';

-- What happens internally:
-- B-tree root: 'alice' < 'mike', go left
-- Next node:   'alice' > 'adam', go right
-- Next node:   'alice' found -> row pointer: #847291
-- Heap fetch:  read row #847291 directly

-- Total rows read: 1
-- Comparisons: ~20 (log2 of 1M)
-- Time: <1ms

The cost of indexes

Indexes are not free. Every index you add must be maintained by the database on every INSERT, UPDATE, and DELETE. For read-heavy workloads, this is a good trade. For write-heavy workloads, excessive indexing becomes the bottleneck.

📖ReadsDramatically faster

O(log n) instead of O(n). 1ms instead of 200ms on a million-row table.

✍️WritesSlightly slower

Every insert, update, or delete must update the index B-tree. Cost scales with number of indexes.

💾StorageExtra disk used

A B-tree index on a large table can consume gigabytes. Monitor total index size vs table size.

Rule of thumb: index columns that appear in WHERE, JOIN ON, and ORDER BY clauses. Do not index every column. A table with 20 indexes and heavy write traffic will be slower than one with 5 well-chosen indexes.

How B-tree Indexes Work

The default index type in PostgreSQL, MySQL, and most other relational databases is a B-tree (balanced tree). The B-tree is a self-balancing sorted tree that keeps all leaf nodes at the same depth. This guarantees that any lookup takes exactly O(log n) comparisons, regardless of which key you are searching for.

The visualizer below uses a 16-row table. Select a search target, then click "Run comparison" to see a full table scan and a B-tree lookup side by side. Notice that the scan checks rows one by one while the index descends the tree in exactly 4 comparisons, regardless of which value you search for.

Full Scan vs Index Lookup

B-tree structure, 16 rows, height 4

80
40
120
20
60
100
140
10
30
50
70
90
110
130
150

Full table scan

10
20
30
40
50
60
70
80
90
100
110
120
130
140
150
160

B-tree index

8090 > 80, go right
12090 < 120, go left
10090 < 100, go left
90Match found

B-tree properties

Balanced height

All paths from root to leaf have the same length. A table with 1 billion rows has a B-tree height of roughly 30. Every lookup takes at most 30 comparisons.

Sorted order

Keys in every node are sorted. This makes range queries efficient: find the start key via binary search, then scan leaves sequentially.

High branching factor

Real B-trees (B+ trees) store hundreds of keys per node, not just two children. A tree with 1B rows might be only 3-4 levels deep.

Leaf chaining

Leaf nodes are linked in sorted order. Range scans (WHERE age BETWEEN 20 AND 30) traverse the chain without backtracking to the root.

O(log n) in practice

Table rowsFull scanB-tree depth
1,0001,000 reads~10 levels
100,000100,000 reads~17 levels
1,000,0001,000,000 reads~20 levels
1,000,000,0001,000,000,000 reads~30 levels

Real B+ trees with branching factor of 100-200 are even shallower. Most production tables are reachable in 3-5 levels regardless of size.

B-trees work well for equality lookups (WHERE id = 42), range queries (WHERE created_at BETWEEN ...), and ORDER BY on indexed columns. They do not help with wildcard prefix searches (WHERE name LIKE '%smith') or full-text matching. Use a dedicated full-text index (tsvector, Elasticsearch) for those.

Index Design: Types and Trade-offs

Not all indexes are equal. The type of index and how you design it determines whether the database can use it for a given query. Understanding these types lets you build indexes that are both fast and efficient.

Single-column indexmost common

An index on one column. The most common form. Speeds up equality and range queries on that column.

CREATE INDEX idx_users_email ON users(email);

-- Queries that use this index:
WHERE email = 'alice@example.com'    -- equality
WHERE email > 'l@...'               -- range
ORDER BY email                       -- sort
Note

The database may ignore the index if the column has very few distinct values (low cardinality) or if the query returns most of the table.

Composite indexleftmost prefix rule

An index on multiple columns. The order of columns matters critically. The index can be used for queries that filter on the leftmost prefix of the index columns.

-- Index on (last_name, first_name, age)
CREATE INDEX idx_name ON users(last_name, first_name, age);

-- Uses the index:
WHERE last_name = 'Smith'
WHERE last_name = 'Smith' AND first_name = 'Alice'
WHERE last_name = 'Smith' AND first_name = 'Alice' AND age > 30

-- Does NOT use the index:
WHERE first_name = 'Alice'  -- skips leftmost column
WHERE age > 30              -- skips two leftmost columns
Note

Put the most selective column (most distinct values) first, unless your most common query always filters on a specific column.

Covering indexindex-only scan

An index that includes all columns needed by a query. The database can answer the query entirely from the index, without touching the main table (heap). This is called an index-only scan.

-- Query: get name and email for users in a role
SELECT name, email FROM users WHERE role = 'admin';

-- A covering index includes all needed columns
CREATE INDEX idx_role_covering ON users(role, name, email);

-- Now the query never touches the users table:
-- Index has role (for filtering) + name + email (for SELECT)
-- Result: index-only scan, zero heap fetches
Note

Covering indexes use more storage but eliminate the most expensive part of a lookup: the heap fetch. Benchmark before adding them blindly.

Partial indexselective rows only

An index that only covers rows matching a WHERE condition. Smaller than a full index, faster to maintain, and can be more selective for the queries that need it.

-- Only index active users (not the 90% who churned)
CREATE INDEX idx_active_users
  ON users(email)
  WHERE status = 'active';

-- Only index unprocessed orders
CREATE INDEX idx_pending_orders
  ON orders(created_at)
  WHERE processed = false;

-- The query must include the same WHERE clause
-- for the database to choose the partial index
Note

Ideal when most of your queries filter on a condition that excludes large portions of the table (soft-deleted rows, inactive records, processed events).

Composite index column order

The leftmost prefix rule catches many engineers off-guard. Given an index on (A, B, C), the database can use it for queries filtering on A, on A+B, or on A+B+C. It cannot use it for queries filtering only on B or only on C.

Index (department, hire_date, salary), what gets used

YesWHERE department = 'Eng'
YesWHERE department = 'Eng' AND hire_date > '2023-01-01'
YesWHERE department = 'Eng' AND hire_date > '2023-01-01' AND salary > 80000
NoWHERE hire_date > '2023-01-01'
NoWHERE salary > 80000
PartialWHERE department = 'Eng' AND salary > 80000

Query Optimization and Common Pitfalls

Even with indexes in place, queries can be slow if they are written in ways the database cannot optimize. The most common culprits are the N+1 problem, functions applied to indexed columns, and access patterns that invalidate the index entirely.

Reading EXPLAIN output

Every major database has an EXPLAIN command that shows how a query will be executed. Reading EXPLAIN output is the fastest way to understand why a query is slow.

PostgreSQL EXPLAIN ANALYZE output

EXPLAIN ANALYZE
SELECT * FROM orders WHERE customer_id = 42;

-- Without index:
Seq Scan on orders  (cost=0.00..18334.00 rows=1 width=52)
                    (actual time=128.3..128.3 rows=1 loops=1)
  Filter: (customer_id = 42)
  Rows Removed by Filter: 999999        <- scanned 1M rows!
Planning Time: 0.1 ms
Execution Time: 128.4 ms

-- After: CREATE INDEX idx_orders_customer ON orders(customer_id);

Index Scan on orders using idx_orders_customer
                    (cost=0.42..8.44 rows=1 width=52)
                    (actual time=0.04..0.04 rows=1 loops=1)
  Index Cond: (customer_id = 42)
Planning Time: 0.1 ms
Execution Time: 0.05 ms            <- 2,500x faster
Seq Scan

Full table scan. Look for this when performance is bad.

Index Scan

B-tree lookup. Usually what you want.

Index Only Scan

Covering index hit. Best case: no heap fetch.

Common slow query patterns

🔁The N+1 query problem

Problematic

# Django ORM, looks clean, performs terribly
posts = Post.objects.all()       # 1 query
for post in posts:
    # Each iteration fires a NEW query!
    author = post.author          # N queries
    comments = post.comments.all() # N more queries

# For 100 posts: 201 queries sent to the database

Fixed

# Use eager loading to fetch in bulk
posts = Post.objects.select_related('author') \
            .prefetch_related('comments') \
            .all()

# Now: exactly 3 queries total, regardless of post count:
# 1. SELECT posts
# 2. SELECT authors WHERE id IN (...)
# 3. SELECT comments WHERE post_id IN (...)

The N+1 pattern is the most common cause of slow pages in ORM-based applications. Enable query logging in development to catch it early.

🔧Function on indexed column

Problematic

-- Index on created_at exists, but this doesn't use it:
SELECT * FROM orders
WHERE YEAR(created_at) = 2025;

-- Or in PostgreSQL:
WHERE DATE_TRUNC('year', created_at) = '2025-01-01';

-- The function wraps the column, breaking the index.
-- Database falls back to full scan.

Fixed

-- Rewrite to a range query on the raw column:
SELECT * FROM orders
WHERE created_at >= '2025-01-01'
  AND created_at <  '2026-01-01';

-- Now the B-tree index on created_at is used.
-- Alternative: create an expression index
CREATE INDEX idx_year ON orders (YEAR(created_at));

If you must apply a function, create an expression index that stores the pre-computed result.

🔍Wildcard prefix search

Problematic

-- Index on name exists, but this causes a full scan:
SELECT * FROM users WHERE name LIKE '%smith';

-- The leading wildcard prevents the B-tree from
-- knowing where to start. Every row must be checked.

Fixed

-- Trailing wildcard works (prefix search):
SELECT * FROM users WHERE name LIKE 'smith%';

-- For full-text search, use the right tool:
-- PostgreSQL: tsvector + GIN index
-- MySQL: FULLTEXT index
-- Or: Elasticsearch for advanced text search

B-trees are sorted by value from left to right. A leading wildcard means 'start could be anywhere', making the sorted order useless.

When not to add an index

Low cardinality columns

A boolean column has only 2 values. An index on it often makes things worse: the database reads half the table and then fetches each row anyway.

Small tables

For tables under a few thousand rows, a sequential scan is faster than an index lookup. The overhead of the index traversal exceeds the benefit.

Write-heavy tables

Every write must update all indexes. An event log or audit table with 20 indexes will be throttled by index maintenance, not by the data itself.

Columns rarely in WHERE

Index only columns your queries actually filter on. An index that is never used still costs write overhead and storage.