Module P-8·27 min read

EXPLAIN ANALYZE for practitioners, key config parameters, N+1 queries, PgBouncer basics, and slow query logging.

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

P-8 — Performance Tuning for Application Engineers

There is a version of performance tuning that belongs to DBAs: they adjust shared_buffers, tune checkpoint_completion_target, read kernel parameters, and operate at the infrastructure level. That work matters at scale.

There is another version that belongs to application engineers — and it produces far larger wins in far less time. This module covers the second kind: reading EXPLAIN ANALYZE output confidently, eliminating N+1 queries, understanding the configuration knobs that actually matter for applications, and using PgBouncer correctly. These are the skills that turn a slow application into a fast one without needing infrastructure access.


Reading EXPLAIN ANALYZE

EXPLAIN shows the query plan PostgreSQL chose. EXPLAIN ANALYZE executes the query and shows both the plan and the actual runtime measurements. Always use ANALYZE when debugging real performance — the plan alone is often misleading.

sql

Anatomy of an EXPLAIN Output

text

Key things to read:

cost=X..Y — estimated cost units. The first number is startup cost (cost before first row). The second is total cost. These are planner estimates, not milliseconds.

actual time=X..Y — real milliseconds. First number: time to first row. Second: total time. This is what you actually care about.

rows=N (estimated) vs rows=N (actual) — if these diverge significantly (10x or more), the planner has bad statistics. Run ANALYZE table_name to refresh them.

loops=N — how many times this node executed. If a nested loop runs 10,000 times, multiply the actual time by loops to get true total time.

Rows Removed by Filter — rows that were scanned and discarded. High numbers here mean a sequential scan is doing unnecessary work that an index could avoid.

The Most Important Nodes to Recognise

Seq Scan — full table scan. Not always bad (small tables, high selectivity filters), but suspicious on large tables with a filter.

Index Scan — uses a B-tree index to find rows. Good.

Index Only Scan — all needed data is in the index itself, no heap fetch. Best case.

Bitmap Index Scan + Bitmap Heap Scan — fetches a set of matching row locations via index, then reads those pages. Good for queries returning many rows from an index.

Nested Loop — for each row in the outer relation, look up matching rows in the inner relation. Fine when the outer set is small. Catastrophic when the outer set is large (N+1 pattern).

Hash Join — build a hash table from the smaller relation, probe it with the larger. Good for large joins on equality conditions.

Merge Join — sort both sides and merge. Good when both inputs are already sorted.


The BUFFERS Option

sql

Adding BUFFERS shows cache hits and misses:

Buffers: shared hit=12 read=3
  • hit — pages served from PostgreSQL's shared buffer cache (fast, no disk I/O)
  • read — pages read from disk (slow, or from OS page cache)

A query with many read pages on a hot table suggests shared_buffers is too small or the working set doesn't fit in memory.


Identifying Missing Indexes

The most common fix for slow queries is a missing index. Signs to look for in EXPLAIN ANALYZE:

  1. Seq Scan on a large table with Rows Removed by Filter >> actual rows returned
  2. actual time on a Seq Scan node is large
  3. The filter column isn't indexed
sql

CREATE INDEX CONCURRENTLY builds the index without locking the table — always use it in production.


The N+1 Query Problem

The N+1 problem is the most common performance killer in production applications. It happens when you execute one query to fetch N parent records, then execute one query per record to fetch related data — producing N+1 total queries.

javascript

At 100 orders this is noticeable. At 1000 orders this is a page timeout.

Fix 1: JOIN in SQL

sql

One query. The database does the join efficiently using indexes.

Fix 2: IN clause batch fetch

javascript

Fix 3: ORM eager loading

In ORMs, N+1 manifests through lazy loading. Most ORMs have an "eager load" or "include" option:

javascript

How to Detect N+1 in Production

Enable log_min_duration_statement to catch slow queries. But for N+1, individual queries may each be fast — the problem is volume. Use a query counter middleware or APM tool (Datadog, New Relic) that shows query count per request. Any request making 50+ identical queries is almost certainly N+1.

sql

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.