PostgreSQL Indexing: The Complete Guide to B-Tree, BRIN, GIN, GiST & Covering Indexes
This article is available in English. Browse translated guides in the language menu.
When a PostgreSQL query slows down under production traffic, the instinctive reaction is often: “Just add an index.”
In high-concurrency systems, adding an index without understanding physical storage access methods can degrade overall throughput rather than improve it. Every index on a table imposes a write penalty on INSERT, UPDATE, and DELETE operations, consumes memory in PostgreSQL’s shared_buffers, generates Write-Ahead Logging (WAL) traffic, and disrupts Heap-Only Tuple (HOT) optimizations.
To build databases that handle tens of thousands of transactions per second while maintaining single-digit millisecond read latencies, database engineers must look past generic advice. You need to understand how the storage engine organizes leaf pages, how different access methods evaluate operator classes, and how the query cost planner decides between a Sequential Scan, an Index Scan, a Bitmap Index Scan, and an Index-Only Scan.
This guide provides an end-to-end architectural breakdown of PostgreSQL indexing: from internal algorithms to production diagnostic scripts.
1. The Anatomy and Overhead of an Index
An index is an auxiliary data structure on disk that maps column values to physical tuple identifiers (TID, or ItemPointerData), formatted as (block_number, offset_number). When PostgreSQL executes a lookup using an index, it traverses the index structure to locate matching TIDs, then visits the corresponding 8KB table heap page to retrieve the full row.
+-------------------------------------------------------------+
| Query with Predicate |
| SELECT * FROM orders WHERE id = 1042; |
+-------------------------------------------------------------+
|
v
+-------------------------------------------------------------+
| Index Access Method (e.g. B-Tree) |
| Traverses MetaPage -> Root -> Internal Nodes -> Leaf Page |
| Result: TID = (Block 412, Offset 8) |
+-------------------------------------------------------------+
|
v
+-------------------------------------------------------------+
| Heap Page (Block 412) |
| Fetches row from 8KB page in shared_buffers or disk |
| Applies MVCC snapshot visibility check on HeapTupleHeader |
+-------------------------------------------------------------+
The Physical Trade-Offs
Before adding an index, consider its four primary system costs:
- Write Amplification: Every
INSERTwrites to the heap and to every index on that table. AnUPDATEmodifying indexed columns must insert a new index entry into the respective index trees. - HOT (Heap-Only Tuple) Invalidation: PostgreSQL can update rows inside the existing 8KB heap block without updating index pointers—provided none of the updated columns are indexed. As soon as you index a column updated frequently (such as
updated_atorstatus), you disable HOT optimization for updates affecting that column, driving write amplification and table bloat. - Buffer Pool Cache Churn: Index pages compete with table heap pages and query working memory for space inside
shared_buffers. Cold, oversized indexes evict warm data pages, increasing disk I/O. - Maintenance Overhead: Autovacuum must scan every index on a table during vacuuming to unmark dead item pointers, extending cleanup duration on write-heavy tables.
2. PostgreSQL Index Access Methods: Architecture & Mechanics
PostgreSQL supports six distinct index access methods natively. Selecting the wrong type can mean the difference between a 20GB index that takes 400ms to scan and a 50MB index that answers in 0.8ms.
+-----------+----------------------+-----------------------------+--------------------------+
| Type | Underlying Algorithm | Best Data Types | Supported Operators |
+-----------+----------------------+-----------------------------+--------------------------+
| B-Tree | Lehman & Yao B-Tree | Integers, Text, Timestamps | =, <, <=, >, >=, BETWEEN |
| BRIN | Block Range Summaries| Sequential Numbers, Dates | =, <, <=, >, >=, BETWEEN |
| GIN | Inverted Index | JSONB, Arrays, tsvector | @>, ?, ?, &&, @@ |
| GiST | Generalized Search | PostGIS, Ranges, Geometry | &&, @>, <@, <<, &<, <-> |
| SP-GiST | Space-Partitioned | Prefixes, Multi-Dim Points | <<, >>, @>, ~= |
| Hash | Linear Hash Bucket | High-cardinality exact text | = |
+-----------+----------------------+-----------------------------+--------------------------+
A. B-Tree: The General-Purpose Standard
B-Tree is PostgreSQL’s default access method. It implements the high-concurrency Lehman & Yao B-Tree algorithm, featuring right-sibling pointers (high keys) that permit concurrent page splits without requiring exclusive write locks across the entire parent path.
A PostgreSQL B-Tree consists of:
- Meta Page: Stores tree depth, root block address, and fast-root metadata.
- Root and Internal Pages: Store non-leaf item pointers directing traversals downward.
- Leaf Pages: Store key values alongside physical
ItemPointerData(TIDs) pointing to the table heap, sorted in ascending or descending collation order. Double-linked leaf chains allow bidirectional range scans (ORDER BY ASCandORDER BY DESC).
+-----------------------+
| Root Node |
| [Keys: 100, 500] |
+-----------+-----------+
|
+----------------+----------------+
| |
+-----------v-----------+ +-----------v-----------+
| Internal Node | ------> | Internal Node |
| [Keys: 20, 50, 80] | | [Keys: 200, 350, 420]|
+-----------+-----------+ +-----------+-----------+
|
+-------+-------+
| |
+---v---+ +---v---+
| Leaf | <---> | Leaf | (Contains TIDs: block_num, offset_num)
+-------+ +-------+
Multi-Column B-Tree Indexing: Column Ordering Rules
When constructing composite B-Tree indexes, column order determines index utility. A composite index on (tenant_id, created_at, status) is stored sorted first by tenant_id, then by created_at, and lastly by status.
- Matches:
WHERE tenant_id = 5 AND created_at >= '2026-01-01'(Index Range Scan) - Matches:
WHERE tenant_id = 5(Index Prefix Scan) - Inefficient / Skip Scan:
WHERE created_at >= '2026-01-01'(Cannot prune by root branch; requires a full index scan or will be discarded in favor of a sequential scan).
Rule: Place strict equality predicate columns first, followed by range/inequality filter columns, and finally sorting columns.
B. BRIN (Block Range Index): Big Data & Time Series
When tables grow to hundreds of millions or billions of rows—such as audit logs, event trackers, metrics, and financial ledgers—B-Trees become massive. A B-Tree on a 500GB table can consume 80GB to 120GB of disk space, exceeding available memory.
BRIN (Block Range Index) solves this footprint challenge for datasets where physical storage order correlates with column values (e.g., auto-incrementing IDs or append-only timestamps).
Instead of storing an entry for every single row, BRIN divides the table into contiguous ranges of 8KB blocks (default: pages_per_range = 128, representing 1MB of heap data) and stores only two values per range: minimum value and maximum value.
Physical Heap Blocks (128 pages = ~1MB):
+---------------------------------------------------------------+
| Block 00000 -> Block 00127: created_at: [2026-01-01 to 2026-01-03] |
| Block 00128 -> Block 00255: created_at: [2026-01-03 to 2026-01-05] |
| Block 00256 -> Block 00383: created_at: [2026-01-05 to 2026-01-07] |
+---------------------------------------------------------------+
^
| (Summary Metadata Only)
+---------------------------------------------------------------+
| BRIN Index Entry |
| Range 0: Min: 2026-01-01, Max: 2026-01-03 -> Block 00000-00127|
| Range 1: Min: 2026-01-03, Max: 2026-01-05 -> Block 00128-00255|
+---------------------------------------------------------------+
When evaluating WHERE created_at = '2026-01-04', PostgreSQL reads the lightweight BRIN index, skips ranges whose [min, max] intervals do not contain the target date, and scans only the matching 128-page blocks.
-- Create a BRIN index on append-only event data
CREATE INDEX idx_events_timestamp_brin
ON event_logs
USING brin (event_timestamp)
WITH (pages_per_range = 64);
Size Comparison
- Table Size: 200,000,000 rows (42 GB)
- Standard B-Tree Index: ~4,800 MB
- BRIN Index: ~1.4 MB (Over 3,000x smaller)
Gotcha: If rows are inserted out of order, or if
UPDATEoperations shuffle data across heap pages, correlation degrades (pg_stats.correlationdrops toward 0), and BRIN loses its pruning efficiency.
C. GIN (Generalized Inverted Index): JSONB, Arrays & Full Text
A standard B-Tree indexes a column as a whole atomic unit. A GIN index decomposes complex, multi-valued items into individual component elements (keys, tokens, array values) and creates an inverted mapping pointing back to the row TIDs containing that element.
Use GIN for:
- Querying structured documents (
jsonb) using containment operators (@>,?,?|) - Searching array columns (
tags text[]) using overlap (&&) or contains (@>) - Full-text search on
tsvectorcolumns using match operators (@@)
Document 1: tags = ['sql', 'postgres', 'backend']
Document 2: tags = ['postgres', 'docker']
Document 3: tags = ['sql', 'analytics']
GIN Inverted Index:
'analytics' -> [Doc 3]
'backend' -> [Doc 1]
'docker' -> [Doc 2]
'postgres' -> [Doc 1, Doc 2]
'sql' -> [Doc 1, Doc 3]
Internal Structure: Posting Lists vs. Posting Trees
For elements that appear in a modest number of rows, GIN stores row pointers as a compressed flat array called a Posting List. When an element becomes extremely frequent (e.g., thousands of rows contain the same tag), the posting list expands into an independent B-Tree of TIDs called a Posting Tree.
The GIN Write Trap: fastupdate
Because updating inverted index entries for every item token in every inserted row is expensive, GIN provides a buffering mechanism called fastupdate:
CREATE INDEX idx_audit_payload_gin
ON audit_records
USING gin (payload jsonb_path_ops)
WITH (fastupdate = on);
When fastupdate = on (default), incoming inserts write to an unindexed “pending list” page buffer. When the pending list exceeds gin_pending_list_limit (default 4MB) or during VACUUM, PostgreSQL flushes and merges the entire list into the primary GIN structures.
The trade-off: Any random query that triggers the pending list flush will stall while merging the backlog, causing unpredictable latency spikes. In high-write systems, tune gin_pending_list_limit or disable fastupdate to balance insert latency.
D. GiST (Generalized Search Tree): Geometric & Range Types
GiST is a balanced, tree-structured access template that allows custom data types to define their own clustering rules. Unlike B-Tree (which requires strict linear ordering: $a < b$), GiST operates on arbitrary bounding predicates.
Key use cases:
- Spatial / Geospatial: PostGIS spatial queries (
ST_DWithin,ST_Intersects) using Minimum Bounding Boxes (MBR / R-Tree). - Range Types: Overlapping interval searches (
tstzrange,daterange,int4range) using the overlap operator (&&). - Nearest Neighbor (K-Nearest Neighbor / KNN): Sorting rows by geometric or vector distance using the distance operator (
<->):SELECT id, address FROM locations ORDER BY coordinates <-> point(37.7749, -122.4194) LIMIT 5; - Exclusion Constraints: Preventing double-booking or room scheduling collisions at the database level:
ALTER TABLE hotel_reservations ADD CONSTRAINT no_overlapping_bookings EXCLUDE USING gist (room_id WITH =, reservation_period WITH &&);
3. Advanced Indexing Strategies for Senior Architects
A. Covering Indexes with INCLUDE (Index-Only Scans)
When PostgreSQL runs an Index Scan, it traverses the index, extracts row pointers, and visits the 8KB heap pages on disk to fetch the non-indexed columns requested by the SELECT list.
A Covering Index includes additional payload columns directly inside the leaf pages of the index, allowing the engine to satisfy the entire query from the index alone—performing an Index-Only Scan without touching the table heap.
-- Traditional index: requires fetching 'email' and 'created_at' from heap
CREATE INDEX idx_users_org ON users (organization_id);
-- Covering index using the INCLUDE clause
CREATE INDEX idx_users_org_covering
ON users (organization_id)
INCLUDE (email, created_at);
B-Tree Structure with INCLUDE:
Root / Internal Nodes : Index Keys ONLY (organization_id)
Leaf Pages : Index Keys (organization_id) + Payload (email, created_at) + TID
Why INCLUDE is better than Composite Indexing:
- Tree Height: Payload columns are stored only in leaf pages, keeping root and internal pages compact and reducing tree depth.
- Constraint Enforcement:
UNIQUE (organization_id) INCLUDE (email)enforces uniqueness strictly onorganization_idwhile still cachingemailin the leaf nodes.
Critical Caveat: An Index-Only Scan still checks the table’s Visibility Map (VM) to verify that the heap tuple is visible to all active transactions. If the table suffers from delayed autovacuuming, the visibility map will be out-of-date, and the planner must visit the heap anyway.
B. Partial Indexes: Shrinking Index Size by 90%+
Most enterprise tables have heavily skewed data distributions: 98% of rows are in state ARCHIVED or PROCESSED, while queries focus exclusively on the remaining 2% in state PENDING or FAILED.
A Partial Index contains only entries that satisfy a predefined WHERE filter clause.
-- Inefficient: Indexes 50,000,000 rows, of which 49,500,000 are 'COMPLETED'
CREATE INDEX idx_jobs_status ON background_jobs (status, scheduled_at);
-- Partial Index: Indexes only the 500,000 actionable active jobs
CREATE INDEX idx_jobs_pending
ON background_jobs (scheduled_at)
WHERE status IN ('QUEUED', 'PROCESSING');
Benefits:
- Index Size: Reduces index disk footprint from multiple gigabytes to mere megabytes.
- Write Performance: Inserts and updates on rows where
status = 'COMPLETED'completely bypass the index, generating zero index maintenance overhead. - Soft Deletes: Ideal for tables with
deleted_at:CREATE UNIQUE INDEX uq_user_tenant_email ON users (tenant_id, email) WHERE deleted_at IS NULL;
C. Expression / Functional Indexes
PostgreSQL cannot use an index on email if your query applies a transformation function:
-- Index on 'email' WILL NOT be used here:
SELECT * FROM users WHERE LOWER(email) = '[email protected]';
An Expression Index precomputes the result of a function or expression during row insertion and stores that computed value in the index tree:
CREATE INDEX idx_users_lower_email
ON users (LOWER(email));
Requirements:
- The expression function must be marked
IMMUTABLE(guaranteed to return the exact same output for the same input across all configurations and timezones). Functions likeNOW()or timezone-dependent formatting cannot be indexed. - For JSONB document querying without GIN:
CREATE INDEX idx_events_customer_id ON raw_events (((payload->'customer'->>'id')::uuid));
4. How the PostgreSQL Planner Evaluates Indexes
PostgreSQL relies on a Cost-Based Optimizer (CBO). It estimates the sequential I/O cost (seq_page_cost = 1.0), random disk seek cost (random_page_cost = 4.0), CPU operator cost (cpu_operator_cost = 0.0025), and table statistics from pg_statistic to pick an access path.
[ Query Planner ]
|
+----------------------+----------------------+
| |
(High Selectivity) (Low Selectivity)
Small % of Rows Large % of Rows
| |
[ Index Lookup ] [ Sequential Scan ]
| Reads 8KB pages linearly
+-----------+-----------+ Uses OS read-ahead cache
| |
[ Index Scan ] [ Bitmap Index Scan ]
Direct heap pointer Collects matching TIDs into
lookup row-by-row in-memory bitmap, sorts by
physical page ID, visits heap
sequentially
The 4 Execution Scan Types Explained
1. Sequential Scan (Seq Scan)
Reads every 8KB page from disk sequentially from start to end.
- When used: Small tables (under ~1,000 rows) or queries retrieving more than 10–20% of the table’s total rows. Sequential scans leverage OS read-ahead caching and avoid random I/O.
2. Index Scan
Traverses the index, fetches a TID, and reads that tuple immediately from the heap page.
- When used: Queries retrieving a tiny fraction of total rows (typically < 2%) ordered by an index key. If many rows match, repeated random access to disk blocks makes this slower than a Sequential Scan.
3. Bitmap Index Scan & Bitmap Heap Scan
A two-phase hybrid approach designed to eliminate random disk seeks:
Bitmap Index Scan: Scans the index and builds an in-memory bitmap array of target heap page numbers where matching rows reside.Bitmap Heap Scan: Sorts those page numbers in physical disk block order and reads each heap block sequentially.
- Bonus: PostgreSQL can combine multiple independent indexes for a single query using bitwise boolean operations (
BitmapAnd/BitmapOr):-- Uses idx_orders_customer AND idx_orders_status simultaneously! SELECT * FROM orders WHERE customer_id = 92 AND status = 'SHIPPED';
4. Index-Only Scan
Retrieves all requested data directly from the index pages without visiting the heap, provided the page is marked all-visible in the Visibility Map.
Diagnosing with EXPLAIN (ANALYZE, BUFFERS)
Never evaluate index performance with raw execution time alone; cache status can hide cold-start latency. Always inspect buffer access counters:
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS)
SELECT email, created_at
FROM users
WHERE organization_id = 42
ORDER BY created_at DESC
LIMIT 20;
What to Look For:
Index Only Scan using idx_users_org_covering on public.users
Output: email, created_at
Index Cond: (users.organization_id = 42)
Heap Fetches: 0
Buffers: shared hit=4 read=0
Planning Time: 0.124 ms
Execution Time: 0.048 ms
Heap Fetches: 0: Confirms a pure Index-Only Scan. If this number is high, your Visibility Map needsVACUUMto eliminate heap lookups.shared hit: Pages found already warm inside PostgreSQL’sshared_buffers.shared read: Physical read requests delegated down to the OS kernel / disk storage.shared dirtied: Pages modified in memory that must eventually be flushed by the checkpoint engine.
5. Production Operations: Zero-Downtime Indexing & Maintenance
Running CREATE INDEX on a production table acquires a SHARE lock. While this allows concurrent SELECT statements, it blocks all concurrent INSERT, UPDATE, and DELETE operations until the entire index build finishes. On a 100-million-row table, this can cause transaction queueing, connection pool exhaustion, and production outages.
A. Zero-Downtime Index Creation: CONCURRENTLY
Always build production indexes using the CONCURRENTLY modifier:
CREATE INDEX CONCURRENTLY idx_transactions_account_id
ON transactions (account_id);
How Concurrent Indexing Operates Internally:
- Pass 1: Enters a short lock to register the index catalog metadata, then starts Transaction 1 to scan the table heap and build the initial index. Concurrent writes continue unimpeded.
- Pass 2: Starts Transaction 2 to catch up on any row inserts, updates, and deletes that occurred while Pass 1 was running.
- Reference Wait: Waits for all existing transactions that opened before Pass 2 to terminate, ensuring no visibility conflicts remain before marking the index valid (
indisvalid = true).
Handling Invalid Indexes:
If a concurrent build fails (e.g., deadlock, query cancel, or a unique constraint collision), PostgreSQL retains the partial index marked INVALID. It will not be used for queries, yet it will continue to incur write penalties on every insert!
Find and clean invalid indexes:
-- 1. Identify invalid indexes
SELECT
schemaname,
relname AS table_name,
indexrelname AS index_name,
pg_relation_size(indexrelid) AS bytes
FROM pg_stat_user_indexes
JOIN pg_index USING (indexrelid)
WHERE indisvalid = false;
-- 2. Drop the invalid index concurrently (never run a standard DROP INDEX)
DROP INDEX CONCURRENTLY idx_transactions_account_id;
B. Index Bloat Remediation: REINDEX CONCURRENTLY
Frequent UPDATE and DELETE statements leave dead slots inside B-Tree leaf pages. When page utilization drops below 50%, the index tree becomes bloated, wasting memory and slowing down range scans.
In PostgreSQL 12+, rebuild bloated indexes online with zero read/write downtime:
REINDEX INDEX CONCURRENTLY idx_orders_customer_id;
6. The Production DBA Diagnostic Toolkit
Keep these three battle-tested diagnostic queries in your runbook to audit database health, eliminate deadweight indexes, and detect duplicate index paths.
Query 1: Find Unused and Low-Yield Indexes
Every unused index is pure latency overhead on writes and waste of buffer cache.
SELECT
schemaname || '.' || relname AS table_name,
indexrelname AS index_name,
pg_size_pretty(pg_relation_size(i.indexrelid)) AS index_size,
idx_scan AS number_of_scans,
idx_tup_read AS tuples_read,
idx_tup_fetch AS tuples_fetched
FROM pg_stat_user_indexes ui
JOIN pg_index i ON ui.indexrelid = i.indexrelid
WHERE
idx_scan < 50
AND NOT indisunique
AND pg_relation_size(i.indexrelid) > 10 * 1024 * 1024 -- Only check indexes > 10MB
ORDER BY pg_relation_size(i.indexrelid) DESC;
Query 2: Detect Duplicate and Redundant B-Tree Indexes
An index on (tenant_id, user_id) makes an independent index on (tenant_id) completely redundant.
SELECT
pg_size_pretty(pg_relation_size(c.oid)) AS redundant_size,
c.relname AS redundant_index,
pg_size_pretty(pg_relation_size(c2.oid)) AS covering_size,
c2.relname AS covering_index,
pg_get_indexdef(c.oid) AS redundant_definition,
pg_get_indexdef(c2.oid) AS covering_definition
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_index i2 ON i.indrelid = i2.indrelid AND i.indexrelid != i2.indexrelid
JOIN pg_class c2 ON c2.oid = i2.indexrelid
WHERE
i.indisvalid AND i2.indisvalid
AND NOT i.indisunique
AND array_to_string(i.indkey, ' ') ~ ('^' || array_to_string(i2.indkey, ' ') || '.*')
AND pg_relation_size(c.oid) > 0;
Query 3: Real-Time Cache Hit Ratio by Index
Identifies cold indexes that trigger high physical disk read I/O rather than serving from memory.
SELECT
schemaname || '.' || relname AS table_name,
indexrelname AS index_name,
idx_blks_hit AS buffer_hits,
idx_blks_read AS disk_reads,
ROUND(
100.0 * idx_blks_hit / NULLIF(idx_blks_hit + idx_blks_read, 0),
2
) AS cache_hit_percentage
FROM pg_statio_user_indexes
WHERE (idx_blks_hit + idx_blks_read) > 1000
ORDER BY cache_hit_percentage ASC;
7. Architecture Summary: The Indexing Decision Framework
Follow this structured protocol before shipping any index to staging or production:
- Verify Cardinality & Selectivity: Never create a standard B-Tree index on low-cardinality values (e.g., boolean flags or status codes with 3 states) unless used as a Partial Index.
- Order Composite Keys by Query Predicates: Place strict equality filter columns first, followed by range boundaries, and finish with sort order columns.
- Use
INCLUDEfor Read-Heavy Projections: If an index scan visits the heap solely to fetch 1 or 2 small scalar values, append them viaINCLUDE (...)to enable pure Index-Only Scans. - Choose BRIN for Monotonic Sequences: For append-only audit tables and time-series ledgers exceeding 20GB, deploy BRIN to reduce index storage footprint by over 95%.
- Always Use
CONCURRENTLYin Production: Never execute bareCREATE INDEXorREINDEXon active operational tables. - Audit and Prune Quarterly: Run the diagnostic scripts above every quarter to drop unused indexes, reclaim write throughput, and minimize checkpoint I/O spikes.
Ready to Level Up Your SQL?
Don't just read about databases. Write queries, build schemas, and practice real-world interview questions interactively.
Start Learning for Free