TL;DR

1. The problem: why do queries get slow as Instagram grows?

Open any Instagram profile and the app shows the 12 most recent posts in a grid. The query behind it looks innocent:

SELECT id, created_at FROM posts
 WHERE user_id = 42
 ORDER BY created_at DESC
 LIMIT 12;

With 1,000 posts in the table this takes microseconds no matter how it's executed. Now assume the table holds 100 million posts of about 200 bytes each — 20 GB. Without an index the database has exactly one strategy: read every row, keep those with user_id = 42, sort them and return 12. Even at a generous 500 MB/s of sequential disk throughput that's ~40 seconds of I/O for a single profile view, and every concurrent profile view competes for the same disk. Instagram serves hundreds of thousands of such reads per second. Without indexes, the product simply cannot exist.

With the right index the same query reads about 4–5 small pages (~40 KB) and finishes in well under a millisecond. That gap — 40 s versus 0.1 ms, a factor of roughly 400,000 — is why indexing is one of the highest-leverage skills in system design.

🧠 Analogy: the index at the back of a book To find every page that mentions "replication" in a 900-page textbook you could read all 900 pages (a full table scan). Or you could flip to the index at the back, which lists terms in alphabetical order with page numbers: find "replication" in seconds (because it's sorted, you can jump to the R section), then go directly to pages 212, 340 and 517. The book index is a separate, sorted structure that points into the main content — exactly what a database index is. It also shows the costs: the index takes extra pages, and every time the author adds a paragraph the index must be updated too.

2. The cost of a full table scan

Databases store rows in fixed-size pages (8 KB in PostgreSQL, 16 KB in MySQL InnoDB). Reading a page from disk is the unit of cost. A full table scan (PostgreSQL calls it a Seq Scan) reads every page of the table:

rows per page   = 8 KB / 200 B          ≈ 40
pages           = 100,000,000 / 40      = 2,500,000 pages (≈ 20 GB)
scan time (SSD) = 20 GB / 500 MB/s      ≈ 40 s
scan time (RAM) = 20 GB / ~5 GB/s       ≈ 4 s   (even fully cached it's far too slow)

The cost is O(n): it grows linearly with table size, so a query that was fine at launch gets 10× slower each time the table grows 10×. Full scans also evict useful pages from the buffer cache, hurting every other query.

Full scans are not always bad. They are the right choice when you need a large fraction of the table anyway (analytics, "count all posts per country"), because reading pages sequentially is much faster than jumping around randomly via an index. The optimizer weighs this trade-off using selectivity (Section 8).

3. B-trees and B+trees: the default index

3.1 Structure

A B-tree is a balanced search tree designed for disks: each node is one page and holds many sorted keys (hundreds), so the tree is very wide and very shallow. Almost every relational database actually uses the B+tree variant:

The number of children per node is the fan-out. With 8 KB pages and ~16-byte entries (8-byte key + pointer), an internal node holds roughly 400–500 entries.

root (level 1) internal (level 2) leaves (level 3) 2000 | 6000 700 | 1400 3000 | 4500 7000 | 8500 1400 … 1999 2000 … 2999 3000 … 4499 4500 … 5999 6000 … 6999 → row pointers → row pointers key 3742 ✔ → row pointers → row pointers leaves linked → range scans ① 2000 ≤ 3742 < 6000 ② 3000 ≤ 3742 < 4500
A (tiny) B+tree. Looking up key 3742 reads one node per level — root → middle internal node → leaf (orange path). Real nodes hold hundreds of keys, so three or four levels cover billions of rows. Leaves are linked, so a range scan continues sideways without going back up the tree.

3.2 Lookup is O(log n) — and log base 500 is tiny

  1. Read the root page (almost always cached in RAM). Binary-search its keys to pick the child.
  2. Read that internal page, binary-search again, pick the next child.
  3. Reach the leaf; find the key; follow the pointer to the row (one more page read, unless the index covers the query).

The height of the tree is about logfan-out(n). With fan-out 500:

LevelsMax keys addressable (500levels)Example table
2250,000Hashtags in a small app
3125,000,000Posts of a mid-sized shard
462,500,000,000Every like ever made on one large table

So looking up one row among 100 million costs 3 index page reads + 1 table page read. Because the root and internal levels are small (level 2 of a 100M-row index is only ~200 pages ≈ 1.6 MB) they stay in memory, so in practice it's often one or two actual disk reads. Compare 2,500,000 page reads for the full scan.

3.3 Inserts, page splits and fill factor

To insert a key, the database finds the correct leaf and adds the key in sorted position. If the leaf is full, it splits into two half-full pages and pushes a separator key up into the parent (which may split in turn; if the root splits, the tree grows one level taller — that's how B-trees stay balanced). Consequences worth knowing:

4. Hash indexes

A hash index applies a hash function to the key and stores the entry in the corresponding bucket — a hash table on disk (or in memory). Lookups by exact key are O(1) on average.

B+tree indexHash index
Equality (= ?)O(log n) — ~3–4 page readsO(1) — ~1–2 page reads
Range (>, BETWEEN, prefix LIKE 'ab%')✅ Yes❌ No — hashing destroys order
ORDER BY without sorting✅ Yes❌ No
Composite / prefix use✅ Leftmost prefix usable❌ Must supply the whole key
Typical homeDefault index in every RDBMSIn-memory stores (Redis, Memcached), Bitcask, PostgreSQL USING hash, MySQL MEMORY engine, hash joins

Because B+trees are nearly as fast for equality and far more flexible, relational databases default to them. Hash indexing shines in key-value stores: Redis is essentially an in-memory hash table, and Bitcask (Riak's engine) keeps an in-memory hash map from key to file offset on disk. Hashing is also how distributed systems decide which node owns a key — see Consistent Hashing.

5. LSM-trees: the write-optimised storage engine

B-trees update pages in place, which means random writes. For workloads with enormous write rates — Instagram's likes, feed entries, activity and messages — a different design wins: the Log-Structured Merge-tree (LSM-tree), used by Cassandra, ScyllaDB, HBase, Bigtable, RocksDB, LevelDB and (under the hood) many others. The core idea: never modify data on disk; only append sorted files and merge them later in the background.

5.1 The write path

  1. WAL (commit log). The write is appended to a sequential log on disk for durability. Appends are the fastest possible disk operation.
  2. Memtable. The write is inserted into an in-memory sorted structure (typically a skip list or balanced tree). The client gets "OK" now — no data file has been touched.
  3. Flush. When the memtable reaches a threshold (e.g. 64–256 MB) it's frozen and written to disk in one sequential pass as an SSTable (Sorted String Table): an immutable file of key-sorted entries plus a small index and a Bloom filter. The WAL segment covering it can then be discarded.
  4. Compaction. Background threads merge SSTables (a merge-sort of already-sorted files), keeping only the newest version of each key and dropping deleted data, so the number of files a read must check stays bounded.

5.2 The read path

A key might be in the memtable or in any SSTable, and newer data shadows older data. So a read checks: memtable → then SSTables from newest to oldest, stopping at the first hit. To avoid opening files that can't contain the key, each SSTable has a Bloom filter in memory, plus a sparse index (every N-th key with its file offset) to jump close to the key within a file.

write(k, v) Memtable (RAM) sorted skip list WAL / commit log append-only, on disk L0: flushed SSTables (may overlap) SST 9 (new)SST 8SST 7 L1: non-overlapping key ranges (~10× L0) a – fg – mn – st – z L2: ~10× L1, also non-overlapping many SSTables, each a sorted key range ② ① ③ flush ④ compaction ④ compaction read(k): 1. check memtable → found? return 2. for each SSTable, newest → oldest: Bloom filter says "definitely not"? skip file else sparse index → read one block
LSM-tree write path (① append to WAL, ② insert into memtable, ③ flush to an immutable SSTable, ④ background compaction into larger levels) and read path (memtable first, then SSTables newest→oldest, skipping files via Bloom filters).

5.3 Deletes and updates

Because files are immutable, an update is just a newer write of the same key, and a delete is a special write called a tombstone ("key k deleted at time t"). Reads see the newest entry, so the tombstone hides older values. Compaction eventually discards both the tombstone and the shadowed data (in Cassandra, only after gc_grace_seconds, so replicas that missed the delete can't "resurrect" the data). Lots of tombstones in a range make reads slow — a well-known Cassandra pitfall.

5.4 Bloom filters

A Bloom filter is a compact probabilistic set: a bit array of m bits plus k hash functions. To add a key, set the k bits it hashes to. To test a key, check those k bits: if any is 0, the key is definitely not present; if all are 1, it's probably present. There are no false negatives, only occasional false positives.

false-positive rate  p ≈ (1 − e^(−k·n/m))^k        n = keys, m = bits, k = hash functions
optimal k            k = (m/n) · ln 2

Example: 10 bits per key (m/n = 10), k = 7  →  p ≈ 0.8 %
         an SSTable with 10 M keys needs 100 M bits ≈ 12 MB of RAM for its filter

Effect: a read for a key that exists in only one of 20 SSTables opens ~1 file instead of 20. Cassandra lets you tune bloom_filter_fp_chance per table (more memory, fewer wasted disk reads).

5.5 Compaction strategies

StrategyHow it worksStrengthsWeaknessesUse for
Size-tiered (STCS)When ~4 SSTables of similar size exist, merge them into one bigger file.Low write amplification; great for write-heavy loads.A key can live in many files → more read amplification; temporary 2× disk space during big merges.Write-heavy, rarely-read data (Cassandra's default).
Leveled (LCS)Levels L1, L2, … each ~10× larger; within a level files don't overlap, so a key is in at most one file per level.Predictable reads (≈ one file per level), low space overhead (~10%).Higher write amplification (data rewritten each time it moves down a level).Read-heavy or update-heavy tables (RocksDB's default style).
Time-window (TWCS)Groups SSTables by time window (e.g. 1 day); compacts only within a window; whole windows expire at once.Ideal for TTL'd, time-series data: expired windows are dropped as whole files, no tombstone scanning.Bad if old data gets updated (mixes windows).Stories (24 h TTL), activity feeds, metrics.

5.6 B-tree vs LSM-tree

Three "amplification" metrics frame the comparison:

B+tree (PostgreSQL, MySQL InnoDB)LSM-tree (Cassandra, RocksDB, HBase)
Write patternRandom in-place page updatesSequential appends + background merges
Write throughputGood; degrades with many indexes / random keysExcellent — often several × higher on the same hardware
Point readsFast and predictable (one tree path)Good with Bloom filters; may touch several files
Range scansExcellent (linked leaves)Good (merge across files), costlier with many SSTables
Write amplificationPage-granular (8–16 KB per small change) + WALCompaction-driven (≈ 10–30× leveled, lower size-tiered)
Read amplificationLowHigher; depends on compaction strategy
Space amplificationFragmentation, page slackOld versions/tombstones until compacted; low with leveled
Latency predictabilityStableCompaction can cause latency spikes if it falls behind
Transactions / lockingNatural fit (each key in one place)Harder; usually single-key/partition atomicity
Best forOLTP with mixed reads/writes, rich secondary indexesWrite-heavy, append-mostly, time-ordered data at massive scale
💡 Rule of thumb "B-trees are read-optimised, LSM-trees are write-optimised" is a fine first answer. A great answer adds: LSMs win when writes dominate and keys are accessed by primary key/partition; B-trees win when you need many secondary indexes, transactions and predictable read latency. That's exactly the split between Instagram's PostgreSQL and Cassandra workloads.

6. Clustered vs non-clustered indexes

Where does the actual row live?

 MySQL InnoDB (clustered by PRIMARY KEY id)          PostgreSQL (heap + indexes)

 secondary idx (username)     PK B+tree (id)         idx (username)        heap file (unordered)
 +----------------------+     +-----------------+    +-----------------+   +---------------------+
 | "alice"  → id 42     | --> | id 42 | FULL ROW|    | "alice" → (7,3) |-->| page 7, slot 3: row |
 | "bob"    → id 17     |     | id 43 | FULL ROW|    | "bob"   → (2,9) |   | page 2, slot 9: row |
 +----------------------+     +-----------------+    +-----------------+   +---------------------+
 lookup by username = 2 tree walks                    lookup = 1 tree walk + 1 heap page read
 lookup by id       = 1 tree walk (row is right there) range by PK may hit many scattered heap pages

Why it matters:

7. Kinds of indexes you'll design

7.1 Primary index

The index on the primary key — unique and non-null, created automatically. Every table should have one; in InnoDB it's the clustered index. Prefer compact integer keys; Instagram's 64-bit time-sortable IDs are a good example: 8 bytes, unique across shards, and naturally ordered by creation time.

7.2 Secondary index

Any additional index on non-PK columns, to support other access paths — e.g. UNIQUE INDEX ON users (email) for login by email. Secondary indexes may be unique (also enforce a constraint) or non-unique. In a sharded system, a secondary index on a column that isn't the shard key is a hard problem (local vs global indexes — see Sharding).

7.3 Composite (multi-column) index and the leftmost-prefix rule

A composite index on (a, b, c) sorts entries by a, then by b within equal a, then by c — like a phone book sorted by (last name, first name). You can efficiently find "all Smiths" or "Smith, John", but not "everyone named John" — they're scattered across the whole book. That's the leftmost-prefix rule: an index can be used for a contiguous prefix of its columns, starting from the left.

For CREATE INDEX posts_user_recent ON posts (user_id, created_at DESC):

QueryUses the index?Why
WHERE user_id = 42✅ YesLeftmost column.
WHERE user_id = 42 ORDER BY created_at DESC LIMIT 12✅ Yes, and no sort stepEntries for user 42 are already in created_at DESC order — read the first 12 and stop.
WHERE user_id = 42 AND created_at > '2026-01-01'✅ YesEquality on the first column, range on the second.
WHERE created_at > '2026-01-01'❌ Not efficientlySkips the leftmost column; matching entries are spread under every user_id. (Some engines can do a "skip scan" when the first column has few distinct values.)
WHERE user_id IN (1, 2, 3) ORDER BY created_at DESC⚠️ PartlyThree index ranges are found, but they must be merged/sorted — fine for 3 users, painful for 3,000 (that's why the feed isn't built this way).

Column order rules of thumb: put columns used with equality first, then the column used for a range or ORDER BY last; a range on column b stops the index from helping with column c after it. Among equality columns, the order matters less for a single query but should be chosen so the prefix serves other queries too.

7.4 Covering index (index-only scan)

If an index contains every column a query needs, the database answers from the index alone and never touches the table — an index-only scan. That saves one random page read per row.

-- The grid needs id, created_at and the thumbnail key. Add the extra column as payload:
CREATE INDEX posts_grid_covering
    ON posts (user_id, created_at DESC)
    INCLUDE (id, thumb_key);          -- PostgreSQL 11+ / SQL Server syntax; in MySQL append the columns

Covering indexes are bigger, so add them only for hot queries. In PostgreSQL an index-only scan also needs the table's visibility map to be up to date (maintained by VACUUM).

7.5 Partial (filtered) index

A partial index indexes only rows matching a predicate. It's smaller, faster to update and more likely to stay in memory.

-- 3% of posts are soft-deleted and never shown; don't index them at all
CREATE INDEX posts_live_by_user ON posts (user_id, created_at DESC)
 WHERE deleted_at IS NULL;

-- Only a small fraction of comments are flagged for moderation review
CREATE INDEX comments_pending_review ON comments (created_at)
 WHERE status = 'pending_review';

The query must include a compatible predicate (AND deleted_at IS NULL) for the planner to use it. Instagram's engineering blog has publicly recommended partial indexes as one of its PostgreSQL scaling tips.

7.6 Expression (functional) index

Index the result of an expression, e.g. case-insensitive usernames: CREATE UNIQUE INDEX users_username_ci ON users (lower(username)); then query with WHERE lower(username) = lower($1). Without it, wrapping a column in a function prevents the normal index from being used.

8. Cardinality and selectivity: is the index even worth it?

Using an index costs roughly one random page read per matching row (unless the matching rows are physically adjacent or the index covers the query). A sequential scan costs one cheap sequential read per page. So if a predicate matches a big fraction of rows, the planner correctly ignores the index and scans. The crossover is often somewhere around a few percent to ~10–20% of rows, depending on row width, caching and storage type.

Table: 100 M posts, 2.5 M pages.
Predicate A: user_id = 42        → ~300 rows (0.0003 %)  → index: ~300 page reads  ✅
Predicate B: media_type = 'photo'→ ~70 M rows (70 %)     → index: ~70 M random reads ❌  seq scan: 2.5 M pages ✅
Predicate C: is_private = true AND created_at > now() - interval '1 hour'
             → combine: index on created_at (selective) + filter on is_private

Planners estimate selectivity from statistics (histograms, most-common values, distinct counts) gathered by ANALYZE. Stale statistics → bad estimates → bad plans; a classic production incident is a plan flip after a large data load. Low-cardinality columns can still be useful inside a composite index or as a partial-index predicate (e.g. WHERE status = 'pending' when pending rows are rare).

9. The write cost of indexes

Indexes are not free. Every INSERT must add an entry to every index on the table; every DELETE must remove them; an UPDATE must modify every index whose columns changed (and in PostgreSQL, a non-HOT update touches all indexes). Each of those is a B-tree descent and possibly a page split, plus WAL records and replication traffic.

Assume the likes table receives 58,000 inserts/s on average (see the databases lesson).
  PK (post_id, user_id)               → 1 index write
  + index (user_id, created_at)       → 1 more
  + index (created_at) "for analytics"→ 1 more
  = 174,000 B-tree insertions/s, each a potential random page write, plus 3× the WAL volume.

Drop the analytics index (run analytics on the data warehouse instead) → 116,000/s: one third less write I/O.

Costs of each additional index:

So: index what your real queries need, monitor index usage (pg_stat_user_indexes.idx_scan), and drop indexes that nobody uses. For bulk loads, it's often faster to drop indexes, load, and rebuild.

10. Reading EXPLAIN plans

EXPLAIN shows the plan the optimizer chose; EXPLAIN ANALYZE also runs the query and reports real row counts and timings. This is how you prove an index is used. (Example outputs below are PostgreSQL-style, simplified, with parallelism disabled for clarity.)

10.1 Before: no suitable index

EXPLAIN ANALYZE
SELECT id, created_at FROM posts WHERE user_id = 42 ORDER BY created_at DESC LIMIT 12;

Limit  (cost=2350412.10..2350412.13 rows=12 width=16) (actual time=8123.4..8123.4 rows=12 loops=1)
  ->  Sort  (cost=2350412.10..2350412.85 rows=300 width=16) (actual time=8123.4..8123.4 rows=12 loops=1)
        Sort Key: created_at DESC
        Sort Method: top-N heapsort  Memory: 25kB
        ->  Seq Scan on posts  (cost=0.00..2350405.00 rows=300 width=16)
                                (actual time=0.03..8120.9 rows=287 loops=1)
              Filter: (user_id = 42)
              Rows Removed by Filter: 99999713
Planning Time: 0.1 ms
Execution Time: 8123.5 ms

How to read it (bottom-up, innermost first):

10.2 After: composite index

CREATE INDEX CONCURRENTLY posts_user_recent ON posts (user_id, created_at DESC);

Limit  (cost=0.57..48.90 rows=12 width=16) (actual time=0.031..0.058 rows=12 loops=1)
  ->  Index Scan using posts_user_recent on posts  (cost=0.57..1208.90 rows=300 width=16)
                                                  (actual time=0.030..0.055 rows=12 loops=1)
        Index Cond: (user_id = 42)
Planning Time: 0.2 ms
Execution Time: 0.08 ms

10.3 Even better: covering index

Index Only Scan using posts_grid_covering on posts  (actual time=0.020..0.031 rows=12 loops=1)
  Index Cond: (user_id = 42)
  Heap Fetches: 0

Heap Fetches: 0 means the table was never touched. Other plan nodes to recognise: Bitmap Index Scan / Bitmap Heap Scan (collect many matching row locations, then read table pages in physical order — used for medium selectivity or to combine indexes with AND/OR), Nested Loop / Hash Join / Merge Join (join algorithms), and in MySQL's EXPLAIN the type column (ALL = full scan, ref/range = index) and Extra: Using index (covering) or Using filesort (extra sort).

⚠️ Things that silently disable an index Wrapping the column in a function (WHERE date(created_at) = …), implicit type casts (comparing a bigint column to a string), a leading wildcard (LIKE '%gram'), OR across different columns, or skipping the leftmost column of a composite index. Always check the plan.

11. Geospatial indexes: "posts near me"

Instagram lets users tag a location on a post and browse a place's page or a map of nearby places. The query "places within 2 km of (lat, lng)" can't use an ordinary B-tree well: an index on lat finds a horizontal band spanning the globe, and an index on (lat, lng) still only narrows on latitude. Two dimensions need a spatial index. Three common approaches:

11.1 Geohash: turn 2-D into a sortable string

A geohash recursively halves the world, alternating longitude and latitude: each bit says "left/right half" or "bottom/top half". The interleaved bits are encoded 5 at a time in base-32 characters. Key property: points that share a prefix are in the same cell, and longer prefixes mean smaller cells. So a spatial lookup becomes a prefix search on an ordinary B-tree (or a key lookup in Redis/Cassandra).

 Bits are interleaved: lng, lat, lng, lat, …     then grouped by 5 → base-32 char
   lng bit 1: is lng ≥ 0?        → 1
   lat bit 1: is lat ≥ 0?        → 1
   lng bit 2: upper half of [0,180)?  …            "t", "td", "tdr", "tdr1", … (each char = 32× smaller cell)

 Neighbour search: the target point P may sit near a cell edge, so query P's cell
 AND its 8 neighbours (prefix length chosen so a cell ≈ the search radius):

        +--------+--------+--------+
        | NW     | N      | NE     |
        +--------+--------+--------+
        | W      | P's    | E      |      9 prefix lookups, then filter by exact
        |        | cell • |        |      distance (haversine) on the candidates
        +--------+--------+--------+
        | SW     | S      | SE     |
        +--------+--------+--------+
Geohash lengthApprox. cell size (at the equator)Good for
439 km × 19.5 kmCity-level
54.9 km × 4.9 km"Nearby" within a few km
61.2 km × 0.6 kmNeighbourhood
7153 m × 153 mStreet / venue
838 m × 19 mBuilding

Caveats: two points a metre apart on either side of a cell boundary can have completely different prefixes (hence the 9-cell query), and cells distort towards the poles. Redis GEOADD/GEOSEARCH is built on geohash-encoded sorted-set scores; Elasticsearch supports geo_point fields with distance queries.

11.2 Quadtree: adaptive cells

A quadtree starts with one square covering the map and splits any cell holding more than K points into four quadrants (NW, NE, SW, SE), recursively. Dense cities get tiny cells, oceans stay as one big cell — so every leaf holds a similar number of places. To search, descend from the root into the quadrants that intersect the search circle and test the points in those leaves.

map: dense area (top-left) split deeper root NWNESWSE NWNESWSE NWNESWSE rule: split a cell when it holds more than K points leaves = cells with ≤ K places dashed circle = "within 2 km"
A quadtree over a map (left) and its tree (right). The dense top-left quadrant is subdivided twice; sparse quadrants stay as single leaves. A radius query (dashed circle) only visits the few leaves it overlaps.

11.3 R-trees, S2 and H3

ApproachStorageStrengthsWeaknesses
GeohashString/int key in any B-tree or KV storeSimple, shardable, cacheable, prefix = cellEdge effects (need neighbours), fixed grid ignores density
QuadtreeUsually an in-memory tree in a serviceAdapts to density; good for "k nearest"Must be rebuilt/updated; harder to distribute
R-tree (PostGIS)Database indexArbitrary shapes, exact SQL queriesTied to one database node; heavier writes
S2 / H364-bit cell IDs in any storeUniform, hierarchical, great for aggregationMore complex libraries

12. Inverted indexes (for search)

B-trees find rows by a column's whole value or prefix. They can't answer "captions containing the word sunset" or "usernames similar to selna". For that, search engines build an inverted index: a map from each term to the list of documents containing it (a "posting list"), e.g. sunset → [post 17, post 942, post 10,331 …]. Text is first tokenised, lower-cased and stemmed; queries intersect posting lists and rank the results. Instagram's user, hashtag and place search runs on this kind of index in Elasticsearch-style clusters, fed asynchronously from Kafka. The full story — tokenisation, posting lists, TF-IDF/BM25 ranking, typeahead — is in Search & Inverted Indexes.

13. How Instagram uses indexes

Map every hot screen to a query, and every query to an index (or, in Cassandra, a primary-key design):

Screen / actionQueryIndex / key
Profile gridLatest 12 posts of user X, then "load more" before a cursor(user_id, created_at DESC) — or, since Instagram IDs are time-sortable, (user_id, id DESC). Partial on deleted_at IS NULL; optionally covering the thumbnail key.
Heart icon state in the feed"Has viewer V liked any of these 10 posts?"Likes primary key (post_id, user_id): 10 point lookups, each fully specified.
"Liked by …" listUsers who liked post P, newest first(post_id, user_id) PK for membership; add (post_id, created_at DESC) if order matters.
"Posts you've liked"Posts liked by user USecondary (user_id, created_at DESC) on likes.
Followers / followingWho follows X? Whom does X follow?PK (follower_id, followee_id) + reverse (followee_id, follower_id).
Login / sign-upFind user by username or emailUnique expression index on lower(username); unique index on email.
Comments under a postNewest comments on post P(post_id, created_at DESC) in SQL; in Cassandra, partition post_id, clustering comment_id DESC.
Location page / nearbyPlaces within 2 km; recent posts at place LGeospatial index (geohash/S2/PostGIS) on places; (location_id, created_at DESC) on posts.
Search boxAccounts/hashtags matching "sel…"Inverted index + prefix (edge n-gram) index in the search cluster.
📸 In Instagram

Rough numbers (assumptions)

Assume one PostgreSQL shard holds 50 M posts.
Index (user_id BIGINT, created_at TIMESTAMPTZ) ≈ 16 B key + ~8 B pointer + overhead ≈ 32 B/entry
  → 50 M × 32 B ≈ 1.6 GB  — fits in RAM next to the hot table pages
Height: 8 KB pages / 32 B ≈ 250 entries per page → 250³ ≈ 15.6 M, 250⁴ ≈ 3.9 B → 4 levels
Profile grid lookup: ~3 cached internal pages + 1 leaf page (+ 12 heap pages, or 0 if covering)
At 20,000 profile views/s on this shard: ~20,000–260,000 page touches/s, nearly all from RAM.

14. Trade-offs & common pitfalls

✅ What indexes buy you

  • Point lookups and ranges in O(log n) instead of O(n)
  • Pre-sorted results: ORDER BY … LIMIT without sorting
  • Uniqueness enforcement (usernames, one like per user per post)
  • Index-only scans that never touch the table
  • Fast joins (index nested-loop) and foreign-key checks

⚠️ What they cost

  • Every write updates every index — lower write throughput
  • Disk and RAM, competing with table data for cache
  • More WAL → more replication lag
  • Planner surprises when statistics are stale
  • LSM engines: compaction I/O, tombstones, read amplification

Common mistakes

15. Real-world technologies

TechnologyIndex / engine typeWhen to use it
PostgreSQLB-tree (default), Hash, GIN (inverted: arrays, JSONB, full-text), GiST/SP-GiST (spatial, PostGIS), BRIN (block-range for huge append-only tables)General OLTP with rich indexing needs; BRIN for time-ordered logs; GIN for tags/JSON.
MySQL InnoDBClustered B+tree on PK; secondary B+trees store the PKHigh-volume OLTP; choose a compact, increasing PK.
RocksDB / LevelDBEmbedded LSM key-value enginesBuilding block for databases (MyRocks, CockroachDB's Pebble is similar, Rocksandra, Kafka Streams state).
Cassandra / ScyllaDB / HBaseDistributed LSM; partition + clustering keyWrite-heavy, time-ordered data at scale.
MyRocksMySQL with RocksDB storageMeta built it to cut storage space and write amplification for MySQL at Facebook scale.
RedisIn-memory hash + sorted sets (skip lists) + geo commandsHot ordered indexes (timelines, leaderboards), geo radius queries.
Elasticsearch / OpenSearchInverted index (Lucene segments — LSM-like), BKD trees for numbers/geoFull-text, fuzzy, faceted and geo search.
PostGIS, S2, H3Spatial indexing / cell systemsMaps, "near me", geo aggregations.

16. Interview corner

Why are B+trees the default index in relational databases?

They're designed for block storage: each node is a page with hundreds of keys, so the tree is only 3–4 levels deep for billions of rows and lookups cost a handful of page reads (the upper levels are cached). Keys are sorted, so they support equality, ranges, prefix matches and ORDER BY without sorting; linked leaves make range scans sequential. They stay balanced automatically via page splits, so performance is predictable. Hash indexes only win for pure equality, which B-trees handle nearly as well.

Compare B-trees and LSM-trees. When would you choose each?

B-trees update fixed pages in place: one place per key, predictable fast reads, good for transactions and many secondary indexes, but random writes and page-granular write amplification. LSM-trees buffer writes in a memtable, flush sorted immutable SSTables sequentially and compact in the background: very high write throughput and good compression, but reads may check several files (mitigated by Bloom filters), compaction adds write amplification and can cause latency spikes, and deletes become tombstones. Choose B-trees for mixed OLTP (users, accounts); choose LSM for write-heavy append-mostly data keyed by partition (likes, feeds, messages, metrics) — like Instagram's PostgreSQL vs Cassandra split.

Design the indexes for an Instagram profile page with infinite scroll.

Query: WHERE user_id = ? AND deleted_at IS NULL AND id < :cursor ORDER BY id DESC LIMIT 12. Index: partial composite (user_id, id DESC) WHERE deleted_at IS NULL, optionally INCLUDE (thumb_key) for an index-only scan. Use keyset pagination with the last seen ID as the cursor (time-sortable IDs make this equivalent to time order), never OFFSET. The table is sharded by user_id, so this is a single-shard range read. Cache the first page in Redis for popular profiles.

What is the leftmost-prefix rule? Give an example where it bites.

A composite index on (a, b, c) is sorted by a, then b, then c, so it can be used for predicates on a; a and b; or a, b and c — a contiguous prefix from the left. It can't efficiently serve a predicate only on b or c. Example: index (user_id, created_at); an analytics query "all posts in the last hour" (WHERE created_at > …) can't use it and does a full scan. Fix with a separate (created_at) index — or, better at Instagram scale, run that query on the data warehouse rather than adding write cost to the OLTP table.

How do Bloom filters help LSM reads, and what's the trade-off?

Each SSTable keeps a Bloom filter of its keys in memory. Before reading a file, the engine checks the filter: "definitely not here" means skip the file with zero disk I/O. Only false positives (≈1% at 10 bits/key) cause wasted reads. So a point read touches about one SSTable instead of all of them. The trade-off is memory: about 1.2 bytes per key at 1% FP; lowering the FP rate costs more bits per key. Bloom filters don't help range scans, which must still merge across overlapping files.

A query got slow after a big data import even though the index exists. What do you check?

Run EXPLAIN ANALYZE and compare estimated vs actual rows. A large mismatch suggests stale statistics → run ANALYZE. Check whether the plan switched to a seq scan because the predicate became less selective, whether the query now has a cast/function preventing index use, whether the index is bloated or invalid (e.g. a failed concurrent build), and whether the working set no longer fits in memory so index lookups now hit disk.

How would you implement "find places near me" at scale?

Encode each place's coordinates as a geohash (or S2 cell ID) and store (cell_prefix, place_id) in an indexed table or KV store, sharded by cell. For a query, compute the user's cell at a precision roughly matching the radius (length 5–6 for a few km), fetch the cell plus its 8 neighbours, then filter candidates by exact haversine distance and rank by popularity. Dense areas can use longer prefixes or a quadtree in a dedicated service; cache popular cells. PostGIS with a GiST index works well at smaller scale.

17. Check your understanding

A B+tree has fan-out 300. Roughly how many levels are needed for 1 billion keys?

300² = 90,000; 300³ = 27,000,000; 300⁴ = 8.1 billion ≥ 1 billion → 4 levels. A lookup reads ~4 index pages, of which the top 2–3 are almost always cached.

Given an index on (post_id, user_id), which can it serve efficiently: (a) post_id = 9, (b) user_id = 42, (c) post_id = 9 AND user_id = 42, (d) post_id IN (1,2,3) AND user_id = 42?

(a), (c) and (d). (d) becomes three exact lookups. (b) skips the leftmost column, so it needs a separate index such as (user_id, post_id).

Why might the planner ignore an index on media_type for WHERE media_type = 'photo'?

Low selectivity: if ~70% of rows are photos, using the index means tens of millions of random row fetches, which is slower than one sequential scan. The planner correctly chooses the seq scan. Such a column is only useful inside a composite index or as a partial-index predicate on a rare value.

An LSM store has 12 SSTables and Bloom filters with a 1% false-positive rate. Roughly how many SSTables does a point read for a key that exists only in the oldest SSTable actually read from disk?

The 11 newer filters each have a 1% chance of a false positive → ~0.11 wasted reads on average, plus 1 real read of the oldest file: ≈ 1.1 SSTable reads instead of 12 (plus a memtable check in RAM).

The likes table has a PK and 4 secondary indexes and receives 50,000 inserts/s. How many index insertions per second is that, and what's a cheap way to reduce it?

5 indexes × 50,000 = 250,000 index insertions/s (plus WAL for each). Find indexes with near-zero idx_scan or serving only offline analytics and drop them; move analytics to the warehouse; merge redundant indexes that are prefixes of others.

What's the difference between a covering index and a clustered index?

A clustered index stores the full rows in its leaves in key order — the table is the index, and there's only one. A covering index is any (usually secondary) index that happens to contain all columns a given query needs, so that query can be answered by an index-only scan. Many covering indexes can exist; each is extra storage and write cost.

Why does OFFSET pagination get slower on page 1,000 of a profile, and what's the fix?

OFFSET 12000 LIMIT 12 still has to walk past 12,000 index entries (and possibly fetch their rows) before returning 12. Keyset pagination — WHERE user_id = ? AND id < :last_id ORDER BY id DESC LIMIT 12 — jumps straight to the cursor position in the B-tree, so every page costs the same.

🎯 Key takeaways

Related lessons