Cloud Parts

Database indexes

A structure kept in step with a table that lets the database find matching rows without reading the whole table, paid for on every write.

1 technology on this page ↓

How it works

Without an index, a query that filters on a column reads every page of the table: a sequential scan. An index is a separate structure that maps values to where their rows are, and the database updates it with every insert, delete and update that touches an indexed column. The default almost everywhere is a B-tree: keys kept sorted in pages a few levels deep, so finding one key or a range of keys reads a handful of pages instead of the whole table. Other kinds give up generality for size or answer new questions. A hash index answers only equality. An inverted index (GIN in PostgreSQL) lists, for each word, array element or JSON key, the rows that contain it. Spatial trees (R-trees, GiST, SP-GiST) search by overlap and distance. A block-range index (BRIN) keeps only the minimum and maximum of each run of 128 pages, which makes it tiny but useful only when the table’s physical order follows the column. Bloom filter indexes keep a small lossy signature per row or per block. The planner uses an index only when it estimates that reading the index and then the matching rows beats scanning the table, and that depends on statistics from ANALYZE. Apart from its kind, an index is either clustered, holding the table’s rows in key order, or non-clustered, holding keys that point to rows stored elsewhere.

Think of it as

The index at the back of a book: look up “vacuum” on a few alphabetised pages and jump straight to pages 212 and 340 instead of reading the book. A BRIN index is more like the date labels on a shelf of binders. “January to March” tells you which binder to open, but only because the binders were shelved in date order.

Reach for it when

  • Queries that pick out a small share of a big table: WHERE customer_id = ?, a date range, a join key, ORDER BY ... LIMIT 10
  • Enforcing uniqueness: a primary key or UNIQUE constraint is backed by a unique B-tree index
  • Huge append-only tables filtered by time, where a BRIN index costs kilobytes instead of hundreds of megabytes
  • Searching inside values (words, JSON keys, array elements, substrings) or by location, with GIN, trigram or GiST indexes

Think twice when

  • Queries that return more than a few percent of the rows: the planner scans the table anyway
  • Indexes nobody queries on write-heavy tables: each one slows every insert, and in PostgreSQL an indexed column changed by an update rules out the cheaper heap-only (HOT) update
  • Small tables that fit in a few pages, where reading them all beats any index
  • Conditions that wrap the column in a function, such as lower(email) = ?, unless the index is built on that same expression

A real example

A table of 10 million sensor readings, one per second, takes 498 MB in PostgreSQL 18. A B-tree on the timestamp takes 214 MB; a BRIN index on the same column takes 32 kB, because it keeps one minimum and maximum per 128 pages. Asked for one hour (3,600 rows), the B-tree reads 37 pages in 0.9 ms. The BRIN index reads its 6 pages and one 128-page block range, throwing away 16,496 rows that don’t match, in 2.6 ms. Copy the same rows into a randomly ordered table and the BRIN index stops helping: every block range now spans the whole year, so the same query reads all 63,744 pages and takes 930 ms.

More on database indexes

Narrower ideas that build on this one.

Kinds of database indexes

Specific products, and what each does differently.

B-tree indexes →

Keys kept in sorted order in a shallow, balanced tree of pages: the default index almost everywhere, good for equality, ranges and sorting.

Hash indexes →

A hash of each key, filed into buckets: answers only `=`, but can be much smaller than a B-tree for long keys.

Block-range indexes (BRIN and zone maps) →

A minimum and maximum, or another summary, for each run of adjacent pages, so a scan skips runs that can’t match: tiny, but only as good as the table’s physical order.

Spatial indexes (R-tree, GiST, SP-GiST) →

Balanced trees of bounding boxes and space-partitioning trees, for questions like “what overlaps this?” and “what’s nearest?”.

Bitmap indexes →

One bitmap per distinct value, with a bit per row, so filters on several low-cardinality columns combine with fast AND and OR.

Bloom filter indexes →

A compact, lossy signature that answers “definitely not here” or “maybe here”, used to skip rows, pages or whole files on equality lookups.

The kinds compared#

Sizes come from the PostgreSQL 18 runs on this page and change with the data. Vector indexes such as HNSW and IVFFlat, which find approximate nearest neighbours of embeddings, are covered under vector databases.

KindAnswersSizeWrite costTypical use
B-tree=, <, >, BETWEEN, IN, IS NULL, LIKE 'foo%', sorted outputOne entry per row: 214 MB for 10 million timestamps in a 498 MB tableAn entry per row in every index; full pages splitThe default: primary keys, foreign keys, unique constraints, most filters
Hash= onlyA 4-byte hash per row: 32 MB against a 119 MB B-tree on 1 million URLsBuckets split in the foreground as the table growsEquality lookups on long keys such as URLs and UUIDs
BRIN (block range)=, <, > with min/max; other classes add Bloom filters and bounding boxesOne summary per 128 pages: 32 kB for 10 million rowsVery low; new ranges are summarized by vacuum; HOT updates still workAppend-only logs and time series, IDs that grow with insert order
GIN (inverted)Contains: array @> and &&, jsonb @>, full-text @@, trigram LIKE '%foo%'Can near the table: 114 MB of trigrams for a 135 MB tableHigh: one entry per extracted key; a pending list batches themTags, JSON documents, text search, substring search
GiST and SP-GiST (spatial)Overlap &&, containment, nearest neighbour <->, exclusion constraintsOne key per row, often a bounding box that needs a recheckLike a B-tree, plus choosing where each new key fitsMaps (PostGIS), time ranges, no-overlap bookings
Bitmap (Oracle)=, combined with AND and OR across columnsA bitmap per distinct value, each with a bit per rowChanging one row locks every row with the same valueRead-mostly warehouse tables with low-cardinality columns
Bloom filter= on any combination of the indexed columnsAn 80-bit signature per row: 153 MB against a 386 MB six-column B-treeLow; matches are rechecked because of false positivesWide tables filtered on arbitrary column combinations

Clustered or non-clustered#

The kinds above say how an index is searched. Separately, an index either holds the table’s rows itself (clustered) or holds keys that point to rows stored somewhere else (non-clustered, also called secondary). A table can be stored in only one order, so it has at most one clustered index.

Clustered indexNon-clustered (secondary) index
Its leaves holdThe table’s rows themselves, sorted by the index keyThe key and a pointer to the row: its physical location in a heap, or its clustered key
How many per tableOneAs many as you need
Finding one rowThe search ends on the page that holds the rowA second lookup follows the pointer to the row, unless the index covers every column the query needs
Reading a rangeNeighboring keys sit on neighboring pagesMatching rows can be anywhere in the table, so each may cost a random read
InsertingEach row goes where its key sorts: an ever-increasing key appends at the end, a random one splits pages in the middleEvery insert, and every change to an indexed column, also updates this index
Key sizeCopied into every secondary index in InnoDB and SQL Server, so a short key keeps all of them smallIts own columns plus the pointer
SQL ServerCreated for the PRIMARY KEY by default; a table without one is a heapUNIQUE constraints and CREATE NONCLUSTERED INDEX
MySQL (InnoDB)Always present: the primary key, else the first unique index on NOT NULL columns, else a hidden 6-byte row IDEvery other index; each entry stores the primary key columns
PostgreSQLNone kept in order: tables are heaps, and CLUSTER reorders one once without keeping new rows in orderEvery index, the primary key included
OracleORGANIZATION INDEX tables, stored in a B-tree by primary key; the default is a heapSecondary indexes, which find rows by a logical rowid built from the primary key
SQLiteEvery ordinary table, clustered on its 64-bit rowid; WITHOUT ROWID tables cluster on the declared primary keyEvery CREATE INDEX, and the primary key of an ordinary table unless it is an INTEGER PRIMARY KEY
  • SQL Server’s guidance for a clustered key is narrow, unique, ever-increasing and never updated. A 16-byte uniqueidentifier key is wider than an 8-byte bigint and isn’t ever-increasing unless its values are generated sequentially.
  • Because PostgreSQL indexes are all secondary, an ordinary index scan reads both the index and the heap, often at random. An index-only scan skips the heap when the index holds every column the query needs.

Clustered and not, side by side in SQLite#

The same 100,000 users stored twice. The ordinary table finds a row through a separate index on email, which gives a rowid to look up in the table; the WITHOUT ROWID table is clustered on email, so the search ends on the row. Its secondary index is bigger, because each entry carries the email instead of a rowid.

sql
-- SQLite 3.54: the same users, stored two ways
CREATE TABLE users_heap (email TEXT PRIMARY KEY, name TEXT, plan TEXT);
CREATE TABLE users_clustered (email TEXT PRIMARY KEY, name TEXT, plan TEXT) WITHOUT ROWID;
CREATE INDEX users_heap_plan ON users_heap (plan);
CREATE INDEX users_clustered_plan ON users_clustered (plan);

WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 100000)
INSERT INTO users_heap
SELECT 'user' || i || '@example.com', 'User ' || i, CASE WHEN i % 10 = 0 THEN 'pro' ELSE 'free' END FROM n;
INSERT INTO users_clustered SELECT * FROM users_heap;

EXPLAIN QUERY PLAN SELECT name FROM users_heap WHERE email = 'user42@example.com';
-- SEARCH users_heap USING INDEX sqlite_autoindex_users_heap_1 (email=?)

EXPLAIN QUERY PLAN SELECT name FROM users_clustered WHERE email = 'user42@example.com';
-- SEARCH users_clustered USING PRIMARY KEY (email=?)

SELECT name, sum(pgsize) / 1024 AS kb FROM dbstat GROUP BY name;
-- users_heap                      4420  <- rows, keyed by rowid
-- sqlite_autoindex_users_heap_1   3196  <- email -> rowid
-- users_heap_plan                 1412  <- plan -> rowid
-- users_clustered                 4504  <- rows, keyed by email: no separate email index
-- users_clustered_plan            3340  <- plan -> email

What an index costs#

Writes. Each index is another structure to change with the table. An insert adds an entry to every B-tree on the table, and to a GIN index one entry for every key it extracts. In PostgreSQL an update normally writes new entries in every index too; the cheaper heap-only tuple (HOT) update, which skips them, is possible only when no indexed column changes (BRIN aside) and the page has room. PostgreSQL’s docs advise dropping indexes that are seldom or never used.

Disk and memory. In the run above, the B-tree on one timestamp column was 43% of the table’s size and took 6.5 seconds to build, against 0.45 seconds for the BRIN index. Indexes compete with the table for cache, and a plain CREATE INDEX blocks writes, though not reads, until it finishes; CREATE INDEX CONCURRENTLY keeps writes flowing but has caveats.

Selectivity. An index pays off when it lets the database skip most of the table. Each row found through a B-tree means a visit to wherever that row sits in the table, so once a query matches more than a few percent of the rows, the planner chooses a sequential scan even though the index exists. Bitmap scans sit in between: they collect matching row locations from one or more indexes, then read the table’s pages in physical order. The planner’s choice rests on statistics, so run ANALYZE and test with realistic data before deciding an index is useless.

Index options#

Composite indexes and column order: a B-tree on (a, b) is sorted by a, then by b within each a. Equality on the leading columns plus a range on the next one narrows the scan; a condition on b alone used to mean reading the whole index, and PostgreSQL 18 added skip scan, which jumps from one a value to the next when there are few of them. Unique indexes (B-tree only in PostgreSQL) enforce a key, treat NULLs as distinct unless declared NULLS NOT DISTINCT, and are created automatically for primary keys and UNIQUE constraints. Expression indexes store a computed value, such as lower(email), so a query that filters on the same expression can use them.

Partial and covering indexes: a partial index has a WHERE clause and holds only the rows that match it, so an index of pending orders stays small and cheap to maintain. A covering index adds payload columns with INCLUDE (PostgreSQL 11 and later) so a query can be answered from the index alone. Such an index-only scan still has to know that each row is visible to the transaction; PostgreSQL keeps that in the visibility map, set by vacuum, and falls back to reading the table for pages that changed recently.

Clustered and heap tables: in MySQL’s InnoDB the table is stored inside its primary-key B-tree (the clustered index), and every secondary index entry carries the primary key to find the row, so a long primary key makes every other index bigger. SQLite stores rowid tables the same way, with all data in the B-tree’s leaves, and Oracle offers index-organized tables. PostgreSQL keeps rows in an unordered heap, and every index, the primary key included, is secondary. Its CLUSTER command sorts the table by an index once, but later writes don’t keep that order.

Partial and covering indexes in PostgreSQL#

The partial index holds only pending orders, so it is about 30 times smaller than an index over every row’s status. The covering index answers a sum straight from the index, reading 4 pages and none of the table.

sql
-- PostgreSQL 18: 1 million orders from 20,000 customers; about 1% are still pending
CREATE TABLE orders (id bigint PRIMARY KEY, customer int, status text, total numeric);
INSERT INTO orders
SELECT i, i % 20000, CASE WHEN i % 97 = 0 THEN 'pending' ELSE 'shipped' END, i % 500
FROM generate_series(1, 1000000) AS i;

-- partial: index only the rows the app asks about
CREATE INDEX orders_status_all ON orders (status, customer);
CREATE INDEX orders_pending ON orders (customer) WHERE status = 'pending';

-- covering: the key to search on, plus a column to read without visiting the table
CREATE INDEX orders_customer ON orders (customer) INCLUDE (total);
VACUUM ANALYZE orders;  -- sets the visibility map, which index-only scans rely on

SELECT relname, pg_size_pretty(pg_relation_size(oid)) AS size
FROM pg_class WHERE relname LIKE 'orders%' ORDER BY relname;
-- orders 57 MB | orders_customer 30 MB | orders_pending 248 kB
-- orders_pkey 21 MB | orders_status_all 7328 kB

SET max_parallel_workers_per_gather = 0;
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id FROM orders WHERE status = 'pending' AND customer = 97;
-- Index Scan using orders_pending on orders (rows=1)
--   Index Cond: (customer = 97)
--   Buffers: shared hit=1 read=2

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT sum(total) FROM orders WHERE customer = 97;
-- Index Only Scan using orders_customer on orders (rows=50)
--   Heap Fetches: 0                         <- the table was never read
--   Buffers: shared hit=1 read=3

In code · sql

-- PostgreSQL 18. Numbers are from one run on a laptop; yours will differ.
-- 10 million sensor readings, one per second, written in time order
CREATE TABLE readings (ts timestamptz NOT NULL, sensor int, value float8);
INSERT INTO readings
SELECT timestamptz '2026-01-01 00:00Z' + i * interval '1 second', i % 100, random()
FROM generate_series(1, 10000000) AS i;

CREATE INDEX readings_btree ON readings USING btree (ts);
CREATE INDEX readings_brin  ON readings USING brin (ts);  -- pages_per_range = 128
VACUUM ANALYZE readings;

SELECT relname, pg_size_pretty(pg_relation_size(oid)) AS size
FROM pg_class WHERE relname LIKE 'readings%' ORDER BY relname;
-- readings 498 MB | readings_brin 32 kB | readings_btree 214 MB

SET max_parallel_workers_per_gather = 0;  -- simpler plans
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT avg(value) FROM readings
WHERE ts >= '2026-03-01 00:00Z' AND ts < '2026-03-01 01:00Z';
-- Index Scan using readings_btree on readings (rows=3600)
--   Buffers: shared read=37
-- Execution Time: 0.903 ms

DROP INDEX readings_btree;  -- leave the planner only the BRIN index
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT avg(value) FROM readings
WHERE ts >= '2026-03-01 00:00Z' AND ts < '2026-03-01 01:00Z';
-- Bitmap Heap Scan on readings (rows=3600)
--   Rows Removed by Index Recheck: 16496
--   Heap Blocks: lossy=128                  <- one 128-page block range
--   ->  Bitmap Index Scan on readings_brin
--         Buffers: shared hit=6             <- the whole index
-- Execution Time: 2.626 ms

-- the same rows in random physical order: every block range spans the year
CREATE TABLE shuffled AS SELECT * FROM readings ORDER BY random();
CREATE INDEX shuffled_brin ON shuffled USING brin (ts);
VACUUM ANALYZE shuffled;
SELECT tablename, correlation FROM pg_stats WHERE attname = 'ts';
-- readings 1 | shuffled -0.004444684

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT avg(value) FROM shuffled
WHERE ts >= '2026-03-01 00:00Z' AND ts < '2026-03-01 01:00Z';
-- Bitmap Heap Scan on shuffled (rows=3600)
--   Rows Removed by Index Recheck: 9996400
--   Heap Blocks: lossy=63744                <- every page of the table
-- Execution Time: 930.778 ms

Often confused with

Where it came from1970–2025

B-trees began as a way to keep the index of a file too big for memory. In July 1970, Rudolf Bayer and Edward McCreight at Boeing Scientific Research Laboratories described B-trees, pages of keys organised so that finding, inserting and deleting a key takes time proportional to the logarithm of the index size, with every page at least half full. They tested indexes of up to 100,000 keys on an IBM 360/44 with a disc. The journal version appeared in Acta Informatica in September 1972, and in 1979 Douglas Comer’s survey was titled “The Ubiquitous B-Tree”.

B-trees need one sort order, and spatial data has none. In 1984 Antonin Guttman at Berkeley published the R-tree for CAD and geographic data, a balanced tree of bounding rectangles. As each new data type seemed to need its own tree, Joseph Hellerstein, Jeffrey Naughton and Avi Pfeffer proposed GiST in 1995: one search tree that behaves like a B+-tree, an R-tree or something new, depending on a few methods written for the type. PostgreSQL rebuilt its R-tree on GiST and added the GIN inverted index in 8.2 (2006), then SP-GiST in 9.2 (2012).

The newest idea is to index less. BRIN, added to PostgreSQL 9.5 in January 2016 by Álvaro Herrera, stores only summaries such as the minimum and maximum for ranges of blocks, so it is very compact and cheap to update yet still speeds up searches when data is naturally clustered. It is the same skipping principle as ClickHouse’s data skipping indexes, Parquet statistics and Exadata storage indexes. PostgreSQL 14 (2021) gave BRIN multiple min/max ranges and Bloom filters for data that isn’t well localised, and PostgreSQL 18 (2025) let B-trees skip scan.

Why it’s built this way

Pages that stay at least half full and split when they overflow
Bayer and McCreight designed for discs and drums with long access times but fast transfer once reading starts, so the index moves a page at a time. Guaranteeing at least 50% use per page keeps the tree shallow and the cost of each operation logarithmic.
Bounding rectangles instead of a sort order
Guttman noted that hash tables only match exact values and B-trees and ISAM rely on a one-dimensional ordering, so neither can answer range searches over objects that cover an area in several dimensions.
One extensible tree instead of a tree per data type
Earlier extensible trees supported only equality and range predicates. GiST put the search tree logic in one piece of code and let an expert in a data type supply its methods, without being a database expert.
Summaries of block ranges instead of an entry per row
On very large tables whose order follows a column, a min and max per range is enough to skip most of the table at a tiny fraction of a B-tree’s size and update cost. The price is a lossy index that needs a recheck and loses its value when the order breaks.

Timeline

  1. 1970

    Bayer and McCreight describe B-trees in a Boeing Scientific Research Laboratories report

  2. 1984

    Guttman publishes the R-tree for spatial searching

  3. 1995

    Hellerstein, Naughton and Pfeffer present GiST at VLDB

  4. 2006

    PostgreSQL 8.2 adds GIN and rebuilds its R-tree on GiST

  5. 2016

    PostgreSQL 9.5 adds BRIN

  6. 2021

    PostgreSQL 14 adds minmax-multi and Bloom filter classes for BRIN

  7. 2025

    PostgreSQL 18 adds skip scan to multicolumn B-tree indexes

Sources45

Improve this page on GitHub