Database Indexes Explained: How They Actually Improve Queries

A deep dive into database indexes: storage pages, B+Tree anatomy, composite selectivity, covering indexes, write amplification, and query execution plan analysis.

November 2, 202519 min readOpenStair Engineering
Diagram showing database tables, indexes, queries, and connection flow

Database Indexes Explained: How They Actually Improve Queries

When a relational database executes a query against an unindexed column, it has only one option: read every data block allocated to the table from start to finish. In textbook terminology, this is a full table scan; in PostgreSQL execution plans, it appears as a Seq Scan. On a table with a few hundred rows, this operation completes in microseconds. On a table containing tens of millions of rows, it requires reading gigabytes of data from disk or buffer memory, saturating disk I/O channels, evicting active cache pages, and stalling concurrent transactions.

Database indexes solve this bottleneck, but they are frequently misunderstood. An index is not a generic magical accelerator that can be applied indiscriminately to every column in a WHERE clause. It is a specialized, physically persisted data structure that trades disk space and write throughput for predictable, low-latency read access.

To design efficient indexing strategies, backend engineers and database administrators must understand what occurs beneath the SQL abstraction layer: how storage engines organize data into fixed-size pages, how B+Trees navigate hierarchies of internal and leaf blocks, how multi-column indexing enforces strict selectivity boundaries, and why write amplification and index bloat can degrade write-heavy systems.


1. Storage Engine Fundamentals: Pages, Buffers, and I/O Costs

Relational database management systems (RDBMS) do not interact with persistent storage in terms of individual rows or individual column values. They read and write data in fixed-size units known as pages (or blocks).

  • In PostgreSQL, the default page size is 8 KB (BLCKSZ = 8192 bytes).
  • In MySQL InnoDB, the default page size is 16 KB (innodb_page_size = 16384 bytes).

The Table Heap vs. The Index

In PostgreSQL and standard heap-organized databases, table rows are stored in the table heap—a collection of 8 KB data pages appended as rows are inserted. A heap page contains page headers, line pointers (item IDs), and tuple data. Rows within the heap are generally unordered. Every tuple in the heap is uniquely identified by a physical address called a Tuple Identifier or ctid, structured as:

ctid=(block_number,offset_number)\text{ctid} = (\text{block\_number}, \text{offset\_number})

For example, a ctid of (4210, 14) points directly to block 4,210 on disk, at item offset 14.

An index is a completely separate file on disk, composed of its own 8 KB pages. Instead of containing full row data, index pages store search keys alongside pointers to the corresponding heap tuples (ctid values in PostgreSQL, or primary key values in MySQL InnoDB secondary indexes).

[ Table Heap: 8 KB Pages ]             [ B+Tree Index: 8 KB Pages ]
+----------------------------+         +----------------------------+
| Page 4210                  |         | Leaf Page 88               |
|  Item 1: [Row data ...]    |         |  Key: 1042 -> (4210, 1)    |
|  Item 2: [Row data ...]    |         |  Key: 1043 -> (1105, 7)    |
|  Item 14: [Target row ...] |<--------|  Key: 1044 -> (4210, 14)   |
+----------------------------+         +----------------------------+

Buffer Cache and Memory Hierarchies

When an engine reads a page, it does not fetch data directly from disk into the executing query process. Instead, it checks the database shared memory buffer cache (shared_buffers in PostgreSQL, innodb_buffer_pool_size in InnoDB):

  1. Buffer Cache Hit: The requested 8 KB page is already resident in RAM. Access latency is typically measured in nanoseconds to single-digit microseconds.
  2. Buffer Cache Miss (Physical Read): The page is absent from the shared buffer pool. The database issues an operating system read() call. The page is retrieved from the OS file system page cache or fetched directly from persistent NVMe/SSD storage, incurring millisecond or sub-millisecond I/O wait latency.

Sequential I/O vs. Random I/O

Query optimizers evaluate execution strategies based on estimated cost models. In PostgreSQL, these unit costs are parameterized by:

  • seq_page_cost (default: 1.0): The cost estimate for sequentially reading an 8 KB page from disk.
  • random_page_cost (default: 4.0): The cost estimate for reading an 8 KB page via random access.

On rotational mechanical drives, random access incurred severe physical head-seek penalties. On modern solid-state NVMe drives, the physical seek penalty is negligible, but sequential reads remain more efficient due to OS readahead prefetching, kernel-level batching, and hardware block transfer alignments. For this reason, modern production databases on SSDs frequently tune random_page_cost down to 1.1–1.5.

This cost difference explains a common query planner behavior: the optimizer will often reject an existing index if a query matches a significant percentage of table rows. If an index matches 25% of a 10-million row table, performing 2.5 million random lookups into the heap across thousands of scattered pages requires substantially more I/O operations than scanning the entire heap sequentially from start to finish.


2. Index Types and Mathematical Data Structures

Relational databases support multiple specialized index structures designed for distinct access patterns and data types.

B+Tree: The General-Purpose Workhorse

The B+Tree is the default index type in PostgreSQL, MySQL, Oracle, and Microsoft SQL Server. It is a self-balancing, multi-way search tree optimized for systems that read and write large blocks of memory.

Unlike a standard binary search tree where each node has at most two children, a B+Tree node corresponds to an entire 8 KB page with a large fanout factor BB, often containing hundreds of keys per node. Furthermore, unlike classic B-Trees, B+Trees maintain all actual data pointers strictly in the leaf nodes, while internal (branch) nodes store only routing keys and child page pointers.

                             [ Root Page: Level 2 ]
                             |  100  |  500  |  900  |
                            /        |       |        \
            +--------------+         |       +---------+
            |                        |                 |
            v                        v                 v
  [ Branch Page: Level 1 ] [ Branch Page: Level 1 ] [ Branch Page: Level 1 ]
  | 20 | 40 | 60 | 80 |    | 150 | 250 | 350 |     | 920 | 940 | 960 |
   /    |    |    |   \
  v     v    v    v    v
[ Leaf Page ]<--->[ Leaf Page ]<--->[ Leaf Page ]<--->[ Leaf Page: Level 0 ]
(Keys + ctids)    (Keys + ctids)    (Keys + ctids)    (Keys + ctids)
       |
       +---> Points directly to (block, offset) in the Table Heap

B+Tree Traversal Complexity and Fanout Math

Searching for an exact key requires descending from the root page through intermediate branch levels to a leaf page:

Height=O(log⁡BN)\text{Height} = O(\log_B N)

Where:

  • NN is the total number of indexed rows.
  • BB is the average node capacity (fanout factor).

Consider an 8 KB page storing 64-bit integer IDs (8 bytes) alongside an 8-byte pointer (child page pointer or ctid), plus 24 bytes of standard page and item metadata. An average index leaf page can store between 200 and 400 entries.

Assuming a conservative average fanout of B=300B = 300:

  • Level 1 (Root): 1 page →\rightarrow references up to 300 branch pages.
  • Level 2 (Branch): 300 pages →\rightarrow references up to 300×300=90,000300 \times 300 = 90{,}000 leaf pages.
  • Level 3 (Leaves): 90,000 pages →\rightarrow references up to 90,000×300=27,000,00090{,}000 \times 300 = 27{,}000{,}000 rows.

With a tree height of only 3 levels, the database can locate any specific record among 27 million rows by reading exactly 3 index pages, followed by 1 heap page read to retrieve the row data. Because root and high-level branch pages are frequently accessed, they remain pinned in the buffer cache, meaning an index lookup often incurs at most one physical disk read.

Efficient Range Scans

The leaf pages of a B+Tree are organized in a doubly linked list via horizontal sibling pointers (prev_page and next_page).

When executing a range query such as:

SELECT * FROM orders 
WHERE created_at >= '2026-01-01' AND created_at < '2026-01-02';

The database traverses the tree from root to leaf in O(log⁡BN)O(\log_B N) operations to locate the lower bound ('2026-01-01'). Once the initial leaf page is located, the engine reads horizontally along the leaf chain using the sibling pointers in O(K)O(K) sequential memory operations—where KK is the number of matching records—without ever re-traversing the upper branch hierarchy.


Comparison of Database Index Types

While B+Trees handle standard scalar comparisons (=, <, <=, >, >=, BETWEEN), specialized workloads require alternative mathematical structures:

| Index Type | Underlying Data Structure | Supported Operations | Ideal Production Workloads | Trade-offs & Limitations | | :--- | :--- | :--- | :--- | :--- | | B+Tree | Balanced multi-way search tree | =, <, >, <=, >=, BETWEEN, IN, ORDER BY | Primary keys, foreign keys, timestamps, sorted ranges | High write amplification on random inserts; large storage footprint | | Hash | Static/dynamic hash buckets | = only | Strict single-column equality lookups | No range queries; cannot satisfy ORDER BY; historically lacked WAL logging before PostgreSQL 10 | | GIN (Generalized Inverted Index) | Inverted list (postings lists and postings trees) | @>, ?, ?&, ?\|, @@ (containment, full-text match) | Full-text search (tsvector), JSONB documents, array elements | High write amplification; expensive index updates mitigated by fastupdate buffer | | GiST (Generalized Search Tree) | Balanced tree for arbitrary hierarchical predicates (R-Tree) | && (overlaps), @>, <@, <-> (nearest neighbor / k-NN) | PostGIS spatial coordinates, geometric shapes, network ranges, tsrange | Slower build times; larger index footprint than B+Trees | | BRIN (Block Range Index) | Range summaries (min/max metadata per page block) | =, <, >, BETWEEN | Append-only time-series data, monotonic audit logs, sorted telemetry | Ineffective if data is not physically ordered on disk; requires block range evaluation |


Deep-Dive: GIN and BRIN in Production

GIN (Generalized Inverted Index)

In a relational table storing JSONB documents or array data types, a single row may contain hundreds of discrete values. A standard B+Tree cannot index individual elements inside a nested array.

A GIN index creates an inverted mapping: it decomposes compound items into individual elements (keys) and maps each element to a postings list (an array of ctid values pointing to rows containing that element).

-- Creating a GIN index on a JSONB metadata column
CREATE INDEX idx_events_payload ON event_logs USING gin (payload);

-- Satisfied via Bitmap Index Scan on idx_events_payload
SELECT * FROM event_logs 
WHERE payload @> '{"service": "auth", "status": "failure"}';

Because an insert of a single document containing 40 keys requires updating 40 distinct postings lists, PostgreSQL provides a fastupdate feature for GIN indexes. Incoming writes are appended to an unindexed pending page list and subsequently merged into the main GIN structure during VACUUM or when the pending list exceeds gin_pending_list_limit.

BRIN (Block Range Index)

When tables scale to hundreds of gigabytes or billions of rows, a standard B+Tree index can consume tens of gigabytes of RAM. If data is naturally inserted in sequential order—such as an event timestamp or an auto-incrementing surrogate ID—the physical storage order closely matches the logical order.

A BRIN index does not store individual row keys. Instead, it divides the table heap into ranges of physical pages (by default, 128 pages = 1 MB in PostgreSQL) and records only the minimum and maximum values for each range.

-- Creating a BRIN index on a massive time-series audit table
CREATE INDEX idx_audit_created_brin ON audit_trail 
USING brin (created_at) WITH (pages_per_range = 128);

When executing WHERE created_at >= '2026-09-01', the engine inspects the tiny BRIN summary table. Any 128-page block whose maximum value is older than '2026-09-01' is skipped entirely. A BRIN index often occupies less than 1% of the storage space of an equivalent B+Tree, leaving the database buffer pool free for active query execution.

[!WARNING] BRIN Failure Mode: If rows are inserted out of order, or if historical records are updated with modern timestamps, the min/max range of a block widens. If the min/max range of every block encompasses the entire query range, the BRIN index loses all pruning capability, forcing the engine to scan the entire table.


3. Multi-Column (Composite) Indexes & The Leftmost Prefix Rule

When queries filter by multiple criteria simultaneously, creating separate single-column indexes on each column is rarely optimal. The database must either choose one index and filter the remaining conditions in memory, or use a BitmapAnd operation to intersect two disparate index scans.

A composite index indexes multiple columns in a defined sequence:

CREATE TABLE customer_orders (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    tenant_id INT NOT NULL,
    status VARCHAR(32) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL,
    total_amount NUMERIC(10, 2) NOT NULL
);

CREATE INDEX idx_orders_tenant_status_date 
ON customer_orders (tenant_id, status, created_at);

The Physical Ordering of Composite Keys

A composite B+Tree orders keys lexicographically, exactly like words in a traditional dictionary:

  1. Records are sorted strictly by tenant_id.
  2. For records with identical tenant_id values, records are sorted by status.
  3. For records with identical (tenant_id, status) pairs, records are sorted by created_at.
Composite Index Entries: (tenant_id, status, created_at)
--------------------------------------------------------
[Tenant: 101, Status: 'CANCELLED', Date: 2026-01-01]
[Tenant: 101, Status: 'COMPLETED', Date: 2026-01-01]
[Tenant: 101, Status: 'COMPLETED', Date: 2026-01-03]
[Tenant: 101, Status: 'PENDING',   Date: 2026-01-02]
[Tenant: 102, Status: 'COMPLETED', Date: 2026-01-01]

The Leftmost Prefix Rule

Because keys are sorted starting from the first column, an index on (A, B, C) can satisfy queries filtering on:

  • A
  • (A, B)
  • (A, B, C)

It cannot be used as an index lookup for queries filtering solely on B, C, or (B, C) without an initial predicate on A. Without A, the sorting of B and C is non-contiguous across the tree.

Equality Columns Before Range Columns

When indexing for combined equality and range filters, place columns tested with strict equality before columns tested with inequality or range operators:

-- Query with mixed predicates:
SELECT * FROM customer_orders
WHERE tenant_id = 101 
  AND created_at >= '2026-01-01' 
  AND status = 'COMPLETED';

Consider two index designs:

  1. Suboptimal Index: (tenant_id, created_at, status)
    • The tree navigates directly to tenant_id = 101.
    • It then encounters the range condition created_at >= '2026-01-01'.
    • Once a range condition is evaluated, the physical ordering of subsequent columns (status) is broken across different dates. The engine must scan every leaf entry matching the date range and inspect status as a filter predicate.
  2. Optimal Index: (tenant_id, status, created_at)
    • The tree navigates directly to the exact sub-branch where tenant_id = 101 AND status = 'COMPLETED'.
    • From that precise leaf position, it performs a clean sequential range scan on created_at. No non-matching status entries are scanned.

Eliminating Sort Operations with Index Ordering

Databases can use an index to fulfill an ORDER BY clause without allocating memory for an explicit sort buffer (work_mem).

SELECT id, total_amount FROM customer_orders
WHERE tenant_id = 101 AND status = 'COMPLETED'
ORDER BY created_at DESC
LIMIT 10;

Using the index (tenant_id, status, created_at DESC), the engine locates the leaf page matching tenant_id = 101 and status = 'COMPLETED', and reads the first 10 index tuples in reverse order. The query completes in sub-millisecond time without triggering an expensive Sort or Incremental Sort node in the query plan.


4. Advanced Performance Optimizations

Covering Indexes and Index-Only Scans (INCLUDE)

A standard index scan involves two steps:

  1. Traverse the index to find matching keys and their ctid references.
  2. Perform a random heap visit to fetch the actual table page to retrieve non-indexed columns listed in the SELECT clause.

If an index contains every column referenced in the query (SELECT, WHERE, JOIN, and ORDER BY), the database can bypass the heap entirely, performing an Index-Only Scan.

The SQL standard and PostgreSQL 11+ provide the INCLUDE clause to create covering indexes:

CREATE INDEX idx_orders_covering
ON customer_orders (tenant_id, created_at)
INCLUDE (total_amount, status);

Why INCLUDE Instead of Adding Key Columns?

  • Key Columns (tenant_id, created_at): Determine the tree's sorting order, participate in B+Tree page-split navigation, and enforce unique constraints.
  • Included Payload Columns (total_amount, status): Are stored exclusively in the leaf pages. They do not occupy space in the higher-level branch or root pages.

This keeps root and branch pages compact, maximizing fanout BB, minimizing tree height, and allowing more routing pages to stay memory-resident in shared_buffers.

The PostgreSQL Visibility Map Requirement

In PostgreSQL, index entries do not store transaction visibility metadata (xmin and xmax). To ensure that an index-only scan does not return tuples committed or deleted by uncommitted concurrent transactions, PostgreSQL consults the Visibility Map (VM).

The Visibility Map stores two bits for every 8 KB heap page:

  • All-Visible: Indicates that all tuples on the page are visible to all current and future transactions.
  • All-Frozen: Indicates that all tuples are frozen for transaction ID wraparound prevention.
[ Query: SELECT tenant_id, created_at, total_amount ... ]
                          |
                          v
               [ B+Tree Leaf Page ]
         Extract: tenant_id, created_at, total_amount
                          |
                          v
            [ Check Visibility Map (VM) ]
           /                             \
    Page is All-Visible?          Page NOT All-Visible?
          /                               \
        YES                                NO
        /                                   \
Skip Heap Visit                      Visit Heap Page
(True Index-Only Scan)               to check xmin/xmax
                                     ("Heap Fetches" > 0)

If the Visibility Map indicates that the target heap page is all-visible, PostgreSQL returns the data directly from the index leaf page without reading the heap page. If the page is not marked all-visible, the engine must perform a heap fetch to check tuple visibility. Consequently, tables with heavy write traffic require regular VACUUM execution to maintain all-visible bits and preserve Index-Only Scan efficiency.


Partial (Filtered) Indexes

In many production schemas, data distribution is heavily skewed. For instance, in a task execution queue, 99.5% of rows may have status = 'PROCESSED', while only 0.5% are 'QUEUED' or 'RETRY'.

Creating a regular index on status forces the index to store entries for millions of processed records that queries never access. A partial index indexes only rows that satisfy an explicit WHERE clause:

-- Partial index for active task processing
CREATE INDEX idx_tasks_pending 
ON background_tasks (scheduled_at) 
WHERE status IN ('QUEUED', 'RETRY');
-- Soft-delete pattern: Index only active records
CREATE UNIQUE INDEX idx_users_active_email 
ON users (email) 
WHERE deleted_at IS NULL;

Production Benefits of Partial Indexes:

  1. Dramatically Smaller Footprint: The index size decreases proportionally to the filter selectivity (often by 90–99%), fitting entirely into shared RAM buffers.
  2. Zero Write Penalty on Excluded Rows: Updates to rows where status = 'PROCESSED' or insertions of soft-deleted records do not modify the partial index, eliminating write amplification for terminal states.

Expression (Functional) Indexes

Database indexes store column values in their raw, unmodified form. If a query applies a transformation or function to a column in the WHERE predicate, the query engine cannot use a standard index:

-- Cannot use a standard index on (email)
SELECT id FROM users WHERE LOWER(email) = 'alex@example.com';

Because the function LOWER() could theoretically produce non-monotonic or non-deterministic transformations, the engine must evaluate the function against every row using a Seq Scan.

An expression index indexes the output of the function directly:

CREATE INDEX idx_users_lower_email ON users (LOWER(email));

The B+Tree stores the precomputed lowercase string, allowing the query planner to perform an exact index lookup.


5. The Cost of Indexing: Write Amplification, Page Splits, and Bloat

Indexes are not free. Every index added to a table imposes an operational tax on every write transaction.

Write Amplification on Data Modifications

  • INSERT Statements: Every insert must write the new record into the table heap, and subsequently add an entry into every single index defined on that table. If a table has 10 indexes, an INSERT involves at least 11 discrete page write operations.
  • DELETE Statements: Must locate and flag or remove corresponding keys across all secondary indexes.
  • UPDATE Statements: Modifying any indexed column requires deleting the old index key and inserting the new key into the corresponding B+Tree.

Leaf Page Splits

When an INSERT or UPDATE writes an entry to an index leaf page that has no remaining free space, the storage engine must execute a page split:

  1. Allocate a new 8 KB page block from disk.
  2. Move approximately half of the index entries from the existing page into the new page.
  3. Update the doubly linked list pointers on adjacent sibling pages.
  4. Insert a new routing key and page pointer into the parent branch node.
  5. If the parent branch page is also full, the split cascades upward toward the root.

Page splits introduce physical fragmentation, reduce page density, generate transaction log (WAL) volume, and temporarily hold exclusive locks on index blocks, introducing latency spikes under concurrent write loads.

Full Leaf Page: [ 10 | 20 | 30 | 40 | 50 ]  <-- Inserting 25 causes Page Split!
                                   |
                                   v
Allocates New Page and Splits 50/50:
Leaf Page A: [ 10 | 20 | 25 ] <=======> Leaf Page B: [ 30 | 40 | 50 ]
                                   ^
            Parent Branch Page must be updated with new routing key!

PostgreSQL HOT (Heap-Only Tuples) Optimization

Under PostgreSQL Multi-Version Concurrency Control (MVCC), an UPDATE does not overwrite data in place; it writes a new version of the tuple into the heap. In standard operation, every index on the table would need a new entry pointing to the new tuple's ctid.

To prevent massive index write amplification, PostgreSQL provides the HOT (Heap-Only Tuple) optimization. A HOT update occurs when:

  1. None of the columns modified by the UPDATE are part of any index on the table.
  2. The new tuple version fits inside the same 8 KB heap page as the old tuple version.
[ Heap Page 4210 ]
+-----------------------------------------------------------+
| Item 1 (Old Tuple) -> points to Item 2 via HOT chain      |
| Item 2 (New Tuple) -> [Updated Data, No Index Entry]      |
+-----------------------------------------------------------+
               ^
               | (Only one index entry exists!)
[ Index Leaf Page ]
  Key: 1042 -> (4210, 1)

The database creates a direct pointer link inside the heap page from the old tuple to the new tuple. The index continues pointing exclusively to (4210, 1). When a query navigates to the old tuple, the engine follows the internal HOT chain directly to the latest visible version without touching any index.

Tuning Table Fillfactor for HOT

By default, PostgreSQL fills heap pages to 100% capacity (fillfactor = 100). On tables with frequent updates, this leaves no room for new tuple versions on the same page, preventing HOT updates.

Setting a lower fillfactor reserves free space on every heap page for future in-place updates:

-- Reserve 15% free space on each heap page for HOT updates
ALTER TABLE account_balances SET (fillfactor = 85);
VACUUM FULL account_balances;

Index Bloat and Zero-Downtime Maintenance

Under heavy UPDATE and DELETE traffic, dead index tuples accumulate. While PostgreSQL's VACUUM process marks dead heap space as reusable, B+Tree index pages that become sparse due to deleted keys often cannot be merged automatically. This condition is known as index bloat. Bloated indexes consume excessive RAM in shared_buffers and force queries to read many sparsely populated pages.

Rebuilding an index with standard REINDEX or CREATE INDEX acquires an AccessExclusiveLock or ShareLock on the table, blocking all concurrent writes:

-- DANGEROUS IN PRODUCTION: Locks table against all INSERT/UPDATE/DELETE
REINDEX TABLE customer_orders;

Safe Zero-Downtime Index Operations

Production migrations must always use the CONCURRENTLY modifier:

-- Zero-downtime creation (PostgreSQL)
CREATE INDEX CONCURRENTLY idx_orders_user_id 
ON customer_orders (user_id);

-- Zero-downtime reindex (PostgreSQL 12+)
REINDEX INDEX CONCURRENTLY idx_orders_user_id;

CREATE INDEX CONCURRENTLY operates across two separate transactions:

  1. Scans the table to build the initial index structure while concurrent writes continue.
  2. Waits for all active transactions to terminate, performs a second verification scan to capture changes that occurred during the first pass, and marks the index valid.

[!IMPORTANT] The INVALID Index Trap: If a concurrent index build encounters a deadlock, unique constraint violation, or is canceled, the build fails. However, PostgreSQL leaves an INVALID index catalog entry on disk. An invalid index continues to receive write updates on every INSERT and DELETE, consuming disk space and write I/O, but is never used by the query planner. Always check for invalid indexes after a failed migration:

SELECT indisvalid, indexrelid::regclass FROM pg_index WHERE NOT indisvalid;

6. Decoding Query Execution Plans (EXPLAIN ANALYZE)

To verify whether an index is behaving as intended, engineers must inspect execution plans generated by the database Cost-Based Optimizer (CBO).

Access Method Taxonomy

| Execution Plan Node | Physical Access Pattern | Memory / I/O Characteristics | Typical When | | :--- | :--- | :--- | :--- | | Seq Scan | Reads every heap page sequentially from start to end. | Sequential I/O; utilizes prefetching. | Small tables; large selectivity (>15–30% of table); queries without index predicates. | | Index Scan | Descends B+Tree to leaf; reads ctid; reads corresponding heap page. | Random I/O into heap; low memory overhead. | Fetching a small number of rows with non-indexed columns needed. | | Index Only Scan | Reads data directly from B+Tree leaf; consults Visibility Map. | Lowest I/O; zero heap reads if pages are marked all-visible. | Covering indexes where all selected columns reside in the index. | | Bitmap Index Scan + Bitmap Heap Scan | Phase 1: Reads index and builds in-memory bitmap of matching block/tuple IDs.
Phase 2: Reads heap pages in physical disk order. | Converts random I/O into sequential heap reads; combines multiple indexes via BitmapAnd / BitmapOr. | Medium selectivity (e.g., 1%–15% of table); multiple index intersections. |


Step-by-Step Annotated Execution Plan

Consider a query analyzing customer orders:

EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id, total_amount
FROM customer_orders
WHERE tenant_id = 42 AND created_at >= '2026-08-01';

Plan 1: Suboptimal Access (Bitmap Scan with High Buffer Reads)

Bitmap Heap Scan on customer_orders  (cost=124.50..3420.10 rows=4820 width=16) 
                                     (actual time=1.421..8.932 rows=5100 loops=1)
  Recheck Cond: ((tenant_id = 42) AND (created_at >= '2026-08-01'::timestamptz))
  Buffers: shared hit=842 read=310
  ->  Bitmap Index Scan on idx_orders_tenant  (cost=0.00..123.30 rows=4820 width=0)
                                              (actual time=1.102..1.102 rows=5100 loops=1)
        Index Cond: (tenant_id = 42)
        Buffers: shared hit=4 read=2
Planning Time: 0.284 ms
Execution Time: 9.340 ms

Interpretation:

  • The engine only had an index on (tenant_id).
  • It gathered all 5,100 records for tenant_id = 42 into an in-memory bitmap.
  • In Bitmap Heap Scan, it read 1,152 pages (842 hit in RAM + 310 read from disk) to evaluate created_at and retrieve total_amount.

Plan 2: Optimized Access via Covering Index

Now, we introduce a covering index tailored to the query:

CREATE INDEX CONCURRENTLY idx_orders_tenant_date_covering
ON customer_orders (tenant_id, created_at)
INCLUDE (customer_id, total_amount);

Re-running EXPLAIN (ANALYZE, BUFFERS):

Index Only Scan using idx_orders_tenant_date_covering on customer_orders  
  (cost=0.43..182.20 rows=4820 width=16) (actual time=0.042..1.215 rows=5100 loops=1)
  Index Cond: ((tenant_id = 42) AND (created_at >= '2026-08-01'::timestamptz))
  Heap Fetches: 0
  Buffers: shared hit=28
Planning Time: 0.192 ms
Execution Time: 1.410 ms

Key Improvements:

  1. Node Type Changed: Upgraded from Bitmap Heap Scan to Index Only Scan.
  2. Zero Heap Fetches: Heap Fetches: 0 confirms that the Visibility Map validated all pages; zero table heap pages were visited.
  3. Buffer Reduction: Total buffer accesses dropped from 1,152 pages down to 28 pages (shared hit=28).
  4. Execution Latency: Dropped from 9.34 ms to 1.41 ms—a 6.6x latency reduction achieved entirely by avoiding heap I/O.

7. Production Indexing Architecture & Observability Checklist

Applying indexes effectively requires continuous monitoring of database statistics. An unused index wastes memory and disk I/O, while a missing index degrades core API endpoints.

Diagnostic SQL Queries for Production

1. Identifying Unused Indexes (Index Debt)

Indexes with zero or negligible scans consume write overhead without providing read benefits:

SELECT
    schemaname,
    relname AS table_name,
    indexrelname AS index_name,
    idx_scan,
    idx_tup_read,
    idx_tup_fetch,
    pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
JOIN pg_index USING (indexrelid)
WHERE NOT indisunique
  AND idx_scan < 50
ORDER BY pg_relation_size(indexrelid) DESC;

2. Identifying High Sequential Scan Tables (Missing Indexes)

Tables with high sequential scan counts relative to index scans frequently indicate missing index predicates:

SELECT
    relname AS table_name,
    seq_scan,
    seq_tup_read,
    idx_scan,
    ROUND(100.0 * idx_scan / NULLIF(seq_scan + idx_scan, 0), 2) AS index_usage_pct,
    pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM pg_stat_user_tables
WHERE seq_scan > 100
ORDER BY seq_tup_read DESC
LIMIT 20;

3. Monitoring HOT Update Efficiency

Measure whether updates are bypassing index writes:

SELECT
    relname AS table_name,
    n_tup_upd AS total_updates,
    n_tup_hot_upd AS hot_updates,
    ROUND(100.0 * n_tup_hot_upd / NULLIF(n_tup_upd, 0), 2) AS hot_update_pct
FROM pg_stat_user_tables
WHERE n_tup_upd > 1000
ORDER BY total_updates DESC;

Production Verification Checklist

Before deploying database schema migrations that introduce or alter indexes, verify the following:

  • [ ] Access Pattern Validation: Has the proposed index been matched against actual query predicates (WHERE, JOIN, ORDER BY), rather than intuitive assumptions?
  • [ ] Leftmost Prefix Compliance: For composite indexes, are equality columns placed before range/inequality columns?
  • [ ] Covering Optimization: Would appending secondary query columns via INCLUDE eliminate random heap visits without increasing tree depth?
  • [ ] Partial Filtering Evaluation: If the query targets a specific lifecycle state (e.g., status = 'ACTIVE'), can a partial index reduce the index size and write overhead?
  • [ ] Write Overhead Assessment: How many existing indexes already exist on this table? Will the new index compromise insertion throughput on high-velocity ingestion tables?
  • [ ] HOT Update Compatibility: Does an UPDATE workload exist on this table? Will indexing this column break HOT update chains and accelerate page bloat?
  • [ ] Zero-Downtime Deployment: Are index creation statements configured with CONCURRENTLY to prevent acquiring exclusive table locks?
  • [ ] Invalid Index Post-Check: Does the migration deployment pipeline verify that the newly created index is marked indisvalid = true in pg_index?

Conclusion

A database index is not a generic performance patch; it is an explicit architectural trade-off. At the storage engine layer, every index is a physically persisted data structure that demands memory in the buffer pool, incurs page splits, multiplies write volume, and requires background vacuuming and reindexing maintenance.

Writing high-performance queries begins with respecting the mechanical realities of the database engine:

  • Favor B+Trees for general scalar ranges and sorting.
  • Use GIN for composite arrays and JSONB documents.
  • Leverage BRIN for append-only sequential time-series data.
  • Structure composite indexes with equality columns first to honor the leftmost prefix rule.
  • Use covering indexes (INCLUDE) and partial indexes to minimize heap I/O and reduce memory consumption.
  • Always inspect execution plans using EXPLAIN (ANALYZE, BUFFERS) to verify that the query optimizer chooses the expected access path.

By basing index design on access patterns, memory boundaries, and physical I/O costs, engineering teams can build database architectures that remain predictable, scalable, and resilient as production workloads grow.

Related articles

Diagram showing database tables, indexes, queries, and connection flow
Databases

Database Connection Pooling Explained

November 9, 2025 · 17 min read

Learn how database connection pooling works, why oversized pools degrade performance, sizing mathematics, client vs. proxy pooling, and leak prevention.

DatabasesSQLBackendSoftware Engineering