PostgreSQL Query Optimization: EXPLAIN ANALYZE, Indexes, and Planner

Slow PostgreSQL queries slow down business processes and make users wait for the system's response. We conduct an in-depth analysis of execution plans and tune indexes to speed up database performance. Our team delivers turnkey optimization—from diagnostics to implementation and ongoing support—ensuring stable performance for your services.

Our competencies:

Frequently Asked Questions

Latest works

  • Development of a web application for FEEDME
    Development of a web application for FEEDME
    1365
  • Development of an online store for the company FURNORO
    Development of an online store for the company FURNORO
    1330
  • B2B Advance company logo design
    B2B Advance company logo design
    776
  • Development of a web application for Enviok
    Development of a web application for Enviok
    1077
  • AIDER company logo development
    AIDER company logo development
    1022
  • CRM development for Chasseurs
    CRM development for Chasseurs
    1113

PostgreSQL Query Optimization: EXPLAIN ANALYZE, Indexes, and Planner

Slow queries in PostgreSQL are a headache for any DevOps engineer. A query that used to take half a second with 100k rows starts hanging for 8 seconds at 2 million — and everyone looks at the developer. The query itself is correct, the schema is normal, the hardware hasn't changed. The issue is the PostgreSQL planner, which chose one plan for a small table and switched to another as data grew. Or that ANALYZE hasn't run in a month, and statistics are stale. Or that an index exists, but the planner doesn't use it due to a wrong statistics target. We've encountered these problems dozens of times and developed a systematic approach to optimization. Our engineers with 10+ years of experience guarantee a reduction in database response time of up to 50% and decreased server load. We'll evaluate your project: contact us for a consultation.

How to Read EXPLAIN ANALYZE Correctly?

EXPLAIN shows the plan without executing the query. EXPLAIN ANALYZE actually executes and shows actual vs. estimated rows. The difference between them is key to diagnosis.

EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT u.email, COUNT(o.id)
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.created_at > CURRENT_DATE - INTERVAL '1 year'
GROUP BY u.email;

The BUFFERS flag adds information about hits in shared_buffers and disk accesses — critical for understanding how much the query stresses I/O. Read the plan bottom-up: lower nodes execute first.

What to look for first:

Rows estimation mismatch. If the planner expected 10 rows (rows=10) but got 50000 (actual rows=50000), statistics are stale or statistics_target is too low. This forces the planner to choose Nested Loop where Hash Join is needed.

-- view statistics and increase granularity
SELECT n_distinct, correlation, most_common_vals
FROM pg_stats
WHERE tablename = 'orders' AND attname = 'status';

ALTER TABLE orders ALTER COLUMN status SET STATISTICS 500;
ANALYZE orders;

Sequential Scan on a large table. Not always bad — if a query returns >5-10% of table rows, Seq Scan is cheaper than Index Scan. But if returning rows are few and Seq Scan is still chosen, the index likely does not exist or does not cover the condition.

Hash Join with large Batches. If Batches > 1 in a Hash Join, the hash table did not fit in work_mem and spilled to disk. Solution: SET work_mem = '64MB' for heavy analytical queries (not globally — that would consume all memory with concurrent sessions).

Why Does the Planner Choose a Wrong Plan?

PostgreSQL uses a cost-based optimizer. Operation costs are set by parameters in postgresql.conf. The most common case of an incorrect plan is outdated cost parameters for SSD:

# For SSD, lower random_page_cost (default=4.0) random_page_cost = 1.1 effective_cache_size = 12GB # ~75% of server RAM 

With random_page_cost = 4.0, the planner considers random disk access expensive and prefers Seq Scan even where Index Scan makes more sense. On SSD, this leads to wrong decisions. PostgreSQL Documentation: random_page_cost should be lowered for SSD.

If after gathering statistics and fixing parameters the query still picks a bad plan, you can force it via pg_hint_plan (extension) or session parameters:

-- temporarily disable Nested Loop for current session
SET enable_nestloop = off;
EXPLAIN ANALYZE SELECT ...;
SET enable_nestloop = on;

This is a diagnostic tool, not a permanent solution. Permanently disabling a planner operator breaks other queries.

Indexes: Which, When, and Why

The standard B-tree index works for equality and ranges on regular types. For specific tasks, other types are needed:

-- partial index: index only active orders
CREATE INDEX idx_orders_active ON orders(created_at) WHERE status = 'active';

-- composite index: column order matters
-- works for WHERE user_id = ? AND status = ?
-- and for WHERE user_id = ? (first column)
-- does NOT work for WHERE status = ? (only second column)
CREATE INDEX idx_orders_user_status ON orders(user_id, status);

-- covering index: include needed columns, avoid heap fetch
CREATE INDEX idx_orders_covering ON orders(user_id) INCLUDE (total_amount, created_at);

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

Covering indexes (with INCLUDE) are especially useful for frequently executed queries that read 2-3 specific fields. The planner performs an Index Only Scan — data is taken directly from the index, without accessing the heap.

Bloated indexes are a separate problem. After mass UPDATE/DELETE, indexes accumulate dead pages. pgstatindex shows bloat:

SELECT * FROM pgstatindex('idx_orders_user_status'); -- leaf_fragmentation > 30% → time for REINDEX CONCURRENTLY 

REINDEX CONCURRENTLY does not block reads and writes — unlike regular REINDEX.

Example of plan analysis with row mismatch

Suppose we have a query:

EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE status = 'pending'; 

The plan shows: Seq Scan on orders (cost=0.00..4567.89 rows=100 actual rows=25000). A 250x discrepancy — statistics are not updated. After ANALYZE orders, the plan changes to Index Scan with correct rows.

Case Study: Laravel Application Optimization

Our client — a Laravel project with PostgreSQL. A paginated list query with filters on status and date:

SELECT * FROM products WHERE category_id = 5 AND is_active = true ORDER BY created_at DESC LIMIT 20 OFFSET 10000; 

With OFFSET 10000, the planner scans 10020 rows, discards 10000. With an index on (category_id, is_active, created_at DESC), Index Scan works, but offset is still expensive. Solution — keyset pagination: WHERE (created_at, id) < ($last_created_at, $last_id) with a corresponding composite index. After implementation, execution time dropped from 2.3 s to 0.05 s.

Another case — queries with LIKE '%pattern%' without pg_trgm. Full scan on every query. After CREATE INDEX ... USING GIN(name gin_trgm_ops), the same queries run via index.

Our Process

  1. Collect slow queries from pg_stat_statements (top by total_exec_time) and pg_stat_activity (long-running active queries)
  2. EXPLAIN (ANALYZE, BUFFERS) for each candidate
  3. Analyze mismatched row estimates — update statistics, increase statistics_target where needed
  4. Create/rebuild indexes via CREATE INDEX CONCURRENTLY (no locks)
  5. Check planner parameters for specific hardware
  6. Performance test before/after on representative data

What's Included

  • Detailed report for each slow query (plan, recommendations)
  • Scripts for creating/rebuilding indexes
  • Tuning postgresql.conf parameters for your hardware
  • Integration of pg_stat_statements and monitoring setup
  • Training for your team: how to read EXPLAIN and diagnose problems
  • Guarantee: reduction in execution time for top-10 queries by at least 30% or free rework

Timeline and Cost

Type of Work Typical Duration
Analysis of top-5 slow queries 4-8 hours
Full audit + indexes + planner 2-3 days
Optimization for high concurrency (OLTP) 3-5 days

Cost is calculated individually depending on scope. We'll evaluate your project in one day: contact us.

Index Type When to Use Features
B-tree Equality, ranges, sorting Standard, universal
GIN JSONB, arrays, full-text search Requires jsonb_path_ops for optimal performance
GiST Geodata, full-text search Supports data types unsuitable for B-tree
BRIN Large tables with natural ordering Saves space but less effective for point queries

Our engineers have 10+ years of experience in DevOps and over 50 successful database optimization projects. Entrust your PostgreSQL tuning to professionals — get a consultation today.