System Design · Database Internals

Database Indexing Explained: B-Trees, B+Trees, LSM Trees & Composite Indexes

What actually happens on disk when you run CREATE INDEX, why column order in a composite index decides everything, and how to read an EXPLAIN plan like an engineer who has debugged slow queries in production.

✍️ Pranjal Jain, Ex-Microsoft · IIT Kanpur 📅 10 October 2026 ⏱ 21 min read
Illustration of a B+tree index as a glowing tree of disk pages with linked leaf nodes
TL;DR
  • An index is a separate sorted data structure that lets the database find rows without scanning the whole table. Almost every relational database uses a B+tree: fat nodes (one disk page each), a depth of 3–4 levels for hundreds of millions of rows, and linked leaves for range scans.
  • Clustered index = the table rows live in the index leaves (InnoDB primary key). Secondary index = leaves store a pointer back to the row (a primary key in InnoDB, a tuple ID in Postgres).
  • A composite index (a, b, c) is sorted by a, then b, then c. It only helps queries that use a leftmost prefix. Rule of thumb: equality columns first, then the range/sort column.
  • A covering index answers the query from the index alone (Index Only Scan / "Using index"). It is often the single biggest win for a hot query.
  • LSM trees (RocksDB, Cassandra) trade read cost for much cheaper writes. Know the read/write/space amplification trade-off for interviews.

If you have ever fixed a slow API by adding one line of SQL, you have seen indexing at work: a query goes from 4 seconds to 3 milliseconds. This guide explains why that happens, from the page layout of a B+tree up to reading an EXPLAIN ANALYZE output. It is written for backend engineers preparing for system design and DBMS rounds, and for anyone who wants to stop guessing which index to create.

Why Indexes Exist: The Cost of a Full Table Scan

Databases store rows in fixed-size pages (8 KB in Postgres, 16 KB in MySQL InnoDB). Reading from disk or even from the OS cache happens a page at a time. Without an index, answering SELECT * FROM orders WHERE order_id = 98123 means reading every page of the table and checking every row. That is a sequential scan, and its cost grows linearly with table size.

Let us put numbers on it. Take an orders table with 100 million rows of about 200 bytes each:

  • Table size ≈ 100M × 200 B = 20 GB, which is about 2.5 million 8 KB pages.
  • A full scan reads all 2.5M pages. Even at 1 GB/s of sequential throughput, that is ~20 seconds, and it evicts useful pages from the buffer cache.
  • With a B+tree index on order_id, the lookup reads ~4 pages (root, two internal levels, leaf) plus one heap page. With the upper levels cached in memory, that is usually one or two actual I/Os: well under a millisecond on SSD.

That gap, O(n) versus O(log n) with a huge logarithm base, is the entire reason indexes exist. The price you pay is extra storage and slower writes, because every INSERT, UPDATE of an indexed column and DELETE must also update every index on the table.

~2.5M pages
Full scan of a 20 GB table
3–4 levels
B+tree depth for 100M+ rows
O(logF n)
Lookup cost, F = fan-out (hundreds)

How a B+Tree Index Works (and How It Differs from a B-Tree)

A binary search tree has a fan-out of 2, so 100 million keys would need about 27 levels. Twenty-seven random disk reads per lookup is unacceptable. A B-tree fixes this by making every node a whole disk page holding hundreds of keys, so each node has hundreds of children. The tree becomes short and wide.

The B+tree structure

Almost every relational engine actually uses the B+tree variant, which has two defining properties:

  1. Internal nodes only route. They hold separator keys and child pointers, never row data. That keeps them small, which maximises fan-out.
  2. All entries live in the leaves, and leaves are linked to their neighbours (doubly linked in InnoDB and Postgres). A range query finds the first leaf and then walks sideways.
[ 500 | 1000 ] <- root (in memory) / | \ [ 100|250|400 ] [ 600|800 ] [ 1200|1500 ] <- internal pages / | | \ ... ... [1..99]<->[100..249]<->[250..399]<->[400..499]<-> ... <- linked leaves | | row ptrs row ptrs (or full rows, if this is a clustered index)

In a classic B-tree, by contrast, a key and its data can sit in an internal node. A lookup might finish higher up, which sounds good, but internal nodes become bigger (lower fan-out) and range scans need an in-order traversal that bounces up and down the tree. In practice the B+tree's predictable leaf-level access and cheap range scans win.

AspectB-treeB+tree
Where data livesInternal and leaf nodesLeaves only
Internal node sizeLarger (keys + data/pointers)Smaller (keys + child pointers)
Fan-outLowerHigher, so a shallower tree
Range scanIn-order traversal across levelsFind first leaf, walk the linked list
Point lookupMay end early at an internal nodeAlways reaches a leaf (predictable)
Used bySome older/embedded systemsPostgres, MySQL InnoDB, Oracle, SQL Server, SQLite tables

Fan-out math: why the tree is only 3–4 levels deep

Suppose the key is an 8-byte BIGINT and a child pointer is about 6–8 bytes. With per-entry overhead, an 8 KB internal page holds roughly 400–500 entries. Use 400 to be conservative:

  • Level 1 (root): 1 page, 400 children
  • Level 2: 400 pages, 160,000 children
  • Level 3: 160,000 pages, 64 million leaf pointers
  • Level 4: enough for ~25 billion entries

So a 100M-row index is 4 levels deep. The root and level 2 total ~401 pages (~3 MB), which stay hot in memory. Real lookups hit disk at most for the bottom one or two levels. This is why "an index lookup is O(log n)" undersells it: with a base of 400, log n is tiny.

A practical consequence: wide keys hurt. Index a 255-character VARCHAR email and fan-out drops sharply, the tree grows deeper and fewer pages fit in cache. This is also why InnoDB advice says to keep primary keys small: every secondary index stores the primary key in its leaves.

Inserts, page splits and why random UUID keys hurt

Inserting a key means finding the correct leaf and adding the entry. If the leaf is full, it splits into two half-full pages and a new separator is pushed into the parent, which can cascade up to the root (the only way a B+tree grows taller). Deletes can merge underfilled pages, though many engines delay this.

With a monotonically increasing key (auto-increment ID, timestamp, ULID, UUIDv7), inserts always land in the rightmost leaf. Pages fill up neatly and the working set of "hot" pages is tiny. With random UUIDv4 keys, every insert lands on a random leaf. Across a large index that means random I/O, frequent splits leaving pages around half full, and a buffer pool that cannot hold the whole hot set. This is a classic reason write throughput collapses as a MySQL table grows past memory, and why time-ordered IDs are recommended (see our Snowflake ID generator design).

Clustered vs Secondary (Non-Clustered) Indexes

A clustered index is one where the table data is physically stored in index order: the leaves of the index are the rows. A secondary index is a separate B+tree whose leaves store the indexed key plus a pointer to the row. How that pointer works differs between the two most common engines, and interviewers love this difference.

MySQL InnoDB: the table is the primary key index

InnoDB always clusters by primary key. If you do not define one, it uses the first non-null unique index, or else a hidden 6-byte row ID. Secondary index leaves store (indexed columns, primary key). A lookup through a secondary index therefore does two tree traversals: one in the secondary index to find the primary key, then one in the clustered index to fetch the row. This is sometimes called a "bookmark lookup" or a lookup "back to the table".

PostgreSQL: heap table plus indexes

Postgres stores rows in an unordered heap. Every index, including the primary key index, is secondary: leaves store (key, TID), where the TID is a physical (page, offset) address. A lookup is one tree traversal plus one heap page fetch. CLUSTER can physically reorder a table once, but the order is not maintained afterwards.

MySQL InnoDBPostgreSQL
Table storageClustered B+tree on primary keyUnordered heap
Secondary index leafKey + primary key valueKey + physical tuple ID
Secondary lookup costTwo B+tree traversalsOne traversal + heap fetch
Primary key range scanVery fast (rows adjacent)Rows may be scattered in heap
Updating a rowSecondaries unaffected unless their columns changeNew tuple version; indexes updated unless HOT update applies
Big primary key costCopied into every secondary indexOnly in its own index
💡
Interview one-liner"In InnoDB the primary key is the clustered index, so choose a short, monotonically increasing key: it is copied into every secondary index and random keys cause page splits. In Postgres every index points to the heap by tuple ID, so updates may need index maintenance unless they qualify as HOT updates."

Composite Indexes and Why Column Order Matters

A composite (multi-column) index on (a, b, c) is a single B+tree whose entries are sorted by a first, ties broken by b, then by c. Think of a phone book sorted by (last name, first name): great for "Sharma, Priya", useless for "everyone named Priya".

The leftmost prefix rule

Given CREATE INDEX idx ON orders (customer_id, status, created_at):

Query predicateUses the index well?Why
customer_id = 42YesLeftmost column
customer_id = 42 AND status = 'PAID'YesPrefix (a, b)
customer_id = 42 AND status = 'PAID' AND created_at > '2026-01-01'Yes, best caseTwo equalities then a range on the last column
customer_id = 42 AND created_at > '2026-01-01'PartlySeeks on customer_id; created_at is checked within that slice but cannot narrow the seek because status sits between
status = 'PAID'No (usually)Not a leftmost prefix. Postgres may still full-scan the index; MySQL 8 may use skip scan if customer_id has few distinct values
customer_id > 40 AND status = 'PAID'PartlyRange on the first column stops further seeking; status is filtered row by row

Equality first, range last (the ESR rule)

A practical ordering heuristic (MongoDB documents it as the Equality, Sort, Range rule, and it applies equally to SQL):

  1. Equality columns first. Columns compared with = or a small IN list. Every equality narrows the seek to a contiguous slice of the index.
  2. Then the sort column. If the remaining slice is already sorted by your ORDER BY column, the database avoids a sort step and can stop early with LIMIT.
  3. Range columns last. Once the index hits a range predicate, columns after it can no longer be used for seeking, only filtering.

Worked example: the "my orders" page

A food delivery app shows a customer's recent orders:

SELECT order_id, restaurant_id, total_amount, created_at
FROM orders
WHERE customer_id = 42
  AND status IN ('DELIVERED', 'CANCELLED')
ORDER BY created_at DESC
LIMIT 20;

Options and what happens:

  • Index on (created_at): the database walks recent orders across all customers and filters for customer 42. If customer 42 last ordered months ago, it scans millions of entries. Bad.
  • Index on (customer_id): finds all of customer 42's orders (maybe 300), fetches each row, filters status, sorts and returns 20. Acceptable, but 300 random heap fetches plus a sort.
  • Index on (customer_id, created_at): seeks to customer 42, reads entries already ordered by created_at, walks backwards, fetches rows and checks status, stopping after 20 matches. Usually excellent.
  • Index on (customer_id, status, created_at): with an IN list, the database seeks once per status and must merge the two sorted streams. Postgres often does this fine; some planners fall back to a sort. Measure with EXPLAIN.

Usually the winner is (customer_id, created_at), possibly with the selected columns added for a covering index (next section). Notice the reasoning: start from the query's filter and sort shape, not from "which columns appear in WHERE".

⚠️
Selectivity is not the whole storyThe old advice "put the most selective column first" is incomplete. Column order must first satisfy the query shape (equalities, then sort, then range). Selectivity only breaks ties between equality columns, and even then the difference is often small because the database seeks on all equality columns together.

Covering Indexes and Index-Only Scans

Every secondary index lookup has two costs: walking the index, and fetching the row from the table (heap fetch in Postgres, clustered index lookup in InnoDB). For queries that return many rows, the second part dominates because those fetches are random I/O. A covering index removes it by including every column the query reads.

-- PostgreSQL 11+: INCLUDE adds payload columns to the leaves only
CREATE INDEX idx_orders_cust_created
  ON orders (customer_id, created_at DESC)
  INCLUDE (order_id, restaurant_id, total_amount, status);

-- MySQL: no INCLUDE clause, so append the columns as key columns.
-- order_id is the primary key, so InnoDB stores it in the leaf anyway.
CREATE INDEX idx_orders_cust_created
  ON orders (customer_id, created_at, status, restaurant_id, total_amount);

In EXPLAIN, Postgres reports Index Only Scan and MySQL shows Using index in the Extra column. In Postgres there is one subtlety: because of MVCC, the index does not know whether a tuple is visible to your transaction. Postgres checks the visibility map; for pages marked all-visible it skips the heap, otherwise it still visits the heap. The Heap Fetches number in EXPLAIN ANALYZE shows how often that happened. A recently vacuumed table gets the full benefit.

Covering indexes are not free: they are bigger, slower to maintain, and duplicate data. Use them for a small number of very hot, read-heavy queries, not as a default.

How to Read EXPLAIN and EXPLAIN ANALYZE

EXPLAIN shows the plan the optimizer chose and its estimates. EXPLAIN ANALYZE actually runs the query and shows real times and row counts. (Careful: EXPLAIN ANALYZE on an UPDATE or DELETE really modifies data, so wrap it in a transaction and roll back.)

A Postgres example, before and after

EXPLAIN ANALYZE
SELECT order_id, total_amount FROM orders
WHERE customer_id = 42 ORDER BY created_at DESC LIMIT 20;

-- Before (no suitable index)
Limit  (cost=2189134.21..2189134.26 rows=20 width=16)
       (actual time=4210.552..4210.560 rows=20 loops=1)
  ->  Sort  (cost=2189134.21..2189135.01 rows=318 width=16)
            (actual time=4210.550..4210.553 rows=20 loops=1)
        Sort Key: created_at DESC
        ->  Seq Scan on orders  (cost=0.00..2189125.75 rows=318 width=16)
                                (actual time=12.1..4209.8 rows=301 loops=1)
              Filter: (customer_id = 42)
              Rows Removed by Filter: 99999699
Execution Time: 4210.6 ms

-- After CREATE INDEX ON orders (customer_id, created_at DESC) INCLUDE (order_id, total_amount)
Limit  (cost=0.57..9.21 rows=20 width=16) (actual time=0.031..0.058 rows=20 loops=1)
  ->  Index Only Scan using idx_orders_cust_created on orders
        (cost=0.57..138.02 rows=318 width=16) (actual time=0.030..0.054 rows=20 loops=1)
        Index Cond: (customer_id = 42)
        Heap Fetches: 0
Execution Time: 0.081 ms

(The numbers are illustrative of the shape you will see, not a benchmark.)

What to look at, in order

  1. Node types. Seq Scan reads the whole table. Index Scan walks the index and fetches rows. Index Only Scan avoids the table. Bitmap Index Scan + Bitmap Heap Scan collects matching TIDs, sorts them by page, then reads pages in physical order; the planner picks it for medium selectivity.
  2. Estimated vs actual rows. rows=318 estimated vs rows=301 actual is healthy. If the estimate is 10 and the actual is 2 million, statistics are stale or the predicate is correlated in ways the planner cannot see. Run ANALYZE, raise the statistics target, or create extended statistics.
  3. Rows Removed by Filter. A big number means the database read many rows only to throw them away. That is the classic sign of a missing or wrongly ordered index.
  4. Sort nodes. A Sort above a scan with a LIMIT means the index does not provide the order. Look at Sort Method: external merge Disk, which means the sort spilled to disk.
  5. Loops. In nested loop joins, the inner node's time is per loop. Multiply by loops to get the real cost.

MySQL EXPLAIN in brief

In MySQL the key columns are type (from best to worst: const, eq_ref, ref, range, index, ALL), key (the index chosen), rows (estimated rows examined) and Extra. Watch for Using filesort (an extra sort step), Using temporary (a temp table, often from GROUP BY) and Using index (covering). MySQL 8.0.18+ supports EXPLAIN ANALYZE with actual timings, and EXPLAIN FORMAT=TREE gives a Postgres-like tree.

Why Your Index Is Not Being Used: 8 Common Causes

"I created an index but the query is still slow" is one of the most common production issues, and a favourite interview follow-up. The usual causes:

  1. A function on the column. WHERE DATE(created_at) = '2026-10-01' or WHERE LOWER(email) = 'a@b.com' cannot use a plain index on that column. Rewrite as a range (created_at >= '2026-10-01' AND created_at < '2026-10-02') or create an expression index: CREATE INDEX ON users (LOWER(email)).
  2. Implicit type conversion. Comparing a VARCHAR phone column with a number (WHERE phone = 9876543210) makes MySQL convert every row's value, which kills the index. Quote the literal.
  3. Leading wildcard. LIKE '%sharma' cannot seek in a B+tree. LIKE 'sharma%' can (in Postgres, with the C collation or a text_pattern_ops index). For substring search use a trigram GIN index or a search engine.
  4. Not a leftmost prefix. Filtering on the second column of a composite index only.
  5. Low selectivity. If status = 'ACTIVE' matches 70% of rows, a sequential scan is genuinely cheaper than millions of random fetches. The optimizer is right to ignore the index. A partial index on the rare value (WHERE status = 'FAILED') is the fix if you only query the rare case.
  6. OR across different columns. WHERE a = 1 OR b = 2 may need two indexes combined with a bitmap OR, or a UNION rewrite.
  7. Stale statistics. After a bulk load, the planner may think a table has 1,000 rows. Run ANALYZE.
  8. Small tables. For a table that fits in a few pages, a scan is the fastest plan. Not a problem to fix.

LSM Trees vs B-Trees: The Write-Optimised Alternative

B+trees update data in place: changing a row rewrites the page it lives on. For write-heavy workloads such as logs, metrics, chat messages and IoT events, that random I/O becomes the bottleneck. The Log-Structured Merge tree (LSM tree) takes the opposite approach: never update in place, always append.

How an LSM tree works

  1. Write-ahead log. Each write is appended to a WAL on disk for durability (sequential I/O).
  2. Memtable. The write goes into an in-memory sorted structure (often a skip list).
  3. Flush. When the memtable reaches a size limit (tens of MB), it is written out as an immutable sorted file called an SSTable. Again sequential.
  4. Compaction. Background jobs merge SSTables, discarding overwritten values and deleted keys (tombstones). Strategies are leveled (RocksDB default; lower space and read amplification, more write amplification) or size-tiered (Cassandra's traditional default; cheaper writes, more space).
  5. Reads. Check the memtable, then SSTables from newest to oldest. Each SSTable has a Bloom filter to skip files that definitely do not contain the key, plus a sparse index to find the right block.
write ──► WAL (append) + Memtable (sorted, in RAM) │ flush when full ▼ L0: [SST] [SST] [SST] (newest, may overlap) │ compaction (merge-sort) L1: [SST][SST][SST][SST] (non-overlapping key ranges) L2: [SST][SST][SST] ... x10 (each level ~10x larger) read ──► memtable ──► L0 files ──► L1 ──► L2 ... (Bloom filter per SSTable skips most files)

Bloom filters are what make LSM point reads tolerable; we cover the math in Bloom filters explained.

The three amplifications

MetricB+treeLSM tree
Write amplification (bytes written to disk per byte of user data)Rewrites a whole page for a small change; plus WALData rewritten during each compaction, but always sequentially. Leveled can be 10–30x; tiered much less
Read amplification (I/Os per lookup)Low and predictable: one path down the treeMay check several levels; Bloom filters keep this low for point reads, but range scans must merge across levels
Space amplificationPages often partly empty after splitsStale versions wait for compaction; good compression since SSTables are immutable
Write patternRandom in-place updatesSequential appends
Latency profileSteadyCan spike when compaction falls behind
ExamplesPostgres, MySQL InnoDB, Oracle, SQL ServerRocksDB, LevelDB, Cassandra, ScyllaDB, HBase, MyRocks

In a system design interview, the right framing is: "This workload is ~95% writes of append-only events with reads by key and recent time window, so an LSM-based store like Cassandra fits. If the workload were read-heavy with complex secondary-index queries and transactions, I'd pick Postgres." See how this plays out in designing a key-value store.

Hash, GIN, GiST, BRIN, Partial and Expression Indexes

B+trees are the default, but not the only option. Knowing when to reach for something else is a strong signal in interviews.

Index typeGood forExample
HashEquality only, no ranges or sortingPostgres USING hash; MySQL MEMORY engine; InnoDB's adaptive hash index is internal
GIN (inverted)Values containing many elements: arrays, JSONB keys, full-text tokens, trigramsCREATE INDEX ON products USING gin (tags); WHERE tags @> '{veg}'
GiST / SP-GiSTGeometric and range types, nearest-neighbourPostGIS location queries; overlapping booking ranges
BRINHuge append-only tables where values correlate with physical orderA 2 TB events table indexed by timestamp; index is kilobytes instead of gigabytes
PartialQuerying a small subset of rowsCREATE INDEX ON payments (created_at) WHERE status = 'PENDING'
ExpressionQueries on a computed valueCREATE INDEX ON users (LOWER(email))
UniqueEnforcing constraints, plus fast lookupsOne account per phone number
Inverted index (search engines)Relevance-ranked text searchElasticsearch, covered in our Elasticsearch guide

Vector similarity indexes (HNSW, IVF) are a separate family for embeddings; they trade exactness for speed in high-dimensional nearest-neighbour search.

Indexing checklist for a new table

  • List the top 5 queries by frequency and latency requirement. Index for those, not for every column.
  • For each, write the filter/sort shape and derive the composite order with the ESR rule.
  • Look for one index that serves multiple queries via a shared prefix.
  • Consider covering only for the hottest one or two.
  • Index foreign keys used in joins (Postgres does not create them automatically).
  • Build indexes on big live tables with CREATE INDEX CONCURRENTLY (Postgres) or online DDL (MySQL) to avoid blocking writes.
  • After a few weeks, drop indexes with zero scans in pg_stat_user_indexes or sys.schema_unused_indexes.

How Indexing Shows Up in Interviews

Indexing appears in three kinds of rounds: DBMS theory questions (common in campus and fresher interviews, see our DBMS interview questions), SQL/backend rounds ("this query is slow, fix it"), and system design rounds, where you are expected to name the access patterns and the indexes that serve them.

Questions you should be ready for

  • Why B+tree and not a binary search tree or hash table? Disk is page-oriented; high fan-out minimises I/O. Hash tables cannot do ranges or ordered scans.
  • Why not index every column? Write amplification, storage, buffer pool pressure, and the optimizer still uses only one or a few indexes per table access.
  • Given this query, design the index. Walk through equality, sort, range. Mention covering if it is hot.
  • What happens to indexes on UPDATE? InnoDB: secondary indexes change only if their columns change. Postgres: a new tuple version is created; indexes need new entries unless it is a HOT update (no indexed column changed and space on the same page).
  • How would you add an index to a 500 GB table with zero downtime? Concurrent/online index build, or tools like gh-ost/pt-online-schema-change for MySQL schema changes; monitor replication lag.
  • When would you choose an LSM-based database? Write-heavy, append-mostly, key-based reads; accept compaction overhead.

Red flags interviewers notice

  • Saying "add an index on each column in the WHERE clause" without thinking about composite order.
  • Claiming indexes make every query faster (they slow writes, and low-selectivity predicates do not benefit).
  • Confusing clustered index with "primary key" in Postgres, where there is no maintained clustering.
  • Not mentioning that sharded systems need indexes that align with the shard key, or else become scatter-gather (see database sharding strategies).
✅
How to present it in a design roundAfter your data model, add one line per table: "orders: PK order_id (time-ordered), index (customer_id, created_at DESC) for order history, partial index on status = 'PENDING' for the reconciliation job." That takes 30 seconds and shows you design for access patterns, which is exactly what interviewers are checking.

Frequently Asked Questions

What is the difference between a B-tree and a B+tree index?▾
In a classic B-tree, keys and their data pointers can live in any node, internal or leaf. In a B+tree, internal nodes hold only separator keys for routing and all data entries live in the leaves, which are linked together. That makes internal nodes smaller (higher fan-out, shallower tree) and makes range scans a simple walk along the leaf chain, which is why almost every relational database uses B+trees.
Does the order of columns in a composite index matter?▾
Yes, a lot. A composite index on (a, b, c) is sorted by a, then b within a, then c within b. It can serve queries that filter on a, on a and b, or on a, b and c, but it cannot efficiently serve a query filtering only on b or c. Put equality-filtered columns first and the range or sort column after them.
What is a covering index?▾
A covering index contains every column a query needs, so the database can answer the query from the index alone without visiting the table. Postgres shows this as an Index Only Scan and MySQL shows Using index in EXPLAIN. You can build one with extra key columns or with INCLUDE columns in Postgres and SQL Server.
Why is my index not being used?▾
Common reasons are a function or cast applied to the indexed column, a leading wildcard LIKE pattern, filtering on a non-leading column of a composite index, low selectivity where a sequential scan is genuinely cheaper, stale statistics, or an implicit type conversion. Run EXPLAIN ANALYZE to see what the planner chose and why the estimated rows differ from actual rows.
Are LSM trees better than B-trees?▾
Neither is better in general. LSM trees turn random writes into sequential appends, so they excel at write-heavy workloads like time series, logs and messaging, and they compress well. B+trees give more predictable read latency and simpler range reads. RocksDB, Cassandra and ScyllaDB use LSM trees; Postgres, MySQL InnoDB and Oracle use B+trees.
How many indexes are too many on a table?▾
There is no fixed number. Every index slows down inserts, updates and deletes, consumes memory in the buffer pool and must be maintained by vacuum or purge. Keep indexes that serve real, frequent query patterns, drop unused ones (Postgres pg_stat_user_indexes shows idx_scan counts), and prefer one well-ordered composite index over several single-column ones.
What is the difference between clustered and non-clustered indexes?▾
A clustered index determines the physical order of the table rows, so the table data itself lives in the index leaves. A table can have only one. A non-clustered or secondary index is a separate structure whose leaves point back to the row, either by primary key (MySQL InnoDB) or by a physical tuple address (Postgres).

Go Deep on Databases for System Design

Prepflix's system design track walks through storage engines, indexing and query tuning with real interview problems and mock feedback.

Explore the Prepflix Course →
Pranjal Jain
Pranjal Jain

Ex-Microsoft SDE · IIT Kanpur · Founder of Prepflix. Helps engineers crack startup and product company interviews across India.