Article • Sep 2026 • 2 min read

PostgreSQL Indexing Deep Dive: B-Tree, BRIN, and Partial Indexes for 10M+ Rows

When a high-volume financial transaction table crosses 10 million rows, naive sequential scans cripple the entire database cluster. In this deep dive, we examine PostgreSQL execution plans using EXPLAIN (ANALYZE, BUFFERS) and demonstrate how a 45-byte partial index dropped query latency from 2,400ms to 2.2ms.

Dicky Joel Saputra
Dicky Joel Saputra
Backend Engineer
PostgreSQL Indexing Deep Dive: B-Tree, BRIN, and Partial Indexes for 10M+ Rows Technical Diagram

1. Diagnosing the Query Plan at 10M Rows

At scale, database query performance does not degrade gracefully—it hits a performance cliff. When our transactional ledger crossed 12 million records, a routine dashboard aggregation spiked from 150ms to over 2.4 seconds, driving database CPU utilization beyond 90%.

Running a detailed buffer analysis revealed the core bottleneck: PostgreSQL had to traverse hundreds of megabytes of raw heap pages because the planner lacked a targeted index matching the active transaction predicate.

diagnose_query_plan.sql SQL
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, amount, user_id, created_at 
FROM transactions 
WHERE status = 'PENDING' 
ORDER BY created_at ASC 
LIMIT 50;

-- Query Telemetry:
-- Seq Scan on transactions (cost=0.00..284120.00 rows=410 width=32)
-- Buffers: shared read=84210 (Over 670MB read from disk into memory!)
-- Execution Time: 2412.35 ms

Sequential scan over 12 million rows reading 84,210 disk buffers.

Architecture Warning: ACCESS EXCLUSIVE Locks

Never execute CREATE INDEX on a live production table with high write throughput without the CONCURRENTLY keyword. A standard CREATE INDEX acquires a SHARE lock that blocks all incoming INSERT and UPDATE transactions.

2. The Solution: Targeted Partial Indexing

Since over 98% of rows had already transitioned to a terminal 'COMPLETED' status, indexing the entire 12 million rows into a standard composite B-Tree would consume 380 MB of RAM in shared buffers. Why pay for indexing 11.9 million rows that we never query for this specific workload?

By adding a predicate filter to the index creation statement, PostgreSQL only indexes entries that match our WHERE clause.

partial_index_solution.sql SQL
CREATE INDEX CONCURRENTLY idx_transactions_pending_created 
ON transactions (created_at ASC) 
INCLUDE (amount, user_id)
WHERE status = 'PENDING';

-- Results after partial indexing:
-- Bitmap Index Scan on idx_transactions_pending_created
-- Buffers: shared hit=4 (Clean memory hit in RAM, 0 disk I/O!)
-- Execution Time: 2.24 ms (> 1,000x speedup!)

Partial B-Tree index with INCLUDE covering clause.

<postgresql-optimization-benchmarks />

2,412ms -> 2.24ms (1,076x)
Query Latency
84,210 -> 4 pages
Disk Buffer Reads
380 MB -> 1.8 MB (-99.5%)
Index RAM Footprint
92% -> 11%
Cluster CPU Load

<more_technical_deep_dives />

EndeavourOS is the Linux Distro I have always wanted
Golang Linux

EndeavourOS is the Linux Distro I have always wanted

After having to resolve a bunch of issues I was facing with Manjaro - …

Read Deep-Dive →
Scaling Django with Redis: Cache-Aside vs Full-Page Caching in High-Traffic Systems
Article

Scaling Django with Redis: Cache-Aside vs Full-Page Caching in High-Traffic Systems

Practical blueprints for handling cache stampedes, implementing probabilistic early expiration (XFetch algorithm), and managing …

Read Deep-Dive →
<collaborate />

Backend Running Slow? Let's Fix That.

Go concurrency, databases, deployment, docker — I handle it all, so your uptime stays solid and your system runs smooth.