System Design
D2

Indexes: structure, column order and cost

An index lets Postgres read a few pages instead of the whole table. This sheet records real plans on a table of 1 million rows. It shows which index Postgres uses, how many pages it reads, and what each index costs on writes.

Not startedSaved in this browser only.
  1. 1An index is a sorted copy of some columns, each entry pointing to its row. A lookup reads about 3 index pages.
  2. 2Column order: equality columns first, then the sort or the range. The index then returns rows already in order.
  3. 3A covering index answers from the index alone, if the visibility map says the pages are all-visible.
  4. 4Every index costs every write. In the lab, 8 secondary indexes cut inserts from 345,748 to 21,695 rows a second.
D2
    A

    One lookup, two plans

    recorded from EXPLAIN (ANALYZE, BUFFERS)
    SELECT created_at, total_cents FROM orders WHERE customer_id = 42428 kB pages read, one linear scale; the table is 12,508 pagesNo index: Seq Scan12,508 pagesRead every row, keep 111, throw 999,889 away.Index on customer_id: Bitmap Index Scan, then Bitmap Heap Scan3 index pages + 110 table pagesThe index holds the 111 matching entries in sorted order, so 3 pages find them all.The 111 rows sit on 110 different table pages, so the table visits cost the most.

    Time: 60.7 ms against 0.081 ms, median of 7 runs. Pages read predict time better than any other number in a plan.

    Without an index Postgres reads every page. With one, it reads a few index pages, then one table page per matching row.

    B

    Index types

    Postgres access methods
    typeservesstatus
    B-tree=, <, >, BETWEEN, ORDER BY, IS NULLDefault
    Hash= onlyEquality only
    GINArrays @>, JSONB @> ?, full text @@, trigramsMany values per row
    GiSTRanges &&, geometry, nearest neighbour <->Ranges, shapes
    SP-GiSTPoints, IP prefixes, text prefixesSpace partitions
    BRINRanges on a column in insert orderPhysical order

    GiST also backs exclusion constraints (sheet T8). Trigram search needs the pg_trgm extension.

    I use a B-tree unless the operator needs another type: GIN for arrays and JSONB, GiST for ranges, BRIN for huge append-only tables.

    C

    The table

    1 million rows, the same on every run
    orderssql
    CREATE TABLE orders (
      id          bigint      PRIMARY KEY,
      customer_id int1         NOT NULL,
      status      text        NOT NULL,
      created_at  timestamptz2 NOT NULL,
      total_cents int         NOT NULL,
      email       text        NOT NULL,
      tags        text[]      NOT NULL
    ) WITH (autovacuum_enabled = off3);
    1. 1About 100 orders per customer, spread through the table. A customer's rows sit on different pages.
    2. 2Rises with id, so it follows the physical order of the table. BRIN depends on that.
    3. 3The lab runs VACUUM itself, so every recorded plan sees the same visibility map.
    table
    1,000,000 rows, 12,508 pages of 8 kB, 98 MB
    values
    10,000 customers. Status: delivered 80%, shipped 10%, pending 5%, cancelled 4%, refunded 1%. One year of dates, in id order. The gift tag on 1% of rows.
    indexes
    (customer_id) 871 pages; two columns 3,853; with INCLUDE 4,954

    I size an index against the table: here 1 million rows are 98 MB, and one integer index is 7 MB.

    D

    How a B-tree index finds rows

    sorted entries, each with a row pointer
    index on (customer_id, created_at); entries and page numbers are illustrativeroot: 2501 | 5001 | 7501inner: 4201 | 4242 | 4290Each level narrows the key range.1,000,000 entries need 3 levels.leaf page, sorted by keyabout 260 entries per 8 kB page4242 | 2026-06-28→ (6120, 7)4242 | 2026-07-03→ (10233, 2)4242 | 2026-07-19→ (812, 41)4242 | 2026-08-02→ (3001, 15)4243 | 2025-10-14→ (77, 3)Customer 4242's entries sit next to each other,in date order. Their rows do not.table pages (heap)page 812page 3001page 4800page 6120page 10233Rows are stored in insert order,so each entry points elsewhere.

    An index entry is the key plus a pointer to the row. The entries are sorted, so equal keys and ranges sit together; the rows they point to do not.

    E

    Try it: three queries, five indexes

    recorded plans on the lab Postgres
    query
    index
    SELECT created_at, total_cents FROM orders WHERE customer_id = 4242 AND created_at >= '2026-07-01' ORDER BY created_at DESC LIMIT 10CREATE INDEX orders_customer_created ON orders (customer_id, created_at)
    EXPLAIN (ANALYZE, BUFFERS)(customer_id, created_at)
    1. Limitrows 10 · pages 13
    2. Index Scan Backward on orders_customer_created on ordersrows 10 · pages 13Index Cond: ((customer_id = 4242) AND (created_at >= '2026-07-01 00:00:00+00'::timestamp with time zone))
    pages read
    13
    rows returned
    10
    time
    under 0.1 ms
    index size
    3,853 pages

    The customer and the date range form one run of entries, in date order. Postgres reads it backward and stops at 10 rows: 13 pages, no sort.

    pages read for this query, every index set (log scale)none: 12,508 pagesnone12,508one column: 113 pagesone column113composite: 13 pagescomposite13wrong order: 172 pageswrong order172covering: 4 pagescovering4

    Plans, rows and pages are exact and the same on every run. Times are medians of 7 runs on a shared 8-core laptop, shown as bands. Settings: max_parallel_workers_per_gather = 0; jit = off; synchronize_seqscans = off; TimeZone = 'UTC'.

    I read the plan: which scan, how many pages, whether it sorts. The right composite index turns a sort and 113 pages into 13 pages and no sort.

    F

    Column order

    equality first, then the sort or the range
    WHERE customer_id = 4242 AND created_at >= '2026-07-01' ORDER BY created_at DESC LIMIT 10✓ (customer_id, created_at)4241 | 2026-09-304242 | 2025-10-144242 | …4242 | 2026-06-284242 | 2026-07-034242 | … 33 entries4242 | 2026-09-214242 | 2026-09-29start4243 | 2025-10-02One contiguous run, already in date order.Read 10 entries backward and stop: 13 pages.✕ (created_at, customer_id)2026-07-01 00:00 | 18772026-07-01 00:01 | 9031…2026-07-03 08:12 | 42422026-07-03 08:13 | 512…2026-09-29 17:40 | 42422026-09-29 17:41 | 61502026-09-30 23:59 | 3388startThe date range is contiguous; the customer is not.Walk back past other customers: 172 pages.Entries are illustrative. With customer_id alone, Postgres reads all of the customer's rows, filters them and sorts: 113 pages.
    design an index for one querypseudo code
    index_for(query):
      key = columns compared with =         // any order among them1
      IF the query sorts:
        key += the ORDER BY columns         // rows come out in order2
      key += ONE column compared with a range3
      include = other columns it selects    // an index-only scan4
      IF it always filters one value:
        index only those rows               // a partial index
      IF it wraps a column in a function:
        index the expression, not the column
    
    check it:
      EXPLAIN (ANALYZE, BUFFERS) query
      read the pages, not only the time5
    1. 1Their order does not change this query. Choose it so that other queries can use the same leading columns.
    2. 2The sort column must follow the equality columns directly. Then the plan has no Sort step and can stop at LIMIT.
    3. 3The scan seeks on the columns up to the first range. Columns after a range only filter entries inside the index.
    4. 4INCLUDE stores extra columns in the leaf pages without making them part of the key.
    5. 5Times change with the cache. Pages read are the same on every run and predict the time.
    Tested source Go: the claims checked on the recorded plans
    Go: the claims checked on the recorded plansgo
    // The claims the sheet makes, checked on the recorded plans.
    p := func(set, q string) []Node { return out.Plans[set+"|"+q] }
    if s := scan(p("none", "customer")); s.Type != "Seq Scan" || s.Buffers != out.TablePages {
      t.Errorf("no index: %s over %d pages, want a Seq Scan over all %d", s.Type, s.Buffers, out.TablePages)
    }
    if !slices.ContainsFunc(p("single", "recent"), func(n Node) bool { return n.Type == "Sort" }) {
      t.Error("single-column index: the recent query should still sort")
    }
    if slices.ContainsFunc(p("composite", "recent"), func(n Node) bool { return n.Type == "Sort" }) {
      t.Error("composite index: the recent query should read in index order, with no sort")
    }
    if a, b := root(p("composite", "recent")).Buffers, root(p("single", "recent")).Buffers; a*5 > b {
      t.Errorf("composite: %d pages for the recent query, single: %d; want 5 times fewer", a, b)
    }
    if a, b := root(p("wrong-order", "recent")).Buffers, root(p("composite", "recent")).Buffers; a < 5*b {
      t.Errorf("wrong order: %d pages for the recent query, right order: %d; want 5 times more", a, b)
    }
    if a, b := root(p("wrong-order", "one-day")).Buffers, root(p("composite", "one-day")).Buffers; a*10 > b {
      t.Errorf("one day: %d pages with created_at first, %d with customer_id first; want 10 times fewer", a, b)
    }
    cov := scan(p("covering", "recent"))
    if cov.Type != "Index Only Scan Backward" || !slices.Contains(cov.Detail, "Heap Fetches: 0") {
      t.Errorf("covering index: %s %v, want an index-only scan with no heap fetches", cov.Type, cov.Detail)
    }
    queryright indexpages
    WHERE customer_id = ?(customer_id)113
    ... AND created_at >= ? ORDER BY created_at DESC LIMIT 10(customer_id, created_at)13
    the same, selecting created_at and total_cents only... INCLUDE (total_cents)4
    WHERE created_at in one day(created_at, ...)15

    An order that is wrong for one query can be right for another. Design each index for a named query.

    I put the equality columns first. Then the column the query sorts by or ranges over. One customer's recent orders become one run of entries, read backward.

    G

    Capabilities used

    what Postgres gives you
    toolcapabilitywhat it gives this designalso used for
    PostgresMulti-column B-tree, read in both directionsEquality, range and ORDER BY from one index. DESC needs no second index.Unique constraints, keyset paging
    PostgresB-tree deduplication (version 13 and later)A repeated key is stored once per page with a list of row pointers. (customer_id) takes 871 pages for 1 million rows.Low-cardinality columns
    PostgresINCLUDE columns (version 11 and later)Extra columns in the leaf pages, outside the key. An index-only scan without a wider key.Unique index plus payload
    PostgresPartial index: WHERE in CREATE INDEXIndex only the rows a query asks for: 34 times smaller here.Unique among active rows
    PostgresExpression indexIndex lower(email) or any immutable expression the query uses.Case-insensitive lookups
    PostgresGINOne entry per element of an array, JSONB document or text.Full-text search, tags
    PostgresGiST and SP-GiSTRanges, geometry and nearest-neighbour search.Exclusion constraints (sheet T8)
    PostgresBRIN (128 pages per range by default)The minimum and maximum per block range: 3 pages for 1 million rows.Logs, events, time series
    PostgresVisibility map and index-only scansSkip the table for pages that VACUUM marked all-visible.Faster VACUUM of frozen pages
    PostgresHOT updatesAn update that changes no indexed column, and fits on its page, writes no index entry.Counters, status columns
    PostgresCREATE INDEX CONCURRENTLYBuild an index while writes continue. It takes longer and scans the table twice.REINDEX CONCURRENTLY
    Postgrespg_stat_user_indexes (idx_scan)Find indexes that no query uses, then drop them.Capacity reviews
    PostgresEXPLAIN (ANALYZE, BUFFERS)The plan, the rows and the pages each step read.Every slow query
    PostgresPlanner statistics (ANALYZE)Limit Plans follow estimated rows. Stale statistics after a bulk load give wrong plans.

    Postgres gives me multi-column B-trees with INCLUDE, partial and expression indexes, GIN, GiST and BRIN, and EXPLAIN with page counts to check each one.

    H

    When an index helps, and when it does not

    recorded plans, one table
    stepplan Postgres chosepages readrowstimeindex size
    Selectivity: one index on status, two values
    status = refunded, 1% of rowsSELECT sum(total_cents) FROM orders WHERE status = 'refunded'Bitmap Heap Scan on ordersHeap Blocks: exact=6892 lossy=06,9049,9951 to 10 msorders_status: 861 pagesIndex used
    status = delivered, 80% of rowsSELECT sum(total_cents) FROM orders WHERE status = 'delivered'Seq Scan on ordersRows Removed by Filter: 19977712,508800,223over 100 msorders_status: 861 pagesIndex not used
    A function on the column
    a function on the columnSELECT sum(total_cents) FROM orders WHERE lower(email) = '[email protected]'Seq Scan on ordersRows Removed by Filter: 99988912,508111over 100 msorders_email: 902 pagesIndex not used
    the bare columnSELECT sum(total_cents) FROM orders WHERE email = '[email protected]'Bitmap Heap Scan on ordersHeap Blocks: exact=110 lossy=0113111under 0.1 msorders_email: 902 pagesIndex used
    an index on the expressionSELECT sum(total_cents) FROM orders WHERE lower(email) = '[email protected]'Bitmap Heap Scan on ordersHeap Blocks: exact=110 lossy=0113111under 0.1 msorders_email_lower: 902 pagesIndex used
    Partial index: only the rows the query wants
    an index on every rowSELECT id, total_cents FROM orders WHERE status = 'pending' ORDER BY created_at LIMIT 20Index Scan on orders_status_created on orders1120under 0.1 msorders_status_created: 4,795 pagesIndexes every row
    a partial index, pending rows onlySELECT id, total_cents FROM orders WHERE status = 'pending' ORDER BY created_at LIMIT 20Index Scan on orders_pending on orders1020under 0.1 msorders_pending: 139 pages34× smaller
    GIN: an element inside an array
    no indexSELECT count(*) FROM orders WHERE tags @> '{gift}'Seq Scan on ordersRows Removed by Filter: 98990012,50810,100over 100 msnoneFull scan
    a GIN index on the arraySELECT count(*) FROM orders WHERE tags @> '{gift}'Bitmap Heap Scan on ordersHeap Blocks: exact=6994 lossy=07,00010,1001 to 10 msorders_tags: 142 pagesIndex used
    BRIN: a column in insert order
    a B-tree on created_atSELECT sum(total_cents) FROM orders WHERE created_at >= '2026-03-10' AND created_at < '2026-03-11'Index Scan on orders_created on orders462,7390.1 to 1 msorders_created: 2,745 pagesFewest pages
    a BRIN index on created_atSELECT sum(total_cents) FROM orders WHERE created_at >= '2026-03-10' AND created_at < '2026-03-11'Bitmap Heap Scan on ordersRows Removed by Index Recheck: 17734Heap Blocks: exact=0 lossy=2562582,7391 to 10 msorders_created_brin: 3 pages915× smaller
    Pagination: OFFSET against keyset
    OFFSET 500000SELECT id, created_at FROM orders ORDER BY created_at, id OFFSET 500000 LIMIT 20Index Only Scan on orders_created_id on ordersHeap Fetches: 01,919500,02010 to 100 msorders_created_id: 3,848 pagesReads 500,020 rows
    keyset: after the last row seenSELECT id, created_at FROM orders WHERE (created_at, id) > ('2026-04-01 11:59:28.464', 500000) ORDER BY created_at, id LIMIT 20Index Only Scan on orders_created_id on ordersHeap Fetches: 0420under 0.1 msorders_created_id: 3,848 pagesReads 20 rows
    • 1% of rows can sit on 55% of the pages. Selectivity is about pages, not rows.
    • BRIN stores a minimum and a maximum per 128 pages. It suits append-only tables and reads some extra pages per query.
    • Keyset paging needs an index on the sort columns, with a unique column last to break ties.

    An index helps when it cuts the pages read. A common value, a function on the column or a deep OFFSET can make it useless.

    I

    Index-only scans and the visibility map

    recorded, same query three times
    visibility map: 1 bit per 8 kB table page; set by VACUUM, cleared by any change to the page1page 01page 10page 2fetch1page 31page 41page 50page 6fetch1page 71page 81page 9bittableA bit of 1: every row on the page is visible, so the scan trusts the index entry.A bit of 0: the scan must read the table page to check the row version.recorded: an index-only scan of one customer's 89 rowsafter VACUUM: 0 heap fetchesafter an UPDATE of the customer's rows: 89 heap fetchesafter VACUUM again: 0 heap fetches
    stepplan Postgres chosepages readrowstime
    Covering index on (customer_id) INCLUDE (total_cents)
    after VACUUMSELECT sum(total_cents) FROM orders_vm WHERE customer_id = 42Index Only Scan on orders_vm_customer on orders_vmHeap Fetches: 0489under 0.1 msNo table visit
    after an UPDATE of the customer's rowsSELECT sum(total_cents) FROM orders_vm WHERE customer_id = 42Index Only Scan on orders_vm_customer on orders_vmHeap Fetches: 891789under 0.1 msUntil VACUUM
    after VACUUM againSELECT sum(total_cents) FROM orders_vm WHERE customer_id = 42Index Only Scan on orders_vm_customer on orders_vmHeap Fetches: 0489under 0.1 msNo table visit

    An index-only scan skips the table only for pages VACUUM marked all-visible. After heavy updates it visits the table again until the next VACUUM.

    J

    The cost on writes

    measured: inserts as indexes grow
    Inserts a second, by secondary indexes10k100k1Mprimary key only: 345,748 rows per secondprimary key only345,748+ 1 index: 201,322 rows per second+ 1 index201,322+ 2 indexes: 163,702 rows per second+ 2 indexes163,702+ 4 indexes: 75,964 rows per second+ 4 indexes75,964+ 8 indexes: 21,695 rows per second+ 8 indexes21,695rows per second, log scale
    secondary indexesrows a secondlog bytes a rowagainst none
    0345,748192baseline
    1201,3223311.7× slower
    2163,7023972.1× slower
    475,9646154.6× slower
    821,6951,53815.9× slower

    100,000 rows into a table of 1,000,000, 1,000 rows per transaction, best of 2 rounds. Eight indexes no longer fit in shared_buffers, so each insert also waits on page reads.

    Each index is another B-tree insert on every write. I keep only the indexes a named query needs, and check idx_scan before I add more.

    K

    Failure cases

    what breaks, and the fix
    failurewhat happensfixfixed with
    Unused indexesEvery write pays for them, and they take memory from useful pages.Find idx_scan = 0 over a full business cycle, then drop them.pg_stat_user_indexes
    Wrong column orderA full index scan: 3,944 pages instead of 114.Equality columns first, then the sort or the range.Composite index
    A function on the columnThe index is not used: a Seq Scan.Index the expression, or rewrite the condition on the bare column.Expression index
    A common valueThe planner ignores the index, which still costs every write.Drop it, or make it partial on the rare values.Partial index
    CREATE INDEX on a busy tableWrites to the table wait until the build ends.Build with CONCURRENTLY. A failed build leaves an invalid index to drop.CONCURRENTLY
    Stale statisticsAfter a bulk load, the planner picks a scan for the old row counts.Run ANALYZE after large loads; tune autovacuum to analyze sooner.ANALYZE
    Index bloatHeavy updates and deletes leave pages half empty. Scans read more pages.REINDEX CONCURRENTLY; keep updates HOT where possible.REINDEX
    Deep OFFSETPage 25,000 of a list reads 1,919 pages.Keyset pagination on an index of the sort columns.Keyset
    Heap fetches in an index-only scanThe scan reads table pages again after updates.Let autovacuum run often enough on hot tables.VACUUM
    L

    Scale ladder

    start simple; climb only on a signal
    Each step adds one component1Primary key2+ composite3+ covering, partial4+ GIN, BRIN5+ search enginemore load →
    Capacity against demand101001k10k100kDemand, average: 500 queries per second, one coreDemand, average500Demand, 10× peak: 5,000 queries per second, one coreDemand, 10× peak5,000No index: 14 queries per second, one coreNo index14One column: 14,000 queries per second, one coreOne column14,000Composite: 59,000 queries per second, one coreComposite59,000Covering: 71,000 queries per second, one coreCovering71,000queries per second, one core, log scale
    stepaddit handlesmove up when you see
    1The primary key only.Lookups by id. Any other filter scans 12,508 pages: about 14 queries a second per core.A query with a Seq Scan on a large table, on a hot path.
    2One composite index per hot query, equality columns first.About 59,000 of those queries a second per core.The plan still visits the table for every row.
    3Covering, partial and expression indexes for the hottest queries. Drop unused ones.Index-only scans: about 71,000 of those queries a second per core.Queries inside arrays or JSONB, or range queries over a huge log table.
    4GIN and BRIN, and time partitions for logs.Element and document queries; time ranges from tiny indexes.Ranked full-text search, fuzzy matching, or many filters in any combination.
    5A search engine fed from Postgres by change data capture.Relevance ranking and facets across many fields.Top of the ladder.

    Queries a second per core is 1,000 divided by the measured time in ms for the "latest 10" query: 1,000 / 0.017 for the composite index. Network and connection costs come on top. Demand example: 500 order-history views a second, 10× at peak.

    I start with the primary key and add one index per slow, named query. I move search and analytics to their own stores only when Postgres indexes cannot serve them.

    M

    Drill

    predict, then reveal

    0 of 9 known

    1. The index is (customer_id, created_at). A query filters on created_at only. Does Postgres use the index?

    2. Why does status = 'delivered' scan the table even with an index on status?

    3. Only 1% of the rows are refunded, yet the plan reads 6,904 of 12,508 pages. Why?

    4. The query uses WHERE lower(email) = ... and there is an index on email. Why a Seq Scan?

    5. An index-only scan reports Heap Fetches: 89. Why, and what fixes it?

    6. Why is OFFSET 500000 slow even with an index on the sort order?

    7. Which index for WHERE status = 'pending' ORDER BY created_at LIMIT 20, when 5% of rows are pending?

    8. You add 8 secondary indexes to a write-heavy table. What happens to inserts?

    9. When does a BRIN index beat a B-tree?

    N

    Numbers to say

    recorded or measured
    table
    1 million rows of about 100 bytes: 12,508 pages, 98 MB.
    lookup
    3 index pages, then one table page per row: 113 pages for 111 rows.
    scan
    A Seq Scan of 1 million rows: tens of ms on one core (60.7 ms measured).
    index size
    One int column 871 pages; int and timestamp 3,853; BRIN 3.
    selectivity
    1% of rows touched 55% of the pages.
    writes
    0, 1, 4, 8 indexes: 346,000, 201,000, 76,000, 22,000 rows a second.
    paging
    OFFSET 500,000: 1,919 pages. Keyset: 4.

    Postgres 16 on an 8-core laptop shared with other jobs. Pages are exact; rates and times are orders of magnitude.