System design / Databases

Database Indexes in Practice: Clustered vs Secondary, Composite, Covering, and Why Your Index Is Ignored

How real database indexes work in MySQL InnoDB and PostgreSQL: clustered vs heap storage, UUID vs time-ordered primary keys, composite index column order, covering indexes and index-only scans, why the planner ignores an index, and what every index costs on writes, with an interview talk track.

On this page
  1. What an index is, in one picture
  2. Clustered vs secondary: where does the row actually live?
  3. Choosing a primary key: sequential, random or time-ordered
  4. Composite indexes: column order is the whole game
  5. Covering indexes and index-only scans
  6. Why your index is ignored
  7. What every index costs
  8. Beyond plain B-trees, briefly
  9. How to say it in the interview

One composite index can turn a query that reads a whole table into one that reads a handful of pages. Ten careless indexes can make every insert in your app quietly slower, eat memory you needed for hot data, and still not help the query that is actually slow. This blog is about telling those two situations apart, and about the reasons a perfectly good index gets ignored.

Where this fits: Part of the Databases under the hood series. Part 1 gave you the interview overview of indexes; here we open them up and design real ones for a real table.

Before you start: Helpful, not required: Storage data structures (the B-tree up close) and Database reads and writes (buffer pool, WAL).

After reading this you will be able to

  • Explain how MySQL InnoDB (clustered) and PostgreSQL (heap) store rows, and what a secondary index lookup costs in each.
  • Pick a primary key type and explain why random UUIDv4 keys hurt a B+tree while UUIDv7 or Snowflake-style IDs don't.
  • Order the columns of a composite index so it serves a filter, a sort and a LIMIT in one go.
  • Name the common reasons the planner ignores an index, and fix each one.
  • Say what every extra index costs on writes, and design indexes for three access patterns in an interview.

For this blog, we'll stay on one machine and one table. We'll use an invented food-delivery app called Tiffin and its busiest table:

-- PostgreSQL syntax; MySQL is nearly identical
CREATE TABLE orders (
  id            bigint PRIMARY KEY,
  user_id       bigint      NOT NULL,
  restaurant_id bigint      NOT NULL,
  status        text        NOT NULL,  -- placed, preparing, out_for_delivery, delivered, cancelled
  created_at    timestamptz NOT NULL,
  total         numeric(10,2) NOT NULL
);

Three screens hit it all day: a customer's "My orders" page, a restaurant's kitchen screen of live orders, and a finance report over date ranges. We'll come back to all three at the end and design indexes for them. Sharding this table across machines is a different story, told in Sharding.

01What an index is, in one picture

Index Definition

A separate, sorted copy of one or more columns, with a pointer from each entry back to its row. The database keeps it in sync on every write so that reads can jump to the right rows instead of scanning the table.

Think of the index at the back of a cookbook. If you want every recipe that uses paneer, you don't read 400 pages. You flip to "P", find "paneer: 34, 112, 287", and go to those pages. The PostgreSQL docs use the same analogy, and add the part people forget: it's the author's job to guess which terms readers will look up. In a database, that author is you.

Almost every index you'll create is a B+tree: a shallow tree of fixed-size pages where the leaves hold the sorted keys and are linked left to right, so a range read is "find the start, then walk sideways". I won't re-derive it here; Storage data structures walks through it properly. Two numbers are worth carrying around:

  • Page size. PostgreSQL stores tables and indexes in pages that are usually 8 kB. InnoDB's B+tree nodes are 16 KB by default. The database reads whole pages, never single rows.
  • Fanout. PlanetScale's back-of-envelope maths: with 16 KB nodes and 8-byte keys, values and pointers, one node holds about 682 entries, so a three-level tree can address over 317 million of them. That's why a lookup in a huge table still touches only three or four pages.

Hold on to that last point. An index lookup is cheap because it reads few pages; everything that makes it read more pages makes it slower. Most of this blog is that sentence applied over and over.

02Clustered vs secondary: where does the row actually live?

Here's the question that separates "I've used indexes" from "I know how they work": when the index finds your key, where is the rest of the row? MySQL InnoDB and PostgreSQL give opposite answers.

Clustered index Definition

An index whose leaf pages hold the full rows, so the table is the index, stored in key order. A table can have only one. Every other index is a secondary index that points back to it.

MySQL InnoDB: the table is the primary key tree

InnoDB stores all table data in a B+tree keyed by the primary key, with the other columns in the leaves. A secondary index is another B+tree whose values are the primary key, not a disk address. So WHERE user_id = 42 walks the user_id index to get some order IDs, then walks the primary key tree once per ID to fetch each row.

Why store a key instead of an address? Because rows in a clustered tree move whenever pages split. A logical key survives that; a physical address wouldn't. Use The Index, Luke puts the trade-off bluntly: reads through a secondary index on a clustered table pay for two tree walks, and the clustering key copied into every secondary entry makes those indexes bigger.

PostgreSQL: a heap plus pointers

Postgres keeps rows in an unordered heap. In the docs' own words, all indexes in PostgreSQL are secondary indexes, including the primary key's. Each entry points at a tuple location called a TID (or ctid): a page number plus a slot on that page. One hop, no second tree.

The diagram below shows the same lookup on both engines.

Fig. 1

InnoDB walks two trees; Postgres follows a pointer into the heap

InnoDB walks two trees; Postgres follows a pointer into the heapLeft: MySQL InnoDB. A lookup on the user_id secondary index finds the primary key 1007, then walks the clustered primary key tree to the leaf page that holds the whole row. Right: PostgreSQL. The user_id index entry holds a tuple location (page 731, item 4) and goes straight to that heap page. The primary key index points at the same heap location.MySQL InnoDB: rows live in the PK treePostgreSQL: rows live in a heapid 10072nd walkTID (731,4)Secondary indexuser_id → idPrimary key treewalk again by idPK leaf pagethe whole rowIndex on user_iduser_id → (page, item)Primary key indexid → (page, item)Heap page 731row at item 4Same query on both: WHERE user_id = 42Two tree walks per row foundOne hop from any index to the rowInnoDB: row moves are cheap, lookups pay twice. Postgres: one hop, but new row versions need new index entries (unless HOT).
What to notice. InnoDB's secondary index hands you a primary key and you walk a second tree. Postgres hands you a page and slot and you go straight there. Neither is free: the cost moves to different places.

So where does Postgres pay? On updates. Postgres uses MVCC, keeping old row versions around for readers that still need them, so an update writes a new row version at a new TID, and indexes point at TIDs. Uber's Postgres to MySQL write-up (written against Postgres 9.2) made this famous: on a table with a dozen indexes, an update to a column covered by just one index still had to be propagated into all 12, and every one of those writes also went into the WAL and over the replication link.

Postgres has a mitigation called HOT (heap-only tuples). If an update changes no indexed column and the new version fits on the same page, no new index entries are needed. That gives you a practical rule: every column you add to an index is a column whose updates can no longer be HOT.

The 30-second answer

"InnoDB clusters rows by primary key, so secondary indexes store the PK and a lookup walks two trees. Postgres keeps rows in a heap and every index points at a physical tuple, so reads are one hop but a non-HOT update has to touch every index."

03Choosing a primary key: sequential, random or time-ordered

On InnoDB the primary key decides the physical order of the whole table. On Postgres it's still a B-tree that receives every insert. Either way, the shape of your keys matters. You have three realistic choices.

Key typeSizeInsert patternGood for
BIGINT auto-increment / sequence8 bytesAlways the right-most leafOne database that hands out IDs
UUIDv4 (random)16 bytes as binary, 36 chars as textAnywhere in the treeIDs generated anywhere, nothing to coordinate
UUIDv7, Snowflake-style16 bytes / 8 bytesNear the right edge, roughly time-orderedBoth: generated anywhere, inserted in order

The next figure shows why the insert pattern matters more than the size.

Fig. 2

Sequential keys touch one hot page; random keys touch every page

Sequential keys touch one hot page; random keys touch every pageLeft: with sequential keys (auto-increment, UUIDv7, Snowflake-style) every new row lands on the right-most leaf page; older pages are full and can leave memory. Right: with random UUIDv4 keys, new rows land on any leaf page, full pages split into two half-empty pages, and every page has to stay in memory to keep inserts fast.Sequential keys: auto-increment, UUIDv7Random keys: UUIDv4LeaffullLeaffullLeaffullLeaffillingLeafhalf fullLeafsplit!Leafhalf fullLeafhalf fullnew rows: 1001, 1002, 1003 ...Only the right edge is hotPages fill to about 94% (InnoDB)new rows: 9f3c.., 1a7e.., c41b..Every page is hot, so all must stay cachedPages can drop to about 50% fullFill figures from PlanetScale on InnoDB. The pattern holds for any B+tree keyed by a random value.
What to notice. With sequential keys only one leaf page is hot and full pages can leave memory. With random keys, any page can be next, so the whole index has to stay cached, and pages split into half-empty halves.

Random keys hurt in three ways:

  • Every page is hot. PlanetScale's B-tree visualisation shows random inserts visiting unpredictable paths, while sequential inserts revisit the same right-most path. Once the index is bigger than your buffer pool, random inserts start turning into disk reads.
  • Pages stay half empty. InnoDB fills pages to about 94% when keys arrive in order, and as low as 50% when they're random. Same rows, up to roughly twice the pages (our arithmetic).
  • Bigger keys everywhere. A UUID is 16 bytes against a BIGINT's 8, and in InnoDB every secondary index entry carries a copy of the primary key. Store it as CHAR(36) and it's 288 bits per value.

Time-ordered IDs keep the "generate anywhere" property and fix the insert pattern. UUIDv7 puts a Unix timestamp in its leading bits, and PostgreSQL 18 ships a built-in uuidv7() function. Instagram's Snowflake-style IDs pack 41 bits of milliseconds, 13 bits of shard ID and 10 bits of sequence into a 64-bit integer, so they sort by time and fit in a bigint.

Many candidates say "use a UUID" and move on

That's half an answer. Say which UUID. A random v4 as the clustered key on InnoDB scatters inserts across the whole table. A v7 or a Snowflake-style ID gives you the same coordination-free generation with inserts that land at the right edge. My default for Tiffin: bigint Snowflake-style IDs, because they're half the size of a UUID and still time-ordered.

04Composite indexes: column order is the whole game

Composite index Definition

One B-tree over several columns, sorted by the first column, then by the second within equal first values, and so on. Also called a multi-column or concatenated index.

A phone directory is the classic picture: sorted by surname, then first name. Finding "Sharma, Priya" is instant. Finding every Priya means reading the whole book. Use The Index, Luke builds its whole chapter on this, and it gives us the first rule.

Rule 1: the leftmost prefix

An index on (restaurant_id, status, created_at) can seek on restaurant_id, on restaurant_id + status, or on all three. A query that filters only on status can't seek into it, because matching entries are scattered under every restaurant. PlanetScale's MySQL write-up shows the index not even appearing in possible_keys for that case.

One modern footnote: PostgreSQL 18 added B-tree skip scan, which can use a later column when the leading column has very few distinct values. It's a nice fallback, not a design strategy. Design for the leftmost prefix.

Rule 2: equality first, then range

The Postgres docs state the exact rule: equality conditions on the leading columns, plus an inequality on the first column without an equality, limit how much of the index is scanned. Anything to the right of the range column is only checked while scanning.

Take the kitchen screen: WHERE restaurant_id = 7 AND created_at > now() - interval '2 hours'. With (restaurant_id, created_at), the database seeks to restaurant 7 at the two-hour mark and reads forward. With (created_at, restaurant_id), it reads every restaurant's orders from the last two hours and throws most away. Use The Index, Luke uses this exact shape to bust the myth that the most selective column should always go first. What matters is which columns use = and which use a range.

Rule 3: let the index do the sort

A B-tree already holds entries in order. If the index order matches your ORDER BY after the equality columns, the database reads rows in order and stops early. The Postgres docs call out ORDER BY ... LIMIT n as the important case: without a matching index the database must process every matching row to find the top n; with one, it reads n entries and stops. Use The Index, Luke calls this a pipelined top-N query: its response time grows with the rows you ask for, not with the table.

Here is the kitchen query against two different column orders.

Fig. 3

Column order decides whether matches sit together

Column order decides whether matches sit togetherThe kitchen query asks for restaurant 7 orders with status preparing, ordered by time. Left: an index on (restaurant_id, status, created_at). The three matching entries sit next to each other and are already in time order, so the database seeks once and reads them. Right: an index on (created_at, restaurant_id). The matches are spread across the time range and the index has no status, so the database walks the index in time order and fetches each restaurant 7 row to check its status.(restaurant_id, status, created_at)(created_at, restaurant_id)5 · delivered · 18:027 · delivered · 17:407 · preparing · 18:017 · preparing · 18:057 · preparing · 18:099 · placed · 18:0317:40 · 718:01 · 718:02 · 518:03 · 918:05 · 718:09 · 7one seek,read 3neighbourswalk every entryin time order,fetch each rowfor restaurant 7to check statusWHERE restaurant_id = 7 AND status = 'preparing' ORDER BY created_atAlready sorted by time: no sort stepMatches are scattered; status is not even hereRule of thumb: equality columns first, then the range or ORDER BY column.
What to notice. On the left, the three matches are neighbours and already in time order: one seek, three reads, no sort. On the right, the same rows are scattered and the index doesn't even contain status.

Before you read on

The kitchen actually wants two statuses: status IN ('placed', 'preparing') ORDER BY created_at. Does (restaurant_id, status, created_at) still avoid the sort?

Not fully. The index gives you two sorted runs, one per status, and the database has to merge or sort them. For two values that's cheap. It's also a hint that a partial index may fit better, which we'll get to below. And the docs add a sanity check worth quoting in interviews: indexes with more than three columns are unlikely to help unless your queries are very stylised.

05Covering indexes and index-only scans

Even a perfect seek still has to fetch each row to read columns the index doesn't have. For "My orders" that's 20 random page reads for 20 rows. A covering index contains every column the query needs, so the database never visits the table. The plan operation is an index-only scan; Use The Index, Luke points out that "covering" sounds like a property of the index, when it really describes how one query runs against it.

-- PostgreSQL: key columns for the seek and sort, INCLUDE for the payload
CREATE INDEX orders_user_recent
  ON orders (user_id, created_at DESC)
  INCLUDE (status, total);

SELECT id, created_at, status, total   -- id is not in the index...
FROM orders WHERE user_id = 42
ORDER BY created_at DESC LIMIT 20;     -- ...so this one still visits the heap

Spot the trap? In Postgres the index doesn't contain id, so that query isn't covered. Either drop id from the select list or add it to INCLUDE. InnoDB is kinder here: every secondary entry already carries the primary key, so id comes free. In MySQL you'd write (user_id, created_at, status, total) and look for Using index in the Extra column of EXPLAIN.

The flow below adds the Postgres twist.

Fig. 4

An index-only scan skips the table, if the visibility map allows it

An index-only scan skips the table, if the visibility map allows itLeft: a plain index scan on (user_id, created_at). For each of the 20 rows it must fetch the row from the table to read status and total. Right: a covering index with INCLUDE (status, total) in PostgreSQL. For each entry the database checks the visibility map: if the page is all-visible it returns straight from the index; if not, it visits the heap anyway.Index scanIndex-only scan (PostgreSQL)x20yesnoIndex (user_id, created_at)finds 20 entries in orderTable rowsone fetch per row for status, totalIndex (user_id, created_at DESC)INCLUDE (status, total)Heap page all-visible?Answer from indexno table visitVisit the heaprecently changedSELECT created_at, status, total ... WHERE user_id = 42 ORDER BY created_at DESC LIMIT 20Up to 20 extra random page readsMySQL InnoDB: every secondary entry already carries the PK. EXPLAIN shows 'Using index' when it covers.
What to notice. The index-only path still has a check: the visibility map. On pages that changed recently, Postgres visits the heap anyway and you save nothing.

Why the check? Postgres stores visibility information only in the heap, not in index entries. So an index-only scan checks the visibility map, a bitmap with one bit per heap page that says "everything here is visible to everyone". The map is four orders of magnitude smaller than the heap, so checking it is cheap. But if the bit isn't set, the heap visit happens anyway. The docs' conclusion: INCLUDE columns only pay off on tables that change slowly enough for most pages to be all-visible. An orders table where yesterday's rows are frozen and today's are churning is a mixed case: the old history is covered, the live orders aren't.

Seen this before?

The visibility map exists because of MVCC: different transactions may see different versions of a row. Why they need their own view of the data at all is the subject of the next part, isolation levels.

06Why your index is ignored

You created the index. EXPLAIN still says Seq Scan (Postgres) or type: ALL (MySQL). There are two families of reasons, and telling them apart is most of the fix.

Fig. 5

Two families of 'why is my index ignored?'

Two families of 'why is my index ignored?'Left group, the planner cannot use the index because the query hides the column: a function on the column, an implicit type cast, a leading wildcard, a query that skips the leading column of a composite index, and OR across different columns. Right group, the planner can use it but chooses not to: the predicate matches a large share of rows, statistics are stale, or the table is small.Can't use it: the query hides the columnChooses not to: a scan looks cheaperFunction on the columnDATE(created_at) = ... → range on created_atImplicit type casttext phone = 9845... → compare as textLeading wildcardLIKE '%paneer%' → trigram or full-textSkipped leading columnindex (user_id, ...) but no user_id filterOR across columnsa = ? OR b = ? → UNION or two indexesMatches too many rowsstatus = 'delivered' → partial indexStale statisticsestimate far from actual → ANALYZESmall tablea few pages: scanning is fineFirst move for bothEXPLAIN (ANALYZE, BUFFERS)estimated rows vs actual rowsSeq Scan / type = ALL?
What to notice. On the left, the planner can't use the index because the query hides the column. On the right, it can but decides a scan is cheaper. The first family needs a rewrite; the second needs better information.

Family 1: the query hides the column

  • A function on the column. WHERE DATE(created_at) = '2026-10-20' asks about DATE(created_at), which isn't what the index stores. PlanetScale calls this index obfuscation. Fix: move the work to the other side, created_at >= '2026-10-20' AND created_at < '2026-10-21'. If you truly need the function, index the expression (below).
  • An implicit type cast. Store phone numbers in a text column, then query WHERE phone = 9845012345 with no quotes. Many databases silently compare as numbers by converting the column, which is a function on the column again. Use The Index, Luke notes PostgreSQL raises an error here instead. Fix: send the right type.
  • A leading wildcard. A B-tree can serve LIKE 'pan%' but not LIKE '%paneer', because sorted order only helps from the left. In a non-C locale Postgres also needs a special operator class even for the prefix case. For "contains" searches, use a pg_trgm GIN index or real full-text search.
  • A skipped leading column. The leftmost-prefix rule from above.
  • OR across columns. One index scan can only use conditions joined by AND, so user_id = 42 OR restaurant_id = 7 can't use one composite index. Postgres can combine two separate indexes with a bitmap OR; MySQL has a similar index merge. Often the cleanest fix is a UNION of two indexed queries.

Family 2: the planner thinks a scan is cheaper

  • The predicate matches too many rows. An index on status is useless for status = 'delivered' if most orders are delivered. Each index hit is a random page read; reading the table in order is faster once you need a big fraction of it. PlanetScale's piece walks through selectivity and why a table scan is sometimes the right answer. Note that it's query-dependent: the same index is great for the rare status = 'cancelled'.
  • Stale or naive statistics. The planner decides from stored statistics, not your data. InnoDB refreshes them automatically after about 10% of a table changes (per the PlanetScale piece above); Postgres relies on ANALYZE (usually via autovacuum). When columns are correlated, like restaurant_id and city, Postgres assumes they're independent and underestimates; extended statistics fix that.
  • The table is small. Ten pages are faster to scan than to index. Your dev database with 200 orders will always scan. Test on realistic volumes.

A tiny before and after

Here's the "My orders" query on Postgres before and after adding orders_user_recent. This output is illustrative and trimmed, not from a real run, so I've left the costs and timings out.

-- Before: only the primary key exists
Limit
  ->  Sort   (Sort Key: created_at DESC)
        ->  Seq Scan on orders
              Filter: (user_id = 42)

-- After: CREATE INDEX orders_user_recent ...
Limit
  ->  Index Only Scan using orders_user_recent on orders
        Index Cond: (user_id = 42)
        Heap Fetches: (low if pages are all-visible)

Read it bottom-up. Before: read every row, keep this user's, sort them all, keep 20. After: seek, read 20 entries in order, stop. In MySQL the same change shows up as type moving from ALL to ref and Using filesort disappearing from Extra.

Real world: the plan that flipped overnight

PlanetScale described a customer incident in "When the Postgres query planner goes rogue". No deploy, no traffic change, yet database CPU jumped from 20% to 100%. One query pattern, almost 50% of total runtime on the cluster, went from milliseconds to about 10 seconds, the configured timeout. The cause: as data grew, the statistics shifted and the planner abandoned an index for full table scans. The fix path is the one I'd describe in an interview: contain the damage, compare old and new plans with EXPLAIN (ANALYZE, BUFFERS), then refresh statistics, adjust the index or rewrite the query.

Forcing the index is a last resort

MySQL lets you hint an index with USE INDEX, and it can stop the bleeding. But it freezes today's decision into your code. As PlanetScale's index article warns, as the data changes you'll need to re-check whether the forced index is still the right one. Fix the statistics or the query first.

07What every index costs

Indexes are not free reads. They're reads you prepay on every write. The figure shows one Tiffin checkout.

Fig. 6

One INSERT, many B-tree writes

One INSERT, many B-tree writesA single INSERT into orders writes the row into the table and then inserts an entry into each index: the primary key, (user_id, created_at), (restaurant_id, status, created_at) and a unique idempotency_key index that must first check for a duplicate. Each one is a page in memory, a WAL record and a possible page split.INSERTone orderTable / clustered treethe row itselfPK indexid(user_id, created_at)order history(restaurant_id, status, ...)kitchen screenUNIQUE idempotency_keycheck for duplicate firstEach arrow costsfind the leaf pagedirty it in the buffer poolwrite a WAL recordmaybe split the pagePostgres UPDATEnew row version = newentry in every index,unless the update is HOTMore indexes: faster reads on those columns, slower writes on all of them.
What to notice. One logical insert becomes one write per index, each with its own page, WAL record and chance of a split. The unique index also has to look before it writes.
  • Slower writes. Every insert, delete and non-HOT update touches every index. The author of PlanetScale's piece on index downsides reported a bulk insert of about a million rows taking 10-15 seconds without indexes and closer to two minutes after adding several for slow queries. They accepted it because the job ran off-peak. Your checkout path might not have that luxury.
  • Storage and memory. Each index is another tree that competes for the same buffer pool as your hot rows. Wide INCLUDE columns and long primary keys (in InnoDB) make this worse.
  • Lost HOT updates. In Postgres, indexes can prevent heap-only tuples, and the docs' advice is plain: remove indexes that are seldom or never used.
  • Build cost. A plain CREATE INDEX in Postgres blocks writes until it finishes. CREATE INDEX CONCURRENTLY avoids that at the price of a slower build with caveats.
  • Unique checks. A unique index must check for an existing key before every insert. That's exactly what you want for an idempotency key on checkout: a retried request can't create a second order, and the check is atomic, unlike an application-level "SELECT then INSERT" that two requests can race through.

Not everyone loves unique indexes at scale. A ByteDance engineer argues on HackerNoon that adding a composite unique index to a table with tens of millions of rows dragged on for days with replication lag swinging around, and that business-level duplicates (like differently-cased emails) need application logic anyway. Both points are fair. My default is still: put real invariants like idempotency keys in a unique index, normalise the value before it gets there (or index lower(email)), and plan big index changes as migrations, not one-liners.

To test a removal safely, MySQL has invisible indexes: the optimizer stops using the index but it's still maintained, so you can flip it back if a query regresses.

08Beyond plain B-trees, briefly

Postgres ships several index types. You don't need them all for interviews, but these five come up.

IndexWhat it storesTiffin use
PartialOnly rows matching a WHERE predicateLive orders only: WHERE status IN ('placed','preparing')
ExpressionThe result of a function, like lower(email)Case-insensitive login lookups
HashA 32-bit hash of the value; equality onlyRarely worth it over a B-tree
GINAn inverted index: each element to the rows containing itSearch inside a JSONB metadata column
BRINMin and max per block range of the tableDate-range reports on an append-only table
  • Partial indexes are the quiet hero. The docs point out that a common value matching more than a few percent of rows won't use an index anyway, so leaving those rows out makes the index smaller and spares many updates from touching it. The catch: the query's WHERE must visibly imply the index predicate, and a parameterised status = $1 won't match.
  • Expression indexes are the escape hatch for "function on the column". They're computed on every insert and non-HOT update, so use them where reads matter more than writes.
  • GIN is an inverted index of (key, posting list) pairs: the same structure search engines use, explained in Storage data structures. It powers JSONB containment (@>), arrays and full-text search, but can't do index-only scans.
  • BRIN stores a summary per range of pages, so it's tiny. It works well only when the column tracks physical order, which is exactly what created_at on an append-only orders table does.

09How to say it in the interview

When I ask "this query is slow, what do you do?", I'm not listening for "add an index". I'm listening for a loop: find it, read the plan, explain the mismatch, fix the cheapest thing, verify. Here's the loop as a picture.

Fig. 7

The slow-query loop I walk through in interviews

The slow-query loop I walk through in interviewsFive steps left to right: find the query that costs the most in total, run EXPLAIN ANALYZE, compare estimated and actual rows, apply a fix (rewrite the predicate, add or reorder an index, refresh statistics), then verify the plan and watch write latency.Find ittop query bytotal time1EXPLAINANALYZEreal plan,real rows2Compareestimate vsactual rows3Fixrewrite, indexor ANALYZE4Verifynew plan, andwrite latency5Usually the fix at step 4 is a rewrite or one composite index, not five new indexes.
What to notice. Adding an index is one option inside step 4, and step 5 includes checking that writes didn't get slower.

Interview talk track

"First I'd confirm which query actually costs the most in total, not just the slowest single call. Then I'd run EXPLAIN ANALYZE on production-like data and compare estimated rows with actual rows. If it's scanning because the predicate hides the column, like a function or a type mismatch, I rewrite the predicate. If there's no index that matches, I design one: equality columns first, then the range or sort column, and I'd only add INCLUDE columns if the table is mostly stable. If the estimates are way off, I refresh statistics before touching indexes. Then I verify the new plan and watch insert latency, because every index taxes writes."

Designing for Tiffin's three access patterns

Start from queries, not columns. Here's what I'd put on the whiteboard, assuming PostgreSQL.

-- 1. "My orders": latest 20 for a customer
CREATE INDEX orders_user_recent ON orders (user_id, created_at DESC)
  INCLUDE (status, total);

-- 2. Kitchen screen: live orders for one restaurant, oldest first
CREATE INDEX orders_kitchen_live ON orders (restaurant_id, created_at)
  WHERE status IN ('placed', 'preparing');

-- 3. Finance: orders in a date range, append-only table
CREATE INDEX orders_created_brin ON orders USING brin (created_at);
  • Pattern 1 is a textbook equality-then-sort with LIMIT. It also supports keyset pagination: "older than the last created_at I showed".
  • Pattern 2 uses a partial index because live orders are a sliver of the table. The kitchen query must repeat the same status IN (...) literally. On MySQL I'd use the plain composite (restaurant_id, status, created_at).
  • Pattern 3 uses BRIN because the finance report reads big date ranges and rows arrive in time order. If the report needs per-restaurant filters too, a B-tree on (restaurant_id, created_at) may win; I'd test both.

Plus the unique index on idempotency_key from checkout. That's four secondary indexes for a write-heavy table, each mapped to a screen or an invariant. Notice what's missing: no index on status alone, no index on total.

Many candidates index every column "to be safe"

It feels safe and it's the opposite. Single-column indexes on status, total and restaurant_id don't serve the composite queries you actually run, and each one taxes every insert on your hottest table. Name the query each index serves. If you can't, don't create it.

One composite index or several single-column ones?

For queries that filter on both columns, a composite index is usually faster. Postgres can AND or OR separate indexes together with bitmaps, but the docs note this loses the index ordering, so an ORDER BY needs a separate sort. Use separate indexes when queries hit each column on its own about equally often.

Should the most selective column go first?

Not as a rule. Equality columns go before range columns, whatever their selectivity. Among equality columns, pick the order that lets other queries reuse the leftmost prefix.

Is a UUID primary key also bad on Postgres?

Less bad, because the table is a heap and doesn't move with the key. But the primary key index is still a B-tree taking random inserts, so you still get scattered pages and a bigger working set. UUIDv7 fixes most of it.

How do I find indexes nobody uses?

Check the database's index usage statistics over a full business cycle, including month-end jobs. On MySQL, make the candidate invisible first and drop it only if nothing regresses.

How do I add an index to a 500 GB table without downtime?

On Postgres, CREATE INDEX CONCURRENTLY, and check the build actually succeeded. Watch replica lag and I/O while it builds. On any engine, treat it as a migration with a rollback plan, not a quick fix.

When is a full table scan the right answer?

When the table is small, or the query needs a large share of rows. Sequential reads of the whole table can beat thousands of random index lookups. If that query is on the hot path, the fix is usually to make it ask for fewer rows.

Key takeaways

  1. InnoDB clusters rows by primary key, so secondary lookups walk two trees. Postgres keeps a heap, so lookups are one hop but non-HOT updates touch every index.
  2. Use sequential or time-ordered keys (auto-increment, UUIDv7, Snowflake-style). Random UUIDv4 keys spread inserts over every page and leave pages half full.
  3. Composite index order: equality columns, then the range or ORDER BY column. Match the sort and LIMIT stops early.
  4. Ignored indexes are either hidden by the query (functions, casts, wildcards, OR) or rejected on cost (low selectivity, bad statistics, small tables). EXPLAIN ANALYZE tells you which.
  5. Every index is a tax on every write. Name the query it serves, or don't create it.

Next up

Isolation levels: two transactions, one row, and every anomaly you'll be asked about, drawn as timelines.

Related

Real-world story

Uber's Postgres to MySQL migration: how index layout turned one small update into writes on every index, and onto the replication link.

Further reading