bigonotes
System DesignDatabases

Indexing Trade-offs

What every index costs in write speed and storage, when not to add one, and which engines use B-trees, LSM trees or other structures.

6 min readLast updated DatabasesListen · Watch on NotebookLM

Indexes make reads fast, but they aren't free. Every index you add makes every write to the table slower and takes up disk space. Good indexing means adding the few indexes your real queries need and resisting the rest. This lesson covers the costs, the cases where an index doesn't help, and a checklist for deciding.

The deal you make#

Every index is a trade: you speed up some reads and pay for it everywhere else.

You get You pay
Faster reads on the indexed columns Slower INSERT, UPDATE and DELETE on the whole table
Extra storage
Ongoing maintenance

Why indexes slow writes#

Each index is its own B-tree (see Read Heavy Indexing). Every write must update the table and every index:

Indexes What one INSERT does Writes
0 Write the row 1
1 Write the row + update 1 B-tree 2
5 Write the row + update 5 B-trees 6
15 Write the row + update 15 B-trees 16

Each index update means finding the position, inserting the key and maybe splitting a node.

Updates to an indexed column are often worse than inserts. Take this statement:

UPDATE users SET email = 'new@example.com' WHERE id = 5;
If email is indexed If email is not indexed
1. Remove the old email from the index B-tree 1. Update the row. Done.
2. Insert the new email into the index B-tree
3. Update the row

Storage cost#

An index stores a copy of the indexed columns plus a pointer for every row. A handful of secondary indexes, especially composite and covering ones that copy several columns, can together take up as much space as the table itself. That's more disk, more memory needed to keep indexes cached, and longer backups.

When not to index#

An index only pays off when queries use it often and selectively. These five cases usually fail that test.

1. Small tables#

With a few hundred rows, a full scan already takes a fraction of a millisecond. An index saves almost nothing and still costs on every write. Tables under roughly a thousand rows rarely benefit.

2. Low-cardinality columns#

Cardinality is the number of distinct values in a column.

Take a status column with the values 'active', 'inactive' and 'pending' across 10 million rows. WHERE status = 'active' matches about a third of the table. Following millions of index pointers is slower than just reading the table, so the query planner ignores the index and does a full scan.

Good cardinality (index helps) Poor cardinality (index rarely helps)
email: one row per value is_active: 2 values
user_id: millions of values status: a handful of values
phone_number: nearly unique country: ~200 values across millions of rows

What matters is how selective a particular query is. If a rare value (say, the 0.1% of rows that are 'pending') is queried often, an index or a partial index on just those rows can still pay off.

3. Write-heavy, rarely read tables#

Picture a logging table with 100,000 inserts per second that is read once a week for analytics. Five indexes mean roughly 6× the write work, for a query that runs weekly.

A better plan is to write with no (or minimal) indexes, then either:

  • build an index just before the analytics job and drop it afterwards, or
  • export the data to a system built for analytics.

4. Columns you never query#

Say a users table has id, email, name, bio, avatar_url and created_at. Index only what real queries touch:

Real query Decision
WHERE email = ? Index email ✓
WHERE created_at > ? Index created_at ✓
ORDER BY name Index name ✓
bio, avatar_url never appear in WHERE, JOIN or ORDER BY No index ✗

It sounds obvious, but adding indexes "just in case" is one of the most common performance mistakes.

5. Frequently updated columns#

Say last_login is updated on every visit, at 100,000 logins per hour. An index on it means 100,000 index updates per hour, each removing the old value and inserting the new one. Unless you often query "users who logged in between X and Y", this index costs more than it saves.

The over-indexing problem#

A common story: a table has 15 indexes and writes are slow. "But reads are fast!" Often only 3 of those indexes are used by real queries. The other 12 slow down every write and waste storage.

To find unused indexes:

  • PostgreSQL: pg_stat_user_indexes shows how often each index is scanned.
  • MySQL: sys.schema_unused_indexes, or performance_schema index statistics.

Then drop what isn't used.

Decision checklist#

Before adding an index, walk through these questions:

# Question If… Then
1 Does the column appear often in WHERE, JOIN or ORDER BY? No Don't index
2 Are the queries on it selective (high cardinality)? No Probably don't
3 Is the table write-heavy? Yes Only the essential ones
4 Is the table tiny (under ~1,000 rows)? Yes Don't bother
5 Would it be a covering index for a critical hot query? Yes Likely worth the space

The same rules, as scenarios:

Scenario Index? Why
Frequent WHERE on a high-cardinality column Yes Fast, selective lookups
Column used in JOIN or ORDER BY Yes Avoids scans and sorts
Critical query that a covering index would serve Yes Skips the table lookup
Small table No A scan is fast enough
Low-cardinality column Usually no The planner scans anyway
Write-heavy, rarely read table No Write cost isn't worth it
Column never queried No Pure waste
Frequently updated column Probably not Constant index churn

The same trade-off in DynamoDB: every global secondary index has its own write capacity, and each table write that touches indexed attributes also writes to those indexes. More indexes literally means a higher cost per write, so high-throughput tables keep their secondary indexes to a minimum.

Which databases use which structure#

Different engines pick different index structures to match the workload they target.

Database Storage engine Main structure
PostgreSQL Built-in B+ tree indexes over a heap table
MySQL InnoDB (default) B+ tree, with the table clustered on the primary key
SQLite Built-in B+ tree
MongoDB WiredTiger B-tree
Cassandra Built-in LSM tree (SSTables)
RocksDB, LevelDB Built-in LSM tree
Redis In-memory Hash tables, skip lists (no on-disk tree)
Elasticsearch Lucene Inverted index

The pattern:

  • General-purpose SQL databases (PostgreSQL, MySQL, SQLite, Oracle, SQL Server) use B+ trees: balanced reads and writes, updates in place, and they fit well with transactions.
  • Write-heavy stores (Cassandra, RocksDB, LevelDB, ScyllaDB) use LSM trees: much cheaper writes, somewhat slower reads. See Write Heavy Indexing.
  • Search engines (Elasticsearch, Solr) use inverted indexes that map terms to documents.
  • In-memory stores (Redis, Memcached) use hash tables and similar structures, since there is no disk layout to optimise. See Hash Indexes.

Key takeaways#

  • Every index speeds up some reads and slows down every write, because each index must be updated.
  • Index columns that appear in frequent, selective WHERE, JOIN or ORDER BY clauses.
  • Skip indexes on tiny tables, low-cardinality columns, rarely read logging tables and columns you never query.
  • Audit index usage regularly and drop indexes that aren't used.
  • B+ trees suit balanced workloads, LSM trees suit write-heavy ones, inverted indexes suit search, and hash tables suit in-memory key-value access.

Found a mistake? Tell me on LinkedIn ↗

Further reading#