Back to blog
Database Performance
Intermediate

PostgreSQL Index Optimization: A Practical Guide for Developers

A hands-on guide to PostgreSQL index optimization. Learn how B-tree, GIN, and BRIN indexes work, how to read EXPLAIN plans, and how to design indexes that speed up real queries.

August 15, 202520 min read

Introduction

PostgreSQL is one of the most powerful open-source relational database systems in the world. Yet many applications suffer from slow queries not because of hardware limitations, but because of missing or poorly designed indexes. PostgreSQL index optimization is the practice of strategically creating and maintaining indexes so that the query planner can retrieve data with minimal disk I/O. In this guide we will go beyond basic CREATE INDEX statements and explore how the PostgreSQL planner uses B-tree, GIN, GiST, BRIN, and covering indexes. You will learn how to read EXPLAIN ANALYZE output, design composite indexes that match your access patterns, and avoid the common traps that cause indexes to be ignored. Whether you are building a SaaS backend, an analytics dashboard, or a high-throughput API, the techniques here will help you achieve predictable low-latency queries. We will also discuss how index maintenance interacts with autovacuum, MVCC, and the visibility map so you understand the full lifecycle of an indexed workload.

Table of Contents

Core Concepts

Before optimizing, you must understand what an index actually is inside PostgreSQL. An index is a separate data structure that maps column values to row pointers (TIDs). The default index type is B-tree, a self-balancing tree that keeps data sorted and allows logarithmic time lookups. PostgreSQL also supports hash, GIN (Generalized Inverted Index), GiST (Generalized Search Tree), BRIN (Block Range INdex), and SP-GiST. Each serves different data shapes and query patterns.

B-tree indexes work best for equality and range queries on scalar types. They store entries in sorted order and support operators like =, <, >, BETWEEN, and pattern matching with left-anchored LIKE. GIN indexes excel at indexing composite values such as arrays, JSONB documents, and full-text search vectors. GiST is useful for geometric data, proximity searches, and overlapping ranges. BRIN is extremely compact and ideal for naturally ordered data like time-series tables where physical storage correlates with column values. SP-GiST supports partitioned spaces such as quad-trees and k-d trees.

Another critical concept is index selectivity. An index is selective if it filters out a large portion of rows. Low selectivity indexes (e.g., a boolean column with 50/50 distribution) often waste space and may be ignored by the planner. The planner uses table statistics collected by ANALYZE to estimate selectivity. Thus, keeping statistics fresh is part of index optimization.

Covering indexes (also called index-only scans) use the INCLUDE clause to store non-key columns in the leaf pages. When a query only needs indexed and included columns, PostgreSQL can answer without visiting the heap, drastically reducing I/O. Partial indexes add a WHERE clause to index only a subset of rows, reducing size and write overhead for queries that target a specific slice of data.

Index visibility and MVCC: PostgreSQL indexes do not store transaction visibility information; instead, the heap tuple header is checked. This is why index-only scans rely on the visibility map. When many rows are recently updated, the visibility map is not all-visible, forcing heap fetches and negating index-only benefits. Regular vacuuming solves this. Understanding this interplay helps you avoid premature conclusions when an index-only scan does not appear.

Finally, understand that every index has a write cost. INSERT, UPDATE, and DELETE must maintain all indexes on the table. Over-indexing slows writes and bloats storage. Optimization is a trade-off tuned to your workload's read/write ratio, and must be revisited as access patterns evolve.

Architecture Overview

PostgreSQL separates the logical table (heap) from index files. When a query executes, the planner chooses a path: sequential scan, index scan, index-only scan, bitmap heap scan, or a combination. The postmaster forks backend processes that communicate with the storage layer via buffer manager. Index pages are cached in shared buffers just like heap pages. The buffer manager uses a clock-sweep algorithm to evict pages. Index pages that are frequently accessed stay in shared buffers, making index scans fast after warm-up.

In a B-tree index, the root and internal nodes guide the search to leaf pages that contain indexed values and TIDs. PostgreSQL implements a variation called Btree with high concurrency (MVCC-safe) using write-ahead logging (WAL). GIN indexes store a per-key list of TIDs, with a pending list for fast inserts that is later merged. GiST indexes are extensible and define methods for union, penalty, and picksplit. BRIN stores summary information (min/max) per block range. WAL ensures crash recovery; index modifications are logged similarly to heap changes.

The planner evaluates each possible path's cost based on statistics: sequential scan cost proportional to table size; index scan cost includes tree traversal plus heap fetch per row. If a query returns many rows, random heap fetches may be more expensive than a sequential scan, causing the planner to skip the index. Bitmap scans bridge this by reading index leaf entries, building a bitmap of pages, and then fetching pages in order. This reduces random I/O.

Understanding this architecture helps explain why an index might not be used, why INCLUDE columns improve performance, and why BRIN works only when physical order matches column order. Index optimization is essentially aligning your data layout and query patterns with these internal mechanisms.

Step-by-Step Guide

Follow this workflow to optimize indexes on an existing PostgreSQL database.

1. Identify Slow Queries

Enable the pg_stat_statements extension by adding it to shared_preload_libraries and querying pg_stat_statements to sort by total_exec_time. Look for queries with high average time or frequency. This extension is the primary observability tool for index work.

2. Capture Execution Plans

Run EXPLAIN (ANALYZE, BUFFERS) for the suspect query. Note whether it uses Seq Scan, Index Scan, or Index Only Scan. Check rows removed by filter vs by index condition. The BUFFERS option shows shared hits and reads, quantifying I/O.

3. Analyze Access Patterns

Determine which columns appear in WHERE, JOIN, ORDER BY, and GROUP BY. Equality columns should lead composite indexes; range columns should follow. Consider cardinality and selectivity estimates from pg_stats to predict planner behavior.

4. Create Targeted Indexes

Start with a B-tree on high-selectivity columns. For JSONB metadata, use GIN. For time-series, consider BRIN on the timestamp. Use CREATE INDEX CONCURRENTLY to avoid locking production tables. In staging, validate that the index size is acceptable.

5. Validate with EXPLAIN

Re-run EXPLAIN ANALYZE. Confirm the planner now chooses Index Scan and that heap fetches are reduced. Check Buffers: shared hit/read counts should drop. If not, examine whether the visibility map is stale.

6. Monitor Write Overhead

Observe INSERT/UPDATE latency after index creation. If writes degrade unacceptably, consider partial indexes or dropping redundant ones. Use pg_stat_user_tables to track dead tuples and index bloat.

7. Maintain Statistics

Run ANALYZE or rely on autovacuum. For skewed data, increase column statistics target with ALTER TABLE ... SET (n_distinct = ...). Accurate statistics are the foundation of good plans.

By iterating this loop, you transform guesswork into measurement-driven optimization.

Real-World Examples

Consider an e-commerce orders table with 50 million rows. A common query fetches recent orders for a customer: SELECT * FROM orders WHERE customer_id = $1 ORDER BY created_at DESC LIMIT 20. Initially this does a sequential scan. Creating an index on (customer_id, created_at DESC) allows a backward index scan, returning rows without sort. Because the index is composite and ordered, the planner uses Index Scan with a single seek.

Another example: a multi-tenant analytics app stores events in a JSONB column with variable attributes. Queries filter on events @> '{"type":"click"}'. A GIN index on the JSONB column enables fast containment checks. Without it, PostgreSQL would scan every row and apply the containment operator.

For a logging table inserted every second, a BRIN index on event_time occupies a few kilobytes versus a multi-gigabyte B-tree. Queries filtering the last hour still prune block ranges efficiently because logs are inserted in time order.

Finally, a support ticket system often queries open tickets: WHERE status = 'open'. Since only 5% of tickets are open, a partial index CREATE INDEX ON tickets (updated_at) WHERE status = 'open' yields a tiny index used only for the hot path, leaving closed ticket writes untouched.

A fifth scenario involves full-text search. Storing a tsvector column and indexing it with GIN allows rapid matching of documents containing keywords. Combined with phrase search operators, this outperforms LIKE patterns by orders of magnitude. These examples show that the right index type is dictated by the shape of the data and the query predicate.

Production Code Examples

Below are realistic SQL and PL/pgSQL snippets you can adapt.

-- B-tree composite index with descending timestamp
CREATE INDEX CONCURRENTLY idx_orders_customer_created
ON orders (customer_id, created_at DESC);

-- GIN index for JSONB containment
CREATE INDEX CONCURRENTLY idx_events_payload
ON events USING GIN (payload jsonb_path_ops);

-- BRIN index for time-series
CREATE INDEX CONCURRENTLY idx_logs_time
ON logs USING BRIN (event_time);

-- Partial index for hot subset
CREATE INDEX CONCURRENTLY idx_open_tickets
ON tickets (updated_at) WHERE status = 'open';

-- Covering index to enable index-only scan
CREATE INDEX CONCURRENTLY idx_users_email_include
ON users (email) INCLUDE (full_name, role);

-- Functional index for case-insensitive lookup
CREATE INDEX CONCURRENTLY idx_users_lower_email
ON users (lower(email));

The following query benefits from the covering index:

EXPLAIN (ANALYZE, BUFFERS)
SELECT full_name, role FROM users WHERE email = 'rakib@example.com';
-- Result: Index Only Scan using idx_users_email_include

To monitor index usage, query the statistics view:

SELECT relname, indexrelname, idx_scan, idx_tup_read, idx_tup_fetch
FROM pg_stat_user_indexes
WHERE relname = 'orders'
ORDER BY idx_scan DESC;

If idx_scan is zero for an index, it is dead weight. Use DROP INDEX CONCURRENTLY to remove it. You can also inspect a sample plan:

EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT * FROM orders WHERE customer_id = 42 ORDER BY created_at DESC LIMIT 5;
-- Expected: Index Scan using idx_orders_customer_created
--   Buffers: shared hit=12 read=3

These patterns form the backbone of day-to-day index optimization in production PostgreSQL.

Comparison Table

Choosing the right index type is crucial. The table below summarizes trade-offs.

Index Type Best For Storage Write Overhead Notes
B-tree Equality, range, sorting Moderate Low-Moderate Default; supports INCLUDE
GIN JSONB, arrays, full-text High High (pending list) Fast reads, slower writes
GiST Geometric, fuzzy match Moderate Moderate Extensible
BRIN Time-ordered data Very Low Very Low Only if physical order matches
Hash Simple equality Low Low No range support; WAL-safe since v10
SP-GiST Partitioned spaces Moderate Moderate Quad-trees, k-d trees

Use B-tree as your default, GIN for document search, and BRIN for massive ordered datasets. SP-GiST is niche but powerful for specialized spatial data.

Best Practices

  • Index columns used in JOIN, WHERE, and ORDER BY, but avoid indexing every column.
  • Lead composite indexes with equality columns, then range/sort columns.
  • Use CREATE INDEX CONCURRENTLY in production to prevent locks.
  • Prefer partial indexes for queries that touch a small row subset.
  • Use covering indexes (INCLUDE) for read-heavy, narrow selects.
  • Run ANALYZE after large data changes to refresh planner statistics.
  • Monitor pg_stat_user_indexes to find and drop unused indexes.
  • Consider BRIN for append-only time-series tables.
  • Test index changes in staging with production-like data volumes.
  • Comment your indexes with COMMENT ON INDEX to document intent.
  • Align index order with ORDER BY to avoid sort nodes.

Common Mistakes

  • Creating redundant indexes: a composite (a,b) makes a single (a) redundant.
  • Indexing low-selectivity columns expecting magic speedups.
  • Using functions in WHERE without functional indexes: WHERE lower(email)=... needs an index on lower(email).
  • Neglecting autovacuum, causing bloat and stale statistics.
  • Adding indexes without measuring write impact.
  • Assuming LIKE '%term%' can use B-tree (only left-anchored works).
  • Creating GIN without jsonb_path_ops when only containment is needed, wasting space.
  • Deploying indexes in a transaction during peak traffic without CONCURRENTLY.
  • Forgetting that type mismatches (text vs int) disable index usage.
  • Letting the visibility map become stale, breaking index-only scans.

Performance Tips

Keep indexes in shared buffers by sizing shared_buffers appropriately (25% of RAM typical). Use connection pooling to avoid plan thrashing. For very large tables, consider partitioning; local indexes on partitions are smaller and easier to maintain. Use parallel index builds with max_parallel_workers_maintenance for fast creation. For read replicas, create indexes on the primary and let them stream, or use CREATE INDEX CONCURRENTLY on replicas if promoted. Avoid over-indexing fact tables; leverage aggregate materialized views with their own indexes for reporting. Regularly run REINDEX CONCURRENTLY on bloated indexes, especially after bulk deletes. Also consider incrementing default_statistics_target for columns with complex distributions to improve planner estimates.

Security Considerations

Indexes themselves are not a security boundary, but they interact with data protection. Avoid indexing sensitive columns like plaintext passwords; use hashing instead. Be aware that INCLUDE columns store data in the index, so a covering index on users (email) INCLUDE (ssn) would expose SSN in index files. Use column-level privileges and row-level security before relying on indexes for performance. Also, an attacker who can trigger expensive queries can cause denial of service; proper indexing mitigates but does not replace query limits and statement timeouts. Ensure that index creation scripts are reviewed in version control to prevent accidental exposure of confidential data in index structures. Additionally, restrict who can create indexes, as a malicious index on a large table can lock resources.

Deployment Notes

In CI/CD pipelines, manage indexes with migration tools such as Flyway, Liquibase, or Django/Rails migrations. Always use CONCURRENTLY for new indexes on live tables; note that CONCURRENTLY cannot run inside a transaction block. For zero-downtime, create the index, verify with EXPLAIN, then later drop old redundant indexes in a separate migration. In managed services like Amazon RDS or Google Cloud SQL, index builds consume IOPS; schedule during low traffic. If using logical replication, indexes are not replicated—each node needs its own indexes tuned to its read pattern. Document each index's purpose in comments: COMMENT ON INDEX idx_orders_customer_created IS 'Speeds customer order history page'. Keep rollback scripts ready in case the index causes unexpected write latency.

Debugging Tips

When an index is not used, check: (1) Statistics are fresh (ANALYZE). (2) The query uses the column alone, not wrapped in a function. (3) The planner estimates low selectivity—force with SET enable_seqscan = off temporarily to see if index is cheaper. (4) Data type mismatch: comparing text to integer prevents index use. (5) Visibility map not updated, preventing index-only scans—run VACUUM. Use EXPLAIN (ANALYZE, BUFFERS, VERBOSE) to see exact filters. The pg_indexes view shows definitions; pg_statio_user_indexes shows I/O. For GIN pending list issues, monitor gin_pending_stats. Enable auto_explain for slow sessions to capture plans in logs. These steps quickly reveal why an index is ignored.

FAQ

What is the difference between INDEX and INDEX CONCURRENTLY?

Standard CREATE INDEX locks the table against writes for the duration of build. CONCURRENTLY allows reads and writes but takes longer and cannot run in a transaction. In production, always prefer CONCURRENTLY.

How many indexes per table is too many?

There is no fixed number. A write-heavy table may suffer with more than 5-10 indexes, while read-heavy dimensional tables can have many. Measure write latency and storage before adding more.

Can I index a JSONB column efficiently?

Yes. Use GIN with jsonb_path_ops for containment queries (@>). For specific keys, a functional index on (payload->>'field') may be smaller and faster for equality.

Why does my query still do a sequential scan after adding an index?

The planner may estimate that scanning the whole table is cheaper, especially if the index returns a large fraction of rows or statistics are stale. Check EXPLAIN and ANALYZE.

What are covering indexes and when should I use them?

Covering indexes use INCLUDE to store extra columns, enabling index-only scans. Use them when SELECT lists are narrow and frequently accessed, such as lookup tables.

Is BRIN a replacement for B-tree?

No. BRIN only works well when the column's physical storage order correlates with its values, like append-only timestamps. Otherwise it can be useless or even slower.

How do I find unused indexes?

Query pg_stat_user_indexes for idx_scan = 0 over a representative period. Combine with business knowledge before dropping, as some indexes serve rare but critical queries.

Does indexing improve JOIN performance?

Yes. Foreign key columns should be indexed to speed up joins and cascade operations. PostgreSQL does not auto-create indexes on referencing columns, only on primary keys and unique constraints.

Should I index boolean or status columns?

Only with partial indexes targeting the less common value (e.g., WHERE status = 'open'). A full index on a low-selectivity column is often ignored.

How often should I reindex?

Only when bloat is significant, as detected via pgstatindex or bloat queries. Modern PostgreSQL with autovacuum rarely needs full reindex, but after massive deletes or updates, REINDEX CONCURRENTLY can restore performance. Avoid scheduling routine reindexes that lock tables.

Conclusion

PostgreSQL index optimization is a continuous discipline that blends measurement, data modeling, and understanding of the query planner. Start by capturing real execution plans, create targeted B-tree, GIN, or BRIN indexes, and validate with EXPLAIN ANALYZE. Remove redundant or unused indexes to protect write throughput. By applying the strategies in this guide, you will cut query latency from seconds to milliseconds and build systems that scale gracefully. Ready to dive deeper? Explore our related articles on database design principles and REST API performance optimization, and start profiling your slowest queries today.