See It Flow
Interactive guide · 10 chapters · ~26 min read

PostgreSQL Production Engineering

An unindexed foreign key or a bloated table that autovacuum can't keep up with doesn't show up in EXPLAIN until it's already slow. This section covers index strategy, query performance, vacuum, replication, and connection pooling — the mechanisms that keep Postgres fast as data and traffic grow.

Mangesh MandlikLast updated:
01 · Speed

Index Optimization

The right index turns a multi-second query into a sub-millisecond one. The wrong index just slows down every write.

Composite indexes

An index on (customer_id, created_at) serves queries filtering on customer_id alone, or customer_id + created_at range — but not created_at alone. Column order matters.

Partial indexes

CREATE INDEX ON orders(id) WHERE status = 'pending' — index only the rows a query actually needs, keeping the index small and fast to maintain.

Covering indexes (INCLUDE)

Add non-key columns to the index leaf so a query can be answered from the index alone (an index-only scan), without visiting the heap.

02 · Diagnosis

Query Performance

Most production slowness traces back to one of three things: a missing index, a bad plan, or lock contention.

N+1 queries

An ORM issuing one query per row instead of a single JOIN. The classic application-layer performance bug — invisible in Postgres itself.

Statistics staleness

The planner's cost estimates come from ANALYZEd statistics. If autoanalyze falls behind after a bulk load, the planner can pick a terrible plan.

PostgreSQL Performance Tuning: Where to Start

Work through the three usual causes in order: add or fix the missing index, read the plan with EXPLAIN ANALYZE, and check for lock contention. Keep planner statistics fresh with ANALYZE so the planner can choose good plans, and make sure autovacuum keeps up with dead tuples.

03 · The tool

EXPLAIN ANALYZE

EXPLAIN shows the plan. EXPLAIN ANALYZE actually runs the query and shows real timing next to the estimate.

sqlAlways add BUFFERS — it shows cache hits vs disk reads, which usually explains the real bottleneck.
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE customer_id = 42;

Reading cost

cost=0.29..8.31 — startup cost .. total cost, in arbitrary planner units. Compare relative costs across plans, not absolute milliseconds.

Rows estimated vs actual

A big gap between rows= (estimate) and the actual row count is the #1 sign of stale statistics or a correlated-columns problem the planner can't see.

04 · Housekeeping

Vacuum & Autovacuum

Every UPDATE and DELETE leaves a dead tuple behind. Vacuum is the process that reclaims that space — and it never stops mattering.

Because MVCC never overwrites a row in place, dead tuples accumulate. VACUUM scans a table, marks the space used by dead tuples as reusable, and updates the visibility map so future scans can skip pages with no dead rows.

Autovacuum

A background process that runs VACUUM automatically once a table's dead-tuple count crosses a threshold (autovacuum_vacuum_scale_factor, default 20% of table size).

Transaction ID wraparound

Transaction ids are a finite 32-bit counter. If autovacuum can't freeze old tuples fast enough, Postgres eventually forces a shutdown to prevent wraparound corruption — a rare but catastrophic failure mode.

05 · Availability

Replication

Streaming replication is how Postgres survives a machine dying and how it scales reads horizontally.

PrimaryWAL writerReplica 1read-onlyReplica 2read-onlyStandbypromoted on failover
A primary streams WAL to one or more replicas. Reads can be served from any caught-up replica.

Asynchronous replication

The default. Fast writes, but a few milliseconds of the most recent commits can be lost if the primary fails before the replica catches up.

Synchronous replication

synchronous_commit = on with synchronous_standby_names set — the primary waits for at least one replica to acknowledge before confirming commit. Zero data loss, higher write latency.

06 · Scale

Partitioning

A billion-row table is really a management problem disguised as a performance one. Partitioning splits it into many smaller physical tables behind one logical name.

Range partitioning

Split by a range, most commonly time — one partition per month. Old partitions can be dropped instantly instead of running a slow DELETE.

List / hash partitioning

Split by discrete values (e.g. tenant_id) or an even hash — useful for multi-tenant workloads where you want to isolate or shard by tenant.

07 · Connections

Connection Pooling

Each Postgres connection is a full OS process — expensive to spin up and limited in count. Pooling is not optional at any real scale.

PgBouncer

The standard external pooler. transaction pooling mode reuses a small number of real Postgres connections across many client connections, multiplexed per transaction.

08 · Visibility

Monitoring

Postgres exposes almost everything you need through system views — the discipline is watching them before they become an incident.

pg_stat_activity

Live view of every connection and what it's currently running — the first place to look during an incident.

pg_stat_statements

Aggregated per-query statistics: total time, calls, mean time. The single most useful extension for finding your worst queries.

Replication lag

pg_stat_replication on the primary shows how far behind each replica is — critical for read-replica correctness and failover readiness.

09 · Incidents

Common Production Issues

A handful of failure modes account for most Postgres incidents.

Connection exhaustion

Too many app instances, no pooler, max_connections reached — new connections start failing outright.

Lock contention / long transactions

One forgotten BEGIN without COMMIT in a debugging session can hold a lock for hours, blocking every writer behind it.

Table/index bloat

Autovacuum falling behind on a high-churn table — queries get slower over weeks with no obvious single cause.

Disk full from WAL

A replica disconnects, and a replication slot (or a failing archive command) makes the primary keep WAL it cannot yet discard, so WAL accumulates and the disk fills — this can take the primary down entirely. max_slot_wal_keep_size (PostgreSQL 13+) caps what slots can retain.

10 · Playbook

Debugging a Slow Postgres

A repeatable order of operations turns a stressful incident into a checklist.

Key takeaways
  • Check pg_stat_activity for long-running or blocked queries first.
  • Run EXPLAIN (ANALYZE, BUFFERS) on the slow query — look for sequential scans on large tables and rows-estimate vs actual gaps.
  • Check autovacuum status and dead tuple ratios on the tables involved.
  • Check replication lag if reads are served from a replica.
  • Check connection count against max_connections and pooler saturation.
  • Only then consider a schema or index change — never guess before measuring.