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.
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
UNIQUEconstraint 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.
- LSM treesA storage structure that buffers writes in a sorted in-memory table, flushes them to immutable sorted files and merges those files in the background, trading extra reads and rewrites later for fast, sequential writes now.
- Unbounded queriesA query with no time range, no partition key and no LIMIT, whose cost grows with every row ever stored.
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.
Inverted indexes (GIN) →
One entry per word, element or key, each listing the rows that contain it: for searching inside arrays, JSON documents and text.
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.
| Inverted indexes (GIN) | Open source | PostgreSQL Global Development Group | PostgreSQL License | 2026-10-06 |
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.
| Kind | Answers | Size | Write cost | Typical use |
|---|---|---|---|---|
| B-tree | =, <, >, BETWEEN, IN, IS NULL, LIKE 'foo%', sorted output | One entry per row: 214 MB for 10 million timestamps in a 498 MB table | An entry per row in every index; full pages split | The default: primary keys, foreign keys, unique constraints, most filters |
| Hash | = only | A 4-byte hash per row: 32 MB against a 119 MB B-tree on 1 million URLs | Buckets split in the foreground as the table grows | Equality lookups on long keys such as URLs and UUIDs |
| BRIN (block range) | =, <, > with min/max; other classes add Bloom filters and bounding boxes | One summary per 128 pages: 32 kB for 10 million rows | Very low; new ranges are summarized by vacuum; HOT updates still work | Append-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 table | High: one entry per extracted key; a pending list batches them | Tags, JSON documents, text search, substring search |
| GiST and SP-GiST (spatial) | Overlap &&, containment, nearest neighbour <->, exclusion constraints | One key per row, often a bounding box that needs a recheck | Like a B-tree, plus choosing where each new key fits | Maps (PostGIS), time ranges, no-overlap bookings |
| Bitmap (Oracle) | =, combined with AND and OR across columns | A bitmap per distinct value, each with a bit per row | Changing one row locks every row with the same value | Read-mostly warehouse tables with low-cardinality columns |
| Bloom filter | = on any combination of the indexed columns | An 80-bit signature per row: 153 MB against a 386 MB six-column B-tree | Low; matches are rechecked because of false positives | Wide 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 index | Non-clustered (secondary) index | |
|---|---|---|
| Its leaves hold | The table’s rows themselves, sorted by the index key | The key and a pointer to the row: its physical location in a heap, or its clustered key |
| How many per table | One | As many as you need |
| Finding one row | The search ends on the page that holds the row | A second lookup follows the pointer to the row, unless the index covers every column the query needs |
| Reading a range | Neighboring keys sit on neighboring pages | Matching rows can be anywhere in the table, so each may cost a random read |
| Inserting | Each row goes where its key sorts: an ever-increasing key appends at the end, a random one splits pages in the middle | Every insert, and every change to an indexed column, also updates this index |
| Key size | Copied into every secondary index in InnoDB and SQL Server, so a short key keeps all of them small | Its own columns plus the pointer |
| SQL Server | Created for the PRIMARY KEY by default; a table without one is a heap | UNIQUE 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 ID | Every other index; each entry stores the primary key columns |
| PostgreSQL | None kept in order: tables are heaps, and CLUSTER reorders one once without keeping new rows in order | Every index, the primary key included |
| Oracle | ORGANIZATION INDEX tables, stored in a B-tree by primary key; the default is a heap | Secondary indexes, which find rows by a logical rowid built from the primary key |
| SQLite | Every ordinary table, clustered on its 64-bit rowid; WITHOUT ROWID tables cluster on the declared primary key | Every 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
uniqueidentifierkey is wider than an 8-bytebigintand 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.
-- 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 -> emailWhat 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.
-- 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=3GIN for JSON and substring search#
Two inverted indexes on the same table. The sequential-scan times in the comments were measured with bitmap scans turned off.
-- PostgreSQL 18: 1 million products with a name and a jsonb attributes column
CREATE EXTENSION pg_trgm;
CREATE TABLE products (id bigint PRIMARY KEY, name text, attrs jsonb);
INSERT INTO products
SELECT i, 'Widget ' || md5(i::text),
jsonb_build_object('color', (ARRAY['red','green','blue','black'])[1 + i % 4],
'tags', jsonb_build_array('tag' || i % 1000, 'tag' || i % 7))
FROM generate_series(1, 1000000) AS i;
-- one entry per key and value, listing every row that contains it
CREATE INDEX products_attrs ON products USING gin (attrs jsonb_path_ops);
-- one entry per three-letter piece of the name
CREATE INDEX products_name_trgm ON products USING gin (name gin_trgm_ops);
VACUUM ANALYZE products;
SELECT relname, pg_size_pretty(pg_relation_size(oid)) AS size
FROM pg_class WHERE relname LIKE 'products%' ORDER BY relname;
-- products 135 MB | products_attrs 10 MB | products_name_trgm 114 MB | products_pkey 21 MB
SET max_parallel_workers_per_gather = 0;
EXPLAIN (ANALYZE, COSTS OFF)
SELECT count(*) FROM products WHERE attrs @> '{"tags": ["tag42"]}';
-- Bitmap Index Scan on products_attrs (rows=1000)
-- Execution Time: 18.717 ms (sequential scan: 161.765 ms)
EXPLAIN (ANALYZE, COSTS OFF)
SELECT id, name FROM products WHERE name ILIKE '%c4ca42%';
-- Bitmap Index Scan on products_name_trgm (rows=4)
-- Bitmap Heap Scan on products (rows=2), Rows Removed by Index Recheck: 2
-- Execution Time: 0.940 ms (sequential scan: 669.450 ms)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 msOften 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
- 1970
Bayer and McCreight describe B-trees in a Boeing Scientific Research Laboratories report
- 1984
Guttman publishes the R-tree for spatial searching
- 1995
Hellerstein, Naughton and Pfeffer present GiST at VLDB
- 2006
PostgreSQL 8.2 adds GIN and rebuilds its R-tree on GiST
- 2016
PostgreSQL 9.5 adds BRIN
- 2021
PostgreSQL 14 adds minmax-multi and Bloom filter classes for BRIN
- 2025
PostgreSQL 18 adds skip scan to multicolumn B-tree indexes
- Rudolf Bayer and Edward McCreight: Organization and Maintenance of Large Ordered Indices (Boeing Scientific Research Laboratories report, July 1970)
- Springer: Acta Informatica volume 1, issue 3 (September 1972), with Bayer and McCreight’s “Organization and maintenance of large ordered indexes”
- Hellerstein, Naughton and Pfeffer: Generalized Search Trees for Database Systems (VLDB 1995)
- Antonin Guttman: R-Trees, A Dynamic Index Structure for Spatial Searching (1984)
- PostgreSQL 8.2 release notes: GIN added, R-tree reimplemented on GiST (December 2006)
- PostgreSQL 9.2 release notes: SP-GiST and index-only scans (September 2012)
- PostgreSQL 9.5 release notes: Block Range Indexes (BRIN) by Álvaro Herrera (January 2016)
- PostgreSQL 14 release notes: BRIN minmax-multi and bloom operator classes (September 2021)
- PostgreSQL 18 release notes: B-tree skip scan (September 2025)
- PostgreSQL docs: GiST indexes (a balanced tree framework; B-trees and R-trees can be built on it)
Sources45
- PostgreSQL docs: Indexes, introduction (a few levels of a search tree; indexes add overhead to writes and can prevent HOT updates)
- PostgreSQL docs: Index types (B-tree, hash, GiST, SP-GiST, GIN, BRIN and the operators each serves)
- PostgreSQL docs: B-tree structure, page splits and deduplication
- PostgreSQL docs: Multicolumn indexes (leading columns, skip scan)
- PostgreSQL docs: Index-only scans and covering indexes (secondary indexes, visibility map, INCLUDE)
- PostgreSQL docs: Partial indexes (common values that are more than a few percent of rows won’t use an index)
- PostgreSQL docs: Indexes on expressions
- PostgreSQL docs: Unique indexes (only B-tree; NULLS NOT DISTINCT)
- PostgreSQL docs: Combining multiple indexes (bitmap scans)
- PostgreSQL docs: Examining index usage (ANALYZE, small tables, forcing plans)
- PostgreSQL docs: Heap-only tuples (HOT); BRIN is the only summarizing index
- PostgreSQL docs: CREATE INDEX (CONCURRENTLY, pages_per_range default 128, autosummarize, fastupdate)
- PostgreSQL docs: CLUSTER (a one-time physical reorder)
- PostgreSQL docs: BRIN indexes (block ranges, summarization, minmax, minmax-multi, bloom and inclusion operator classes)
- PostgreSQL docs: GIN indexes (key and posting list pairs, posting trees, pending list)
- PostgreSQL docs: GiST indexes (a balanced tree framework; B-trees and R-trees can be built on it)
- PostgreSQL docs: SP-GiST indexes (quad-trees, k-d trees, radix trees)
- PostgreSQL docs: Hash indexes (4-byte hash codes, equality only, buckets and overflow pages)
- PostgreSQL docs: bloom extension (signatures, parameters, example, limitations)
- PostgreSQL docs: JSON types, jsonb indexing (jsonb_ops and jsonb_path_ops)
- PostgreSQL docs: pg_trgm (gin_trgm_ops and gist_trgm_ops)
- PostgreSQL docs: Constraints (exclusion constraints with EXCLUDE USING gist)
- PostgreSQL 10 release notes: write-ahead logging for hash indexes (October 2017)
- PostgreSQL 11 release notes: covering indexes with INCLUDE (October 2018)
- PostgreSQL 13 release notes: B-tree deduplication (September 2020)
- PostgreSQL 14 release notes: BRIN minmax-multi and bloom operator classes (September 2021)
- PostgreSQL 18 release notes: B-tree skip scan (September 2025)
- MySQL 8.4 Reference Manual: Clustered and secondary indexes (InnoDB)
- MySQL 8.4 Reference Manual: Comparison of B-tree and hash indexes
- MySQL 8.4 Reference Manual: Adaptive hash index
- MySQL 8.4 Reference Manual: What is new in MySQL 8.4 (innodb_adaptive_hash_index now OFF by default)
- MySQL 8.4 Reference Manual: The MEMORY storage engine (hash indexes by default)
- SQLite: Database file format (table b-trees keep all data in the leaves)
- Oracle Database 23ai Concepts: Indexes and index-organized tables (bitmap indexes, locking, IOTs)
- ClickHouse docs: Data skipping indexes (no row-based secondary indexes, minmax, bloom_filter, GRANULARITY, correlation)
- Apache Parquet docs: Bloom filter (min/max statistics and dictionaries, split block Bloom filters)
- Apache Parquet docs: Page index (statistics to skip pages)
- Oracle: Exadata storage indexes (in-memory min/max per storage region)
- RocksDB wiki: RocksDB Bloom filter (a filter per SST file, bits per key and false-positive rates)
- pgvector on GitHub: HNSW and IVFFlat indexes for approximate nearest-neighbour search
- PostGIS workshop: Spatial indexing (bounding boxes, R-tree on GiST)
- Microsoft Learn: Clustered and nonclustered indexes (one clustered index per table, heaps, row locators, primary key creates a clustered index)
- Microsoft Learn: Index architecture and design guide (clustered key: narrow, unique, ever-increasing, immutable; page splits)
- SQLite: Clustered indexes and the WITHOUT ROWID optimization
- SQLite: Rowid tables (the rowid is the B-tree key; PRIMARY KEY is a unique index unless INTEGER PRIMARY KEY)