Database Index Internals: B-Tree, GIN, GiST & BRIN Architecture on Linux
Created: 2026-11-08 · Last updated: 2026-11-08 · Nejat Hakan
Executive Summary: An index trades disk space and write overhead for dramatic reductions in read query latency—transforming an O(N) full sequential table scan into an O(log N) tree traversal or O(1) bitmap lookup. However, applying default B-Trees indiscriminately to JSON payloads, geometric ranges, or multi-terabyte append-only time-series tables results in massive index bloat and cache thrashing. This guide explores the physical data structures and algorithms powering B-Tree, GIN, GiST, and BRIN indexes, alongside production patterns like Covering Indexes (
INCLUDE), Partial Indexes, and non-blockingCREATE INDEX CONCURRENTLY.
-
B-Tree Architecture
Traverse balanced N-ary search trees: navigate root and branch routing pages down to doubly-linked leaf pages for range scans.
-
GIN for JSONB & Arrays
Invert unstructured payloads into posting lists: accelerate containment (
@>) and key existence lookups onJSONBdocuments. -
:material-compress:{ .lg .middle } BRIN Time-Series Compression
Index multi-terabyte append-only tables with 99% less disk space: summarize minimum and maximum values per physical 1 MB block range.
-
Covering & Partial Indexes
Eliminate heap pointer hops via
INCLUDEfor Index-Only Scans, and index strictly active rows (WHERE status = 'PENDING').
1. Index Architecture & Traversal Mechanics
Choosing the correct index structure depends on the mathematical nature of the data and query predicates:
| Index Type | Underlying Data Structure | Optimal Query Operators | Primary Production Workload |
|---|---|---|---|
| B-Tree (Default) | Balanced N-ary tree with horizontally linked leaf pages | =, <, <=, >, >=, BETWEEN, IN, ORDER BY |
High-cardinality scalar columns (id, email, created_at, foreign keys). |
| GIN (Generalized Inverted) | Inverted lookup map of component elements → posting lists of ctids |
@>, ?, ?&, ?|, @@ (Full-Text Search) |
Nested JSONB documents, array types (TEXT[]), and full-text search tsvector. |
| GiST (Generalized Search Tree) | Lossy balanced tree supporting arbitrary bounding boxes | && (Overlap), <-> (KNN distance), @> |
PostGIS geographic coordinates, IP network subnets (INET), and exclusion constraints. |
| BRIN (Block Range Index) | Min/Max boundary summary per physical block range (default 128 pages) | <, >, BETWEEN on correlated physical data |
Multi-terabyte append-only audit logs, IoT sensor data, and time-series telemetry. |
%%{init: {'flowchart': {'wrappingWidth': 600}}}%%
flowchart TB
subgraph BTree["🌳 B-Tree: High-Precision O(log N)"]
B_ROOT["Root Page<br/>Keys: [1000 | 5000]"]
B_INT["Internal Routing Node<br/>Keys: [250 | 750]"]
B_LEAF1["Leaf Page 1 (Sorted)<br/>ctid -> (Block 4, Slot 1)"]
B_LEAF2["Leaf Page 2 (Sorted)<br/>ctid -> (Block 4, Slot 2)"]
B_ROOT --> B_INT
B_INT --> B_LEAF1
B_INT --> B_LEAF2
B_LEAF1 <-->|"Doubly-Linked Scan"| B_LEAF2
end
subgraph GIN["📄 GIN: Inverted Key Map"]
G_KEY["JSON Key: 'role'"]
G_POST["Posting List<br/>ctid: [(1,2), (5,8), (9,1)]"]
G_KEY --> G_POST
end
subgraph BRIN["📦 BRIN: Block-Range Summary"]
BR_RANGE["Pages 0–127 (1 MB Block)"]
BR_SUM["Min: 2026-01-01<br/>Max: 2026-01-07"]
BR_RANGE --> BR_SUM
end
2. B-Tree Internals: Why Leaf Doubly-Linked Lists Matter
In a B-Tree index, all leaf pages reside at the exact same depth from the root (guaranteeing uniform O(log N) search latency).
Fast Range Scans Without Root Traversal
When an application queries WHERE created_at BETWEEN '2026-01-01' AND '2026-01-31':
1. The engine traverses from the Root page down through internal routing nodes to find the leaf page containing the start date '2026-01-01'.
2. Once the starting leaf page is located, the engine never re-traverses the root. It follows the horizontal doubly-linked pointers (next_page / prev_page) directly between leaf pages until it encounters '2026-01-31'.
3. This architecture makes sequential range scans and ORDER BY operations run at near-memory bandwidth.
3. GIN (Generalized Inverted Index) for JSONB
Standard B-Trees index entire column values as single monolithic entities. A GIN Index decomposes composite values (such as JSONB documents or arrays) into individual component keys and values:
Document 1: {"role": "admin", "team": "devops"}
Document 2: {"role": "viewer", "team": "devops"}
GIN Inverted Map:
"admin" --> [Doc 1]
"viewer" --> [Doc 2]
"devops" --> [Doc 1, Doc 2]
jsonb_ops vs. jsonb_path_ops
PostgreSQL provides two operator classes for GIN indexing:
- jsonb_ops (Default): Indexes every key, value, and path. Supports path lookups (?, ?|, @>), but results in larger index footprints.
- jsonb_path_ops: Hashes the full path-value pair (e.g., hash("team.devops")). Produces an index up to 60% smaller and faster, but only supports containment queries (@>).
-- Production Best Practice: Fast containment indexing on JSONB
CREATE INDEX CONCURRENTLY idx_customer_metadata_gin
ON customer_orders USING GIN (metadata jsonb_path_ops);
4. BRIN (Block Range Index): Scaling to Multi-Terabytes
On massive time-series tables (e.g., 500 million rows of server metrics), a B-Tree index can consume hundreds of gigabytes of RAM, exceeding available buffer pool capacity.
If rows are inserted in natural chronological order, the data in physical storage blocks is already sorted:
Physical Storage on Disk:
Block 0-127 --> Timestamps from 2026-01-01 to 2026-01-05
Block 128-255 --> Timestamps from 2026-01-06 to 2026-01-10
Block 256-383 --> Timestamps from 2026-01-11 to 2026-01-15
A BRIN Index does not record pointers for individual rows. It simply stores the min and max values for each contiguous range of physical pages (default 128 pages = 1 MB):
- When querying WHERE recorded_at = '2026-01-08', the engine skips Blocks 0–127 and Blocks 256–383 entirely.
- A BRIN index is typically 100× to 1000× smaller than a B-Tree, fitting entirely inside the CPU L3 cache.
-- Production Best Practice: BRIN index for chronological time-series tables
CREATE INDEX CONCURRENTLY idx_server_metrics_brin
ON server_metrics USING BRIN (recorded_at)
WITH (pages_per_range = 128);
5. Advanced Production Indexing Patterns
A. Covering Indexes (INCLUDE) for Index-Only Scans
When a query selects columns not present in a secondary index, the engine must make a random I/O hop to the table heap page for every matching row.
Using INCLUDE appends non-key payload columns to the B-Tree leaf pages without adding them to the search key:
-- Allows dashboard queries to be answered ENTIRELY from the index (Index-Only Scan)
CREATE INDEX CONCURRENTLY idx_orders_customer_covering
ON customer_orders (customer_id)
INCLUDE (total_cents, created_at);
-- Query:
SELECT customer_id, total_cents, created_at
FROM customer_orders
WHERE customer_id = 101; -- Zero Heap Hops if Visibility Map is clean!
B. Partial Indexes: Indexing strictly what matters
If 99% of orders have status = 'COMPLETED' and only 1% are status = 'PENDING', indexing the entire table wastes disk space and slows down inserts. A Partial Index filters rows at index-creation time:
-- Index shrinks by 99%, writes to completed orders cause zero index updates!
CREATE INDEX CONCURRENTLY idx_orders_pending_queue
ON customer_orders (created_at)
WHERE status = 'PENDING';
6. The Production Golden Rule: CREATE INDEX CONCURRENTLY
A standard CREATE INDEX command acquires a SHARE lock on the table, blocking all concurrent INSERT, UPDATE, and DELETE operations for the entire duration of the build:
-- NEVER RUN THIS IN PRODUCTION ON LARGE TABLES:
CREATE INDEX idx_orders_customer ON customer_orders (customer_id);
-- Blocks all customer checkouts and payments until the index finishes!
-- ALWAYS USE CONCURRENTLY IN PRODUCTION:
CREATE INDEX CONCURRENTLY idx_orders_customer ON customer_orders (customer_id);
-- Scans the table in two background passes; writes continue completely unhindered!
Handling Failed Concurrent Builds
If a concurrent build fails (e.g., due to a deadlock or unique constraint violation), PostgreSQL marks the index as INVALID. Invalid indexes consume disk space and slow down writes without serving queries. Always audit and clean invalid indexes: