PostgreSQL Index Tuning Guide — How to Reduce Slow Queries with EXPLAIN ANALYZE
PostgreSQL performance problems are not solved by creating more indexes blindly. This post explains how to read EXPLAIN ANALYZE, when Seq Scan is acceptable, how composite index ordering works, when partial indexes help, and how to tune sorting and pagination queries in practice.
Why index tuning is often misunderstood
When a query is slow, many teams immediately say, “let’s add another index.”
But in PostgreSQL, poorly chosen indexes can increase write cost, consume space, and still fail to help the real query pattern.
The practical approach is:
- inspect the actual execution plan
- understand which filter, join, or sort dominates cost
EXPLAIN vs EXPLAIN ANALYZE
EXPLAINshows the planned execution pathEXPLAIN ANALYZEshows what actually happened
In real tuning work, EXPLAIN ANALYZE is far more useful because real row counts and timing matter more than estimated cost alone.
What to inspect first
Start with these:
- is there a
Seq Scan - is there a big gap between estimated rows and actual rows
- is sorting expensive
- is a Nested Loop exploding in repetitions
- is Bitmap Heap Scan doing heavy work
Those signals usually point to the right tuning direction quickly.
Seq Scan is not always bad
A sequential scan may be optimal when a large portion of the table must be read anyway.
It becomes suspicious when:
- a highly selective filter still results in full scan
- a large table is scanned for a small
LIMIT - frequently repeated filter and sort patterns have no supporting index
The question is not “why is Seq Scan present” but “why did the planner choose it here.”
Composite index ordering matters
Consider:
SELECT *
FROM orders
WHERE tenant_id = 10
AND status = 'PAID'
ORDER BY created_at DESC
LIMIT 20;
A good candidate is:
CREATE INDEX idx_orders_tenant_status_created_at
ON orders (tenant_id, status, created_at DESC);
That works because the index matches:
- the main filtering columns first
- then the sort order
Index order is tightly tied to query shape.
Partial indexes can be extremely effective
If traffic focuses on a subset of rows, a partial index may outperform a full-table index.
CREATE INDEX idx_orders_paid_created_at
ON orders (created_at DESC)
WHERE status = 'PAID';
Benefits:
- smaller index size
- lower write overhead
- better optimization for the most common filter path
OFFSET pagination becomes expensive
This pattern degrades badly at scale:
SELECT *
FROM orders
ORDER BY created_at DESC
LIMIT 50 OFFSET 10000;
Keyset pagination is often better:
SELECT *
FROM orders
WHERE created_at < :cursor
ORDER BY created_at DESC
LIMIT 50;
With the right index, it remains stable on large tables.
Sorting can be the real bottleneck
Sometimes filtering is cheap but sorting is expensive.
Common examples:
- latest activity feeds
- recent events by status
- user-specific recent actions
In such cases, an index that combines filtering and ordering can remove a large sort cost.
Covering indexes can help selectively
PostgreSQL supports INCLUDE:
CREATE INDEX idx_orders_lookup
ON orders (tenant_id, created_at DESC)
INCLUDE (status, total_amount);
This can reduce heap access for some lookup patterns.
But it should be used carefully because larger indexes also mean higher maintenance cost.
Statistics problems can break good plans
If estimated rows and actual rows differ heavily, planner statistics may be part of the issue.
Check:
- whether
ANALYZEruns often enough - whether data distribution is heavily skewed
- whether join columns are represented well in statistics
Without accurate statistics, even a good index can be ignored.
Common mistakes
- creating overlapping indexes
- optimizing reads while ignoring write amplification
- indexing low-cardinality columns blindly
- choosing composite index order without verifying query patterns
- tuning based on assumptions instead of the real ORM-generated SQL
A practical tuning flow
- identify the slow query
- capture
EXPLAIN ANALYZE - isolate filter, join, and sort cost
- design indexes around the most frequent query pattern
- validate both read gain and write impact after deployment
Closing thoughts
PostgreSQL index tuning is not about creating more indexes. It is about reading the execution plan and removing the most expensive work.
In practice, the best gains usually come from a disciplined process:
- inspect the real query
- read the actual plan
- add fewer but more precise indexes
rather than blindly indexing every column that appears in SQL.