MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Database Indexing: Beyond the Basics

Advanced indexing: covering indexes, partial indexes, expression indexes, and index-only scans.

DatabasePostgreSQLIndexingPerformance

By MinhVo

Introduction

Most developers learn the basics of database indexing early in their careers: create an index on columns you filter by, and queries get faster. But beneath that simple mental model lies a rich landscape of advanced indexing techniques that can deliver order-of-magnitude performance improvements for the right workloads. Covering indexes eliminate table lookups entirely, partial indexes shrink index size by targeting specific row subsets, and expression indexes unlock optimization for computed values that standard indexes cannot touch.

Understanding these advanced techniques separates developers who write functional SQL from those who write performant SQL. When a query scans millions of rows in 200 milliseconds instead of 20 seconds, it's often because someone chose the right index type—not because they threw more hardware at the problem. This guide explores the advanced indexing capabilities in PostgreSQL, with practical examples, benchmarking strategies, and production-ready patterns you can apply immediately.

Database indexing and query performance

Understanding Advanced Index Types: Core Concepts

How PostgreSQL Indexes Actually Work

PostgreSQL's default index type is B-tree, a balanced tree structure that maintains sorted order of indexed values. When you create an index on a column, PostgreSQL builds a tree where each node contains key values and pointers to child nodes or heap tuples (the actual row data). A B-tree index lookup for a single value has O(log n) complexity—on a table with 10 million rows, that's roughly 23 comparisons to find any value.

The critical detail most developers miss is the difference between an index scan and an index-only scan. In a regular index scan, PostgreSQL traverses the index to find matching row pointers, then makes random access reads into the heap (the main table storage) to retrieve the full row data. Each heap access is a separate disk I/O operation. If your query matches 10,000 rows, that's 10,000 additional heap fetches.

An index-only scan eliminates the heap access entirely. If the index contains all the columns your query needs (in SELECT, WHERE, and ORDER BY), PostgreSQL can satisfy the query by reading only the index. This transforms a query from thousands of random I/Os to a sequential scan of a compact data structure.

The Visibility Map

Index-only scans depend on PostgreSQL's visibility map, a bitmap that tracks which table pages contain only tuples visible to all transactions. When PostgreSQL finds a matching entry in an index, it checks the visibility map. If the page is marked all-visible, PostgreSQL returns the data directly from the index without accessing the heap. If not, it must fetch the heap tuple to check visibility, defeating the optimization.

This is why VACUUM is critical for index-only scan performance. Running VACUUM updates the visibility map. On tables with heavy write activity, autovacuum may not keep pace, and index-only scans degrade to regular index scans. Monitoring the n_live_tup and n_dead_tup statistics in pg_stat_user_tables helps identify tables that need manual vacuum attention.

Physical Index Size and Cache Efficiency

Index size directly impacts performance because smaller indexes fit more readily in PostgreSQL's shared buffer cache and the operating system's page cache. A 500 MB index that fits entirely in cache delivers consistent sub-millisecond lookups. A 5 GB index that exceeds cache forces disk reads, adding 5–10 milliseconds per miss.

Understanding index bloat is essential. B-tree indexes can accumulate dead tuples and internal fragmentation over time, growing much larger than their data would suggest. Running pgstattuple extension's pgstatindex() function reveals the internal structure:

SELECT * FROM pgstatindex('idx_users_email');
-- Shows: leaf_pages, dead_items, bloat_percentage

Query execution plan visualization

Architecture and Design Patterns

Covering Indexes

A covering index includes extra columns beyond those needed for filtering, appended to the index using the INCLUDE clause. These included columns are stored only in the leaf pages of the B-tree, not in the internal pages, keeping the index compact while still enabling index-only scans.

-- Standard index: requires heap access for username and email
CREATE INDEX idx_orders_user_id ON orders (user_id);
 
-- Covering index: satisfies the query without heap access
CREATE INDEX idx_orders_user_id_covering ON orders (user_id)
  INCLUDE (total_amount, status, created_at);

The covering index above supports this common query pattern entirely from the index:

SELECT total_amount, status, created_at
FROM orders
WHERE user_id = 12345
ORDER BY created_at DESC
LIMIT 20;

The INCLUDE columns add storage overhead but avoid the random heap access pattern that makes index scans slow on large result sets. The trade-off is worthwhile for queries that return many rows or run at high frequency.

Partial Indexes

A partial index applies a WHERE clause at index creation time, indexing only rows that match the condition. This dramatically reduces index size when you frequently query a subset of data.

-- Index only active orders (maybe 5% of total)
CREATE INDEX idx_orders_active ON orders (user_id, created_at)
  WHERE status = 'active' AND deleted_at IS NULL;
 
-- Index only unprocessed items in a queue table
CREATE INDEX idx_queue_pending ON job_queue (priority, created_at)
  WHERE processed_at IS NULL;

For a table with 100 million rows where only 2% are "active," a partial index on active rows creates an index 50x smaller than a full index. This means the index fits in cache, lookups are faster, and write operations on non-matching rows don't update the index at all.

The critical constraint is that PostgreSQL can only use a partial index when the query's WHERE clause implies the index's WHERE clause. The query must filter on the same conditions (or logically stronger ones) for the optimizer to choose the partial index.

Expression Indexes

Expression indexes index the result of a function or expression rather than raw column values. This is essential when queries filter on computed values.

-- Case-insensitive email lookup
CREATE INDEX idx_users_email_lower ON users (LOWER(email));
 
-- Date extraction for partition-like queries
CREATE INDEX idx_events_year_month ON events (EXTRACT(YEAR FROM event_date), EXTRACT(MONTH FROM event_date));
 
-- JSON field indexing
CREATE INDEX idx_data_name ON json_table ((data->>'name'));
 
-- Trigram index for fuzzy text search
CREATE EXTENSION pg_trgm;
CREATE INDEX idx_products_name_trgm ON products USING gin (name gin_trgm_ops);

When a query uses WHERE LOWER(email) = 'user@example.com', PostgreSQL matches it to the expression index only if the expression in the WHERE clause exactly matches the expression in the index definition. This means your application code must use the same function call syntax.

Multi-Column Index Design

Composite (multi-column) indexes follow the "leftmost prefix" rule. An index on (a, b, c) supports queries filtering on a, (a, b), or (a, b, c), but not queries filtering only on b or c. Column order matters enormously.

The optimal column order follows the ESR (Equality, Sort, Range) rule:

-- Query pattern: equality on status, sort by created_at, range on amount
SELECT * FROM orders
WHERE status = 'completed'
  AND created_at BETWEEN '2024-01-01' AND '2024-06-30'
ORDER BY created_at;
 
-- Optimal index: equality columns first, sort column next, range column last
CREATE INDEX idx_orders_esr ON orders (status, created_at, amount);

Placing equality columns first allows PostgreSQL to seek directly to the matching rows. The sort column next enables index ordering to satisfy ORDER BY without a separate sort step. The range column last allows index-based filtering within the already-located and sorted subset.

Step-by-Step Implementation

Analyzing Current Index Usage

Before creating new indexes, understand how your existing indexes are used (or not used):

-- Find unused indexes (never scanned since last stats reset)
SELECT
  schemaname,
  relname AS table_name,
  indexrelname AS index_name,
  idx_scan AS times_used,
  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
  AND indexrelid NOT IN (
    SELECT conindid FROM pg_constraint WHERE contype IN ('p', 'u')
  )
ORDER BY pg_relation_size(indexrelid) DESC;
-- Find tables with sequential scans that might benefit from indexes
SELECT
  schemaname,
  relname AS table_name,
  seq_scan,
  seq_tup_read,
  idx_scan,
  n_live_tup AS row_count,
  CASE WHEN seq_scan > 0
    THEN ROUND(seq_tup_read::numeric / seq_scan, 0)
    ELSE 0
  END AS avg_rows_per_seq_scan
FROM pg_stat_user_tables
WHERE seq_scan > 100
  AND n_live_tup > 10000
ORDER BY seq_tup_read DESC;

Creating Covering Indexes

Identify queries that would benefit from covering indexes by looking at EXPLAIN (ANALYZE, BUFFERS) output:

EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT user_id, total_amount, status
FROM orders
WHERE user_id = 12345;
 
-- Look for "Index Scan using idx_orders_user_id"
-- followed by "Heap Fetches: N"
-- If N is close to rows returned, a covering index would help
-- Create the covering index
CREATE INDEX CONCURRENTLY idx_orders_user_covering
  ON orders (user_id)
  INCLUDE (total_amount, status, created_at);
 
-- CONCURRENTLY allows creation without locking the table
-- Trade-off: creation takes longer but doesn't block writes

Building Expression Indexes for Common Queries

-- Analyze query patterns for case-insensitive searches
EXPLAIN ANALYZE
SELECT * FROM users WHERE LOWER(email) = 'test@example.com';
-- Will show "Seq Scan" without expression index
 
-- Create expression index
CREATE INDEX CONCURRENTLY idx_users_email_lower
  ON users (LOWER(email));
 
-- Re-run the same EXPLAIN - now shows "Index Scan using idx_users_email_lower"
EXPLAIN ANALYZE
SELECT * FROM users WHERE LOWER(email) = 'test@example.com';

Implementing Partial Indexes for Queue Tables

Queue tables are a classic use case for partial indexes because queries always filter for unprocessed items:

-- Queue table pattern
CREATE TABLE job_queue (
  id BIGSERIAL PRIMARY KEY,
  job_type VARCHAR(50) NOT NULL,
  payload JSONB NOT NULL,
  priority INTEGER DEFAULT 0,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  processed_at TIMESTAMPTZ,
  error_message TEXT
);
 
-- Partial index: only unprocessed jobs
-- This index is tiny compared to the full table
CREATE INDEX idx_job_queue_pending
  ON job_queue (priority DESC, created_at ASC)
  WHERE processed_at IS NULL;
 
-- This query uses the partial index efficiently
SELECT * FROM job_queue
WHERE processed_at IS NULL
ORDER BY priority DESC, created_at ASC
LIMIT 10
FOR UPDATE SKIP LOCKED;

Index Maintenance Script

-- Reindex bloated indexes (non-blocking)
REINDEX INDEX CONCURRENTLY idx_users_email_lower;
 
-- Check index bloat
SELECT
  indexrelname,
  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
  ROUND(100 * pgstatindex(indexrelid)::record::text::json->>'dead_items'::float /
    NULLIF((pgstatindex(indexrelid)::record::text::json->>'leaf_items')::float, 0), 2) AS bloat_pct
FROM pg_stat_user_indexes;

Real-World Use Cases

An e-commerce platform with 5 million products had a search query filtering by category, price range, and availability, sorted by relevance score. The original query took 800ms:

-- Slow query: sequential scan on 5M rows
SELECT id, name, price, relevance_score
FROM products
WHERE category_id = 42
  AND price BETWEEN 10 AND 100
  AND in_stock = true
ORDER BY relevance_score DESC
LIMIT 50;

A covering index following the ESR rule reduced this to 3ms:

CREATE INDEX idx_products_search
  ON products (category_id, in_stock, relevance_score DESC)
  INCLUDE (name, price)
  WHERE in_stock = true;

The partial condition (WHERE in_stock = true) and covering columns (INCLUDE name, price) eliminated both the heap access and the sort operation, delivering results 266x faster.

Use Case 2: Multi-Tenant SaaS Data Access

A SaaS application stored all tenant data in a single table, with every query filtering by tenant_id. As the table grew to 500 million rows, queries slowed because the index on (tenant_id) was 8 GB and didn't fit in cache.

The solution was a composite index designed for the most common query pattern:

-- Original: 8GB index, frequent cache misses
CREATE INDEX idx_events_tenant ON events (tenant_id);
 
-- Optimized: covers the most common query pattern
CREATE INDEX idx_events_tenant_time
  ON events (tenant_id, created_at DESC)
  INCLUDE (event_type, payload);

This covering index supports tenant-scoped queries with time ordering, common in dashboards and audit logs, without heap access. The INCLUDE clause avoided adding the large payload column to the B-tree internal pages, keeping the index compact.

Use Case 3: Time-Series Analytics with Expression Indexes

A monitoring system storing sensor readings needed to query by month efficiently. Queries used EXTRACT(YEAR FROM reading_at) and EXTRACT(MONTH FROM reading_at) in their WHERE clauses:

CREATE INDEX idx_readings_month
  ON sensor_readings (sensor_id, EXTRACT(YEAR FROM reading_at), EXTRACT(MONTH FROM reading_at));
 
-- Now this query uses the expression index
SELECT AVG(value)
FROM sensor_readings
WHERE sensor_id = 100
  AND EXTRACT(YEAR FROM reading_at) = 2024
  AND EXTRACT(MONTH FROM reading_at) = 6;

Best Practices for Production

  1. Use CREATE INDEX CONCURRENTLY: Always create indexes on production tables without holding an exclusive lock. This prevents blocking writes during index creation. The trade-off is that creation takes longer and cannot run inside a transaction.

  2. Follow the ESR rule for composite indexes: Order columns by Equality predicates first, Sort columns second, and Range predicates last. This ensures the index can both filter and sort efficiently.

  3. Prefer covering indexes for high-frequency queries: Adding INCLUDE columns to indexes eliminates heap access for the most common query patterns. Monitor Heap Fetches in EXPLAIN (ANALYZE, BUFFERS) output to identify candidates.

  4. Use partial indexes for skewed data distributions: When your query consistently filters for a small subset of rows (active records, unprocessed items, non-deleted rows), a partial index is dramatically smaller and faster.

  5. Monitor unused indexes: Every unused index wastes storage, slows down writes, and increases vacuum overhead. Query pg_stat_user_indexes regularly to identify and drop indexes that are never scanned.

  6. Benchmark with realistic data volumes: Index performance on 1,000 rows tells you nothing about performance on 10 million rows. Use production-sized datasets and EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) for meaningful benchmarks.

  7. Consider index-only scans when designing queries: If a query only needs columns available in an existing index, restructure the query to avoid selecting additional columns. This can turn a slow index scan into a fast index-only scan.

  8. Vacuum regularly for index-only scan efficiency: The visibility map must be up-to-date for PostgreSQL to use index-only scans. On write-heavy tables, ensure autovacuum runs frequently or schedule manual vacuum operations.

Common Pitfalls and Solutions

PitfallImpactSolution
Over-indexing write-heavy tablesSlower writes, increased storage, longer vacuum timesAudit with pg_stat_user_indexes, drop unused indexes
Wrong column order in composite indexesIndex cannot be used for filtering or sortingFollow ESR rule: Equality, Sort, Range
Forgetting CONCURRENTLY on productionTable locked during index creation, blocking all writesAlways use CREATE INDEX CONCURRENTLY in production
Ignoring index bloatIndexes grow much larger than necessary, degrading cache efficiencySchedule REINDEX CONCURRENTLY during maintenance windows
Assuming indexes are always usedQueries with functions or type casts may bypass indexesUse expression indexes or rewrite queries to match index definitions
Partial index WHERE mismatchIndex exists but optimizer can't use it because query WHERE doesn't imply index WHEREEnsure query conditions logically include the index predicate

Performance Optimization

Index-Only Scan Optimization

To maximize index-only scan utilization, consider adding frequently-needed columns as INCLUDE columns:

-- Before: heap access needed for username
SELECT id, username, email FROM users WHERE email = 'user@example.com';
 
-- After: index-only scan possible
CREATE INDEX idx_users_email_covering
  ON users (email) INCLUDE (username);
 
-- Verify with EXPLAIN
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, username, email FROM users WHERE email = 'user@example.com';
-- Should show "Index Only Scan" with 0 heap fetches

BRIN Indexes for Time-Series Data

For naturally ordered data (timestamps, auto-incrementing IDs), BRIN (Block Range INdex) indexes are dramatically smaller than B-tree indexes:

-- B-tree on timestamp: ~500 MB for 100M rows
CREATE INDEX idx_events_time_btree ON events USING btree (created_at);
 
-- BRIN on timestamp: ~500 KB for 100M rows
CREATE INDEX idx_events_time_brin ON events USING brin (created_at)
  WITH (pages_per_range = 32);

BRIN indexes store min/max values for block ranges rather than individual rows. For time-series data where new rows have increasing timestamps, BRIN achieves near-perfect correlation between physical position and index value, delivering excellent compression and fast range scans.

Comparison with Alternatives

FeatureB-TreeHashGINGiSTBRIN
Best forRange queries, sortingEquality-onlyFull-text, arrays, JSONGeometric, range typesOrdered sequential data
Index sizeMediumMediumLargeMediumVery small
Supports ORDER BYYesNoNoPartialNo
Partial index supportYesYes (PG 10+)YesYesYes
INCLUDE columnsYes (PG 11+)NoNoNoNo
Typical use caseGeneral purposeHigh-cardinality equalityJSONB containmentPostGIS spatialTime-series timestamps

Advanced Patterns

Index-Backed Unique Constraints with Partial Scopes

-- Unique email, but only among non-deleted users
CREATE UNIQUE INDEX idx_users_active_email
  ON users (email)
  WHERE deleted_at IS NULL;
 
-- This INSERT succeeds even if a deleted user has the same email
INSERT INTO users (email, name) VALUES ('john@example.com', 'John');

Covering Indexes for Pagination

-- Cursor-based pagination pattern
CREATE INDEX idx_orders_pagination
  ON orders (created_at DESC, id)
  INCLUDE (user_id, total_amount, status);
 
-- Efficient cursor query: uses index-only scan
SELECT id, user_id, total_amount, status
FROM orders
WHERE (created_at, id) < ('2024-06-01', 12345)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Testing Strategies

-- Compare query plans with and without an index
SET enable_indexscan = off;
EXPLAIN ANALYZE SELECT ... ;  -- Without index
SET enable_indexscan = on;
EXPLAIN ANALYZE SELECT ... ;  -- With index
 
-- Measure index creation time
\timing on
CREATE INDEX CONCURRENTLY idx_test ON large_table (column_name);
 
-- Verify index is used by specific queries
EXPLAIN (FORMAT YAML)
SELECT * FROM orders WHERE user_id = 123;
-- Look for "Index Name" in the output

Future Outlook

PostgreSQL continues to evolve its indexing capabilities. Recent versions introduced improvements to B-tree deduplication (reducing index size for low-cardinality columns), improved GIN index compression, and better parallel index building. Future versions may add support for incremental materialized view maintenance using indexes, and there's ongoing work on Zheap, a new storage engine that would change how indexes interact with MVCC.

Cloud databases like AlloyDB and Aurora are pushing index recommendations using AI-based query analysis, automatically suggesting optimal indexes based on workload patterns. The trend toward automated index management will make understanding index internals even more valuable—developers who understand why an index was recommended can make better decisions about when to accept or reject those recommendations.

Conclusion

Advanced indexing is one of the most cost-effective performance optimizations available. A well-designed index can transform a query from scanning millions of rows to reading a few hundred, without adding hardware or rewriting application logic.

Key takeaways:

  1. Covering indexes eliminate heap access: Use INCLUDE columns to satisfy queries entirely from the index, especially for high-frequency read patterns.
  2. Partial indexes shrink index size dramatically: Index only the rows you actually query. For queue tables, active records, and filtered queries, partial indexes are a game-changer.
  3. Expression indexes unlock computed value optimization: When your queries use functions on columns, create expression indexes that match the exact function calls.
  4. Follow the ESR rule: Order composite index columns by Equality, Sort, Range for optimal filtering and sorting.
  5. Monitor and maintain: Track unused indexes, watch for bloat, and use CREATE INDEX CONCURRENTLY for production changes.

Start by analyzing your slowest queries with EXPLAIN (ANALYZE, BUFFERS), identify the access patterns that would benefit from advanced indexes, and implement changes incrementally. The performance gains are often dramatic and permanent.