Showing posts with label postgresql. Show all posts
Showing posts with label postgresql. Show all posts

Friday, April 17, 2026

Database Transactions in 2026: ACID, Isolation Levels, MVCC, and Deadlock Prevention

Hero image

Introduction

Transaction bugs are among the most insidious failures in production systems. They almost never appear in your local test environment. They don't produce clean stack traces. They surface as subtle data corruption — a bank balance that is off by the exact amount of a concurrent withdrawal, a cart that charges the same order twice during a flash sale, an inventory count that goes negative despite a CHECK constraint. By the time you notice something is wrong, the transaction that caused the issue finished hours ago and left no obvious trace.

What makes this category of bug especially dangerous is its concurrency dependency. A function that transfers money between two accounts can pass a thousand unit tests and still corrupt data the moment two users call it at the same millisecond. You need real concurrent load to trigger these anomalies, and real concurrent load typically means production.

The underlying mechanics that prevent these bugs — ACID properties, isolation levels, and Multi-Version Concurrency Control — have been part of relational databases for decades. But knowing that "ACID means reliable" is not enough to write correct concurrent code. You need to understand exactly what each ACID property guarantees, what it does not guarantee, and what happens when you trade one guarantee for another.

Isolation levels, in particular, are widely misunderstood. Most developers know their database has them. Few know which anomalies each level actually prevents. Fewer still understand that PostgreSQL's Read Committed (the default) lets non-repeatable reads happen, or that Serializable uses Snapshot Isolation rather than actual serial execution.

MVCC — the mechanism PostgreSQL uses to implement isolation — explains behaviors that otherwise seem paradoxical: why reads never block writes, why VACUUM is a correctness requirement rather than optional maintenance, and why you can observe a row version that was deleted by another transaction without holding any lock.

This post works through all of it with concrete examples. Every anomaly is demonstrated with actual SQL. Every pattern has working code you can run against a real PostgreSQL instance. The goal is not a survey — it is a working mental model you can apply to every concurrent data access pattern you write.


1. ACID: What Each Property Actually Means in Practice

ACID is four words that each hide a large amount of implementation complexity. Here is what each property actually guarantees — and what it does not.

Atomicity: All or Nothing, Not Eventually Consistent Parts

Atomicity means a transaction either commits completely or has zero effect. If your BEGIN ... COMMIT block contains twenty SQL statements and statement fifteen fails, none of the first fourteen persist. The database rolls back to the state before BEGIN.

This is not the same as eventual consistency. Atomicity is a synchronous, binary guarantee. There is no "partial commit that will be cleaned up later." If you ROLLBACK — whether explicitly or because the connection drops — every change in that transaction disappears.

The classic bank transfer shows why this matters:

-- Without atomicity, a crash here causes permanent inconsistency
BEGIN;
  UPDATE accounts SET balance = balance - 500 WHERE id = 1;
  -- If the process crashes HERE, account 1 has lost $500 and account 2 gained nothing
  UPDATE accounts SET balance = balance + 500 WHERE id = 2;
COMMIT;

PostgreSQL implements atomicity through its Write-Ahead Log (WAL). Before any data page is modified on disk, a WAL record describing the change is written and flushed. If the server crashes mid-transaction, recovery replays the WAL — but only for committed transactions. Uncommitted WAL records are ignored during recovery.

Consistency: Application Invariants Are Your Responsibility

Consistency is the most misunderstood ACID property. The database enforces structural constraints: NOT NULL, CHECK, UNIQUE, FOREIGN KEY. It will not let you insert a row that violates a foreign key or a balance that violates a CHECK constraint.

But business rule consistency is your code's job. If you have the invariant "a user's total order value must never exceed their credit limit," the database will not enforce that automatically. Your transaction logic must read the current total, compute the new total, verify it does not exceed the limit, and only then insert the order — inside a single transaction with sufficient isolation.

This distinction matters because it defines where bugs live. A database can only be as consistent as the invariants you express. Leaving a business invariant unenforced in code and trusting the database to catch it is a common source of data corruption.

Isolation: Concurrent Transactions Should Not Interfere

Isolation is the property that directly governs concurrency bugs. Ideally, each transaction executes as if it were the only transaction in the system. Realistically, enforcing full isolation has a performance cost, so databases expose isolation levels that let you trade isolation for throughput.

The degree of isolation determines which concurrency anomalies are possible. We will cover these in detail in Section 2. For now: the default PostgreSQL isolation level — Read Committed — prevents dirty reads but allows non-repeatable reads. Most of the subtle bugs described in this post are permitted at Read Committed.

Durability: What "Committed" Actually Means

Durability means once the database returns success from a COMMIT, the data survives any subsequent crash. This sounds obvious but has implementation consequences.

PostgreSQL's durability guarantee depends on fsync. When a transaction commits, PostgreSQL calls fsync() to flush WAL records to disk before returning to the client. If fsync is disabled (a dangerous but sometimes-seen optimization), a crash can lose committed transactions.

"Committed" means the WAL record reached durable storage. It does not mean the data is immediately visible in the heap files — PostgreSQL may not have flushed the actual data pages yet. Recovery will replay the WAL and reconstruct those pages if needed.

ACID vs BASE: An Intentional Trade-off

BASE (Basically Available, Soft state, Eventually consistent) is not a degraded version of ACID — it is a different contract chosen for different workload profiles. Systems like Cassandra or DynamoDB choose availability and partition tolerance over consistency. This is appropriate when you need to write across multiple data centers and can tolerate briefly stale reads.

The problem is when teams accept eventual consistency by accident — using a message queue where they needed a synchronous transaction, or reading from a replica without understanding replication lag. The choice between ACID and BASE should be explicit, documented, and made with full understanding of which anomalies become possible.


Architecture

Architecture diagram

The diagram below shows two concurrent transactions — one with isolation enforced and one without — and how their intermediate states interact:

sequenceDiagram participant T1 as Transaction 1 (Transfer $500) participant DB as PostgreSQL participant T2 as Transaction 2 (Read Balance) Note over T1,T2: WITHOUT ISOLATION (No Transaction) T1->>DB: UPDATE accounts SET balance = balance - 500 WHERE id=1 T2->>DB: SELECT balance FROM accounts WHERE id=1 Note over T2: Reads intermediate state: $500 missing, not yet added to id=2 T1->>DB: UPDATE accounts SET balance = balance + 500 WHERE id=2 Note over T1,T2: WITH ISOLATION (Serializable) T1->>DB: BEGIN T1->>DB: UPDATE accounts SET balance = balance - 500 WHERE id=1 T2->>DB: BEGIN T2->>DB: SELECT balance FROM accounts WHERE id=1 Note over T2: Reads pre-transaction snapshot — sees original $500 balance T1->>DB: UPDATE accounts SET balance = balance + 500 WHERE id=2 T1->>DB: COMMIT T2->>DB: COMMIT Note over T2: Now sees committed post-transfer balance

2. Isolation Levels and the Anomalies They Permit

PostgreSQL implements four isolation levels defined by the SQL standard, though Read Uncommitted is treated identically to Read Committed internally.

Read Uncommitted: Dirty Reads

A dirty read occurs when transaction T2 reads a row that T1 has modified but not yet committed. If T1 rolls back, T2 has read data that never officially existed.

PostgreSQL does not implement dirty reads. Even at READ UNCOMMITTED, it falls back to Read Committed behavior. However, this anomaly exists in MySQL and SQL Server and is the reason READ UNCOMMITTED should essentially never be used.

Read Committed: Non-Repeatable Reads

PostgreSQL's default. Each statement within a transaction sees a fresh snapshot of committed data. This means:

  • No dirty reads (you only see committed data).
  • Non-repeatable reads are possible: if you SELECT balance twice in the same transaction, and another transaction commits a change between your two reads, you see different values.
-- Session 1
BEGIN;
SELECT balance FROM accounts WHERE id = 1;
-- Returns: 1000

-- Session 2 (runs concurrently)
BEGIN;
UPDATE accounts SET balance = 500 WHERE id = 1;
COMMIT;

-- Session 1 (still in same transaction)
SELECT balance FROM accounts WHERE id = 1;
-- Returns: 500  ← different value! Non-repeatable read.
COMMIT;

This is the anomaly that breaks the "read then decide" pattern. If your transaction reads a value to make a business decision, and that value can change before you act on it, your decision is based on stale data.

Repeatable Read: Phantom Reads

At REPEATABLE READ, PostgreSQL takes a snapshot at the start of the transaction (not per-statement as in Read Committed). The same SELECT returns the same rows no matter how many times you run it — but new rows inserted by other transactions can still appear.

-- Session 1
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT COUNT(*) FROM orders WHERE user_id = 42;
-- Returns: 5

-- Session 2 (runs concurrently)
BEGIN;
INSERT INTO orders (user_id, amount) VALUES (42, 99.99);
COMMIT;

-- Session 1 (still in same transaction)
SELECT COUNT(*) FROM orders WHERE user_id = 42;
-- At REPEATABLE READ in PostgreSQL: returns 5 (snapshot isolation prevents phantom)
-- Note: PostgreSQL's REPEATABLE READ actually prevents phantom reads too,
-- but write skew is still possible (see Serializable section)
COMMIT;

Note: PostgreSQL's implementation of Repeatable Read is actually stronger than the SQL standard requires — it uses snapshot isolation which incidentally prevents phantom reads. However, write skew anomalies remain possible.

Serializable: Full Isolation

PostgreSQL uses Serializable Snapshot Isolation (SSI) — a technique that detects when a set of concurrent transactions would produce a result impossible under any serial execution, and aborts one of them.

-- Classic write skew: two doctors both check if someone else is on call
-- Session 1
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT COUNT(*) FROM on_call WHERE shift_id = 1;
-- Returns: 2 (two doctors on call)
-- Decides: it's safe to go off call since 2 > 1
UPDATE on_call SET status = 'off' WHERE doctor_id = 101 AND shift_id = 1;
COMMIT; -- May fail with: ERROR: could not serialize access

-- Session 2 (concurrent)
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT COUNT(*) FROM on_call WHERE shift_id = 1;
-- Also returns: 2
-- Also decides: safe to go off call
UPDATE on_call SET status = 'off' WHERE doctor_id = 102 AND shift_id = 1;
COMMIT; -- One of these commits will succeed; the other will fail

SSI tracks read/write dependencies between transactions. When it detects a cycle (T1 read what T2 will write, T2 read what T1 will write), one transaction is aborted with a serialization failure. Your application must be prepared to retry.

Anomaly Matrix

flowchart TD A["Isolation Level Anomaly Matrix"] subgraph levels["Isolation Levels"] RI["Read Uncommitted"] RC["Read Committed (default)"] RR["Repeatable Read"] SR["Serializable"] end subgraph anomalies["Anomaly Prevention"] D["Dirty Read"] NR["Non-Repeatable Read"] PH["Phantom Read"] WS["Write Skew"] end RI -->|"POSSIBLE"| D RI -->|"POSSIBLE"| NR RI -->|"POSSIBLE"| PH RI -->|"POSSIBLE"| WS RC -->|"PREVENTED"| D RC -->|"POSSIBLE"| NR RC -->|"POSSIBLE"| PH RC -->|"POSSIBLE"| WS RR -->|"PREVENTED"| D RR -->|"PREVENTED"| NR RR -->|"PREVENTED in PG"| PH RR -->|"POSSIBLE"| WS SR -->|"PREVENTED"| D SR -->|"PREVENTED"| NR SR -->|"PREVENTED"| PH SR -->|"PREVENTED"| WS

Choosing the right level: For most CRUD web applications, Read Committed is appropriate. For financial operations — balance checks, inventory deductions, anything with a "check then act" pattern — use Serializable and handle serialization failures with retries. The performance overhead of SSI is typically 5-15% in write-heavy workloads and near-zero for read-heavy workloads.


3. MVCC: How PostgreSQL Implements Isolation Without Locking Reads

Multi-Version Concurrency Control is the mechanism that lets PostgreSQL give readers and writers independent progress without coordination. Readers never block writers. Writers never block readers. This is the fundamental reason PostgreSQL can maintain high read throughput under concurrent write load.

Row Versions: xmin and xmax

Every row in a PostgreSQL heap has two hidden system columns:

  • xmin: the transaction ID that inserted this row version.
  • xmax: the transaction ID that deleted or updated this row version (zero if still live).

When you UPDATE a row, PostgreSQL does not modify the existing row. It marks the old row version with xmax = current_transaction_id and inserts a new row version with xmin = current_transaction_id. The old version remains on the heap until VACUUM removes it.

You can observe this directly:

-- Create a test table and observe xmin/xmax
CREATE TABLE mvcc_demo (id int, val text);
INSERT INTO mvcc_demo VALUES (1, 'original');

-- See the row's transaction metadata
SELECT id, val, xmin, xmax FROM mvcc_demo;
-- xmin shows the transaction that inserted this row
-- xmax is 0 (row is live, not deleted/updated)

-- Now update the row in a transaction
BEGIN;
UPDATE mvcc_demo SET val = 'updated' WHERE id = 1;
-- In another session, query the table:
-- SELECT id, val, xmin, xmax FROM mvcc_demo;
-- You'll see BOTH the old row (xmax = current_txid) and new row (xmin = current_txid)
COMMIT;

-- After commit, VACUUM can remove the old row version
VACUUM mvcc_demo;

Snapshot Visibility

At transaction start (or statement start for Read Committed), PostgreSQL records:
1. The current transaction ID.
2. The set of all currently active (in-progress) transaction IDs.
3. The highest transaction ID assigned so far.

A row version is visible to a snapshot if:
- Its xmin committed before the snapshot was taken (and is not in the active set).
- Its xmax is either zero, or has not committed by the time the snapshot was taken.

This is why a long-running transaction always sees the same snapshot of the data, even as other transactions commit. It does not hold any locks on those rows — it simply does not consider newer committed versions visible.

VACUUM: A Correctness Requirement

Because old row versions accumulate on the heap, VACUUM is not optional maintenance — it is a correctness requirement for two reasons:

  1. Space reclamation: Without VACUUM, heap files grow without bound as updates accumulate dead tuples.
  2. Transaction ID wraparound: PostgreSQL uses 32-bit transaction IDs. After ~2 billion transactions, IDs wrap around. PostgreSQL prevents this by running aggressive autovacuum on tables approaching the wraparound threshold. If VACUUM is blocked for too long, PostgreSQL will stop accepting writes and demand manual VACUUM — a production incident.
-- Monitor dead tuple accumulation
SELECT relname, n_live_tup, n_dead_tup,
       round(n_dead_tup::numeric / nullif(n_live_tup + n_dead_tup, 0) * 100, 2) AS dead_pct,
       last_autovacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY n_dead_tup DESC;

HOT Updates: Avoiding Index Churn

If you update a column that is not indexed, PostgreSQL can use a Heap Only Tuple (HOT) update: the new row version is placed on the same heap page as the old version, and no index entry is created for the new version. The index still points to the old row, which has a pointer to the new version.

HOT updates significantly reduce write amplification for tables with many indexes. They only apply when the updated columns have no indexes and the new row fits on the same page.

Comparison visual
flowchart LR subgraph snap["Transaction Snapshot at T=100"] SN["xmin_snapshot = 100\nactive_txids = {98, 99}\nmax_txid = 102"] end subgraph row1["Row Version A (xmin=95, xmax=101)"] R1["val='original'\nInserted by txid 95\nDeleted by txid 101"] end subgraph row2["Row Version B (xmin=101, xmax=0)"] R2["val='updated'\nInserted by txid 101\nStill live"] end subgraph visibility["Visibility Check for Snapshot T=100"] V1["Row A: xmin=95 committed before snapshot? YES\nxmax=101 committed before snapshot? NO (101 > 100)\nResult: VISIBLE"] V2["Row B: xmin=101 committed before snapshot? NO (101 > 100)\nResult: NOT VISIBLE"] end snap --> visibility row1 --> V1 row2 --> V2

4. Deadlocks: Detection, Prevention, and Patterns

A deadlock occurs when two transactions are each waiting for a lock held by the other. Neither can proceed. Left unresolved, both would wait forever.

PostgreSQL Deadlock Detection

PostgreSQL's lock manager detects deadlocks by periodically checking the wait-for graph. The detection interval is controlled by deadlock_timeout (default: 1 second). When a cycle is detected, PostgreSQL cancels one transaction (the "victim") with:

ERROR: deadlock detected
DETAIL: Process 12345 waits for ShareLock on transaction 67890; blocked by process 11111.
        Process 11111 waits for ShareLock on transaction 12345; blocked by process 12345.
HINT: See server log for query details.

The victim transaction receives a rollback. Your application must detect this error and retry.

Reproducing a Deadlock

-- Session 1
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- Locks row id=1
-- (wait before running next statement)

-- Session 2 (run while Session 1 is waiting)
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 2;  -- Locks row id=2
UPDATE accounts SET balance = balance + 100 WHERE id = 1;  -- Waits for Session 1's lock on id=1

-- Session 1 (now run)
UPDATE accounts SET balance = balance + 100 WHERE id = 2;  -- Waits for Session 2's lock on id=2
-- Deadlock! PostgreSQL cancels one of the transactions.
COMMIT;

Deadlock Prevention: Consistent Lock Ordering

The canonical prevention technique is to always acquire locks in the same global order across all transactions. If every transfer always locks the lower account ID first, no cycle can form:

import psycopg2
from contextlib import contextmanager

@contextmanager
def get_connection(dsn: str):
    conn = psycopg2.connect(dsn)
    try:
        yield conn
        conn.commit()
    except Exception:
        conn.rollback()
        raise
    finally:
        conn.close()

def transfer_funds(dsn: str, from_id: int, to_id: int, amount: float) -> None:
    """
    Transfer funds between accounts using consistent lock ordering to prevent deadlocks.
    Always acquires locks in ascending account ID order regardless of transfer direction.
    """
    # Sort IDs to ensure consistent lock ordering
    lock_first, lock_second = sorted([from_id, to_id])

    with get_connection(dsn) as conn:
        with conn.cursor() as cur:
            # Lock both rows in consistent order using SELECT FOR UPDATE
            cur.execute("""
                SELECT id, balance FROM accounts
                WHERE id IN (%s, %s)
                ORDER BY id  -- Critical: order matches our lock_first/lock_second ordering
                FOR UPDATE
            """, (lock_first, lock_second))

            rows = {row[0]: row[1] for row in cur.fetchall()}

            if rows[from_id] < amount:
                raise ValueError(f"Insufficient funds: balance {rows[from_id]}, requested {amount}")

            cur.execute("UPDATE accounts SET balance = balance - %s WHERE id = %s", (amount, from_id))
            cur.execute("UPDATE accounts SET balance = balance + %s WHERE id = %s", (amount, to_id))

SKIP LOCKED: Queue Consumer Pattern

SELECT FOR UPDATE SKIP LOCKED is a powerful pattern for building work queues on top of PostgreSQL. Each consumer claims rows that no other consumer has locked, without waiting:

-- Queue consumer: atomically claim and process one job
-- SKIP LOCKED means: if a row is locked by another consumer, skip it entirely
-- This allows multiple consumers to work the queue concurrently without contention

WITH claimed AS (
    SELECT id, payload, created_at
    FROM job_queue
    WHERE status = 'pending'
    ORDER BY created_at
    LIMIT 1
    FOR UPDATE SKIP LOCKED  -- Skip rows locked by other consumers
)
UPDATE job_queue
SET status = 'processing', claimed_at = now(), worker_id = pg_backend_pid()
FROM claimed
WHERE job_queue.id = claimed.id
RETURNING job_queue.*;
import psycopg2
import json
import time

def consume_jobs(dsn: str, worker_id: str) -> None:
    """
    Queue consumer using SKIP LOCKED for contention-free concurrent processing.
    Multiple instances can run this function simultaneously without deadlocks.
    """
    conn = psycopg2.connect(dsn)

    while True:
        with conn.cursor() as cur:
            # Claim exactly one job atomically
            cur.execute("""
                WITH claimed AS (
                    SELECT id, payload
                    FROM job_queue
                    WHERE status = 'pending'
                    ORDER BY created_at
                    LIMIT 1
                    FOR UPDATE SKIP LOCKED
                )
                UPDATE job_queue
                SET status = 'processing',
                    claimed_at = now(),
                    worker_id = %s
                FROM claimed
                WHERE job_queue.id = claimed.id
                RETURNING job_queue.id, job_queue.payload
            """, (worker_id,))

            job = cur.fetchone()
            conn.commit()

            if job is None:
                time.sleep(0.1)  # No work available, backoff
                continue

            job_id, payload = job
            try:
                process_job(json.loads(payload))
                # Mark as complete
                cur.execute("UPDATE job_queue SET status = 'done' WHERE id = %s", (job_id,))
                conn.commit()
            except Exception as e:
                # Mark as failed, allow retry
                cur.execute("""
                    UPDATE job_queue
                    SET status = 'failed', error = %s
                    WHERE id = %s
                """, (str(e), job_id))
                conn.commit()

def process_job(payload: dict) -> None:
    # Application-specific job processing
    pass

NOWAIT: Fail Fast on Lock Contention

Use NOWAIT when you want immediate failure rather than waiting:

-- Fail immediately if the row is locked rather than waiting
SELECT * FROM accounts WHERE id = 1 FOR UPDATE NOWAIT;
-- If locked: ERROR: could not obtain lock on row in relation "accounts"

This is useful in user-facing APIs where waiting for a lock would cause unacceptable latency. Handle the lock error at the application layer and return an appropriate response.


5. Optimistic vs Pessimistic Concurrency Control

Choosing between optimistic and pessimistic locking is one of the most consequential architectural decisions in concurrent data access design.

Pessimistic Concurrency Control

Pessimistic control assumes conflict is likely. You acquire a lock before reading, hold it through processing, and release at commit. SELECT FOR UPDATE is the standard mechanism:

-- Pessimistic: lock the row before reading it
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- Other transactions trying to UPDATE this row will wait here
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
-- Lock released at COMMIT

Pessimistic locking is correct for high-contention scenarios — financial operations, inventory deductions, anything where conflict is the common case. Its downside is serializing access: only one transaction can work on a row at a time.

Optimistic Concurrency Control

Optimistic control assumes conflict is rare. Read without locking. When you are ready to commit, verify that the data you read has not changed. If it has, abort and retry.

The standard implementation uses a version column:

-- Schema with version column
CREATE TABLE products (
    id BIGSERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    stock INT NOT NULL,
    version INT NOT NULL DEFAULT 1
);
import psycopg2
import time
from typing import Optional

def deduct_stock_optimistic(
    dsn: str,
    product_id: int,
    quantity: int,
    max_retries: int = 5
) -> bool:
    """
    Optimistic locking with version column.
    Detects concurrent modification by checking that version hasn't changed.
    Returns True on success, raises after max_retries exhausted.
    """
    conn = psycopg2.connect(dsn)
    conn.autocommit = False

    for attempt in range(max_retries):
        try:
            with conn.cursor() as cur:
                # Step 1: Read current state without locking
                cur.execute(
                    "SELECT stock, version FROM products WHERE id = %s",
                    (product_id,)
                )
                row = cur.fetchone()
                if row is None:
                    raise ValueError(f"Product {product_id} not found")

                current_stock, current_version = row

                if current_stock < quantity:
                    raise ValueError(f"Insufficient stock: have {current_stock}, need {quantity}")

                # Step 2: Update, but only if version still matches what we read
                # If another transaction modified the row, version will have changed
                # and this UPDATE will affect 0 rows — our signal to retry
                cur.execute("""
                    UPDATE products
                    SET stock = stock - %s,
                        version = version + 1
                    WHERE id = %s
                      AND version = %s  -- Optimistic lock check
                """, (quantity, product_id, current_version))

                rows_affected = cur.rowcount

                if rows_affected == 0:
                    # Version mismatch: another transaction modified the row
                    # Rollback and retry
                    conn.rollback()
                    backoff = 0.01 * (2 ** attempt)  # Exponential backoff
                    time.sleep(backoff)
                    continue

                conn.commit()
                return True

        except psycopg2.Error:
            conn.rollback()
            raise

    raise RuntimeError(f"Failed to deduct stock after {max_retries} attempts")

When to Use Each

Scenario Recommendation Reason
Financial transfers Pessimistic (SELECT FOR UPDATE) Conflict is common; retry cost is high
Inventory deduction (flash sale) Pessimistic with SKIP LOCKED High contention during peak load
Profile updates Optimistic (version column) Low contention; clean UX without lock waits
Configuration changes Optimistic Rare writes, easy to retry
Long-running transactions Optimistic Holding locks for seconds causes cascading waits
Batch data processing Pessimistic with NOWAIT Fail-fast is preferable to queuing behind slow batches

Optimistic locking achieves higher throughput when contention is low because no lock traffic touches the lock manager. Under high contention, it degrades badly — every transaction retries constantly, and the effective throughput drops below pessimistic locking.


6. Distributed Transactions

Distributed transactions are qualitatively harder than single-database transactions. When your operation spans two databases — or a database and a message queue — you lose the atomicity guarantee that a single BEGIN ... COMMIT provides.

Why You Cannot Just BEGIN in Two Databases

Consider an order placement that must:
1. Deduct inventory from a PostgreSQL database.
2. Publish an order_placed event to a Kafka topic.

There is no single COMMIT that covers both. If you write to PostgreSQL and then Kafka fails, your inventory is decremented but no order event was published. If Kafka succeeds and PostgreSQL crashes, the event exists but the inventory is not decremented.

Two-Phase Commit (2PC)

2PC is the classic solution: a coordinator sends PREPARE to all participants, waits for all to acknowledge readiness, then sends COMMIT or ROLLBACK. PostgreSQL supports 2PC natively via PREPARE TRANSACTION.

-- Coordinator: Phase 1 (Prepare)
BEGIN;
UPDATE inventory SET stock = stock - 1 WHERE product_id = 42;
PREPARE TRANSACTION 'order_12345';  -- Row is locked until COMMIT PREPARED or ROLLBACK PREPARED

-- Coordinator: Phase 2 (Commit, after all participants prepared)
COMMIT PREPARED 'order_12345';

-- Or rollback if any participant failed
ROLLBACK PREPARED 'order_12345';

2PC has a critical failure mode: if the coordinator crashes after sending PREPARE but before sending COMMIT, participants remain in a prepared state holding locks — indefinitely, until an operator resolves the situation. For this reason, 2PC is often avoided in favor of the Saga pattern.

The Outbox Pattern: Atomic Database + Message Queue

The outbox pattern solves the database/queue atomicity problem without 2PC. The key insight: write the event into an outbox table in the same transaction as your business data change. A separate relay process reads the outbox and publishes to the queue. If the relay crashes, it replays from the outbox. If the database transaction rolls back, the outbox row is never written.

-- Outbox table schema
CREATE TABLE outbox (
    id BIGSERIAL PRIMARY KEY,
    aggregate_type TEXT NOT NULL,      -- e.g., 'order'
    aggregate_id TEXT NOT NULL,        -- e.g., order UUID
    event_type TEXT NOT NULL,          -- e.g., 'order_placed'
    payload JSONB NOT NULL,
    created_at TIMESTAMPTZ DEFAULT now(),
    published_at TIMESTAMPTZ,          -- NULL = not yet published
    sequence_no BIGSERIAL              -- For ordered delivery
);
import psycopg2
import json
from datetime import datetime

def place_order(dsn: str, user_id: int, product_id: int, quantity: int) -> str:
    """
    Place an order using the outbox pattern.
    The order record and the outbox event are written in the same transaction.
    If this transaction commits, the event will eventually be published.
    If it rolls back, neither the order nor the event persists.
    """
    import uuid
    order_id = str(uuid.uuid4())

    conn = psycopg2.connect(dsn)
    conn.autocommit = False

    try:
        with conn.cursor() as cur:
            # Business operation 1: deduct inventory
            cur.execute("""
                UPDATE inventory
                SET stock = stock - %s
                WHERE product_id = %s AND stock >= %s
            """, (quantity, product_id, quantity))

            if cur.rowcount == 0:
                raise ValueError("Insufficient inventory")

            # Business operation 2: create order record
            cur.execute("""
                INSERT INTO orders (id, user_id, product_id, quantity, status, created_at)
                VALUES (%s, %s, %s, %s, 'pending', now())
            """, (order_id, user_id, product_id, quantity))

            # Outbox entry: written in SAME transaction as business data
            # This is the atomicity guarantee — both commit or neither does
            event_payload = {
                "order_id": order_id,
                "user_id": user_id,
                "product_id": product_id,
                "quantity": quantity,
                "timestamp": datetime.utcnow().isoformat()
            }
            cur.execute("""
                INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)
                VALUES ('order', %s, 'order_placed', %s)
            """, (order_id, json.dumps(event_payload)))

        conn.commit()
        return order_id

    except Exception:
        conn.rollback()
        raise
    finally:
        conn.close()


def relay_outbox_to_kafka(dsn: str, kafka_producer) -> int:
    """
    Relay worker: reads unpublished outbox events and publishes them to Kafka.
    Uses SKIP LOCKED so multiple relay workers can run without contention.
    Idempotency: records published_at to prevent double-publishing.
    Returns count of events published.
    """
    conn = psycopg2.connect(dsn)
    conn.autocommit = False
    published_count = 0

    try:
        with conn.cursor() as cur:
            # Claim a batch of unpublished events
            cur.execute("""
                SELECT id, aggregate_type, aggregate_id, event_type, payload
                FROM outbox
                WHERE published_at IS NULL
                ORDER BY sequence_no
                LIMIT 100
                FOR UPDATE SKIP LOCKED
            """)
            events = cur.fetchall()

            for event_id, agg_type, agg_id, event_type, payload in events:
                # Publish to Kafka (outside the transaction)
                kafka_producer.produce(
                    topic=f"{agg_type}.events",
                    key=agg_id,
                    value=json.dumps({
                        "event_type": event_type,
                        "payload": payload
                    })
                )
                kafka_producer.flush()

                # Mark as published — only after Kafka confirms receipt
                cur.execute("""
                    UPDATE outbox
                    SET published_at = now()
                    WHERE id = %s
                """, (event_id,))

                published_count += 1

        conn.commit()
        return published_count

    except Exception:
        conn.rollback()
        raise
    finally:
        conn.close()

The outbox pattern delivers at-least-once semantics: if the relay crashes after publishing to Kafka but before marking published_at, it will republish on restart. Consumers must handle duplicate events (typically via idempotency keys).

Saga Pattern for Long-Running Operations

When an operation spans multiple services and cannot be wrapped in a single transaction, the Saga pattern models it as a sequence of local transactions, each with a compensating transaction that undoes its effect if a later step fails.

# Saga: place order across three services
# Each step has a compensating action

def order_saga(order_request: dict) -> None:
    steps_completed = []

    try:
        # Step 1: Reserve inventory
        inventory_reservation = reserve_inventory(order_request["product_id"], order_request["quantity"])
        steps_completed.append(("inventory", inventory_reservation["reservation_id"]))

        # Step 2: Charge payment
        payment = charge_payment(order_request["user_id"], order_request["total"])
        steps_completed.append(("payment", payment["charge_id"]))

        # Step 3: Create shipment
        shipment = create_shipment(order_request)
        steps_completed.append(("shipment", shipment["shipment_id"]))

    except Exception as e:
        # Compensate in reverse order
        for service, resource_id in reversed(steps_completed):
            if service == "inventory":
                release_inventory_reservation(resource_id)
            elif service == "payment":
                refund_payment(resource_id)
            elif service == "shipment":
                cancel_shipment(resource_id)
        raise

Sagas are eventually consistent — there is a window during which the saga is partially applied. Design your system so that partially-applied states are either invisible to users or explicitly surfaced (e.g., "order pending" rather than immediate confirmation).


Conclusion

Database transactions are not a checkbox feature — they are a precise contract between your application and the database about what anomalies are and are not possible. The default settings (Read Committed isolation, autocommit off) are sensible for most applications, but "most" is not "all."

The practical takeaways:

Choose isolation levels deliberately. Read Committed is correct for simple read-and-write operations, but any "read the current state, then decide" pattern needs at minimum Repeatable Read or Serializable. Add BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE to financial operations and be prepared to retry on serialization failure.

Understand what MVCC costs. Dead tuple accumulation is not a minor housekeeping concern — it affects query performance (table bloat), index bloat, and eventually transaction ID wraparound. Monitor n_dead_tup in pg_stat_user_tables. Ensure autovacuum is not chronically blocked by long-running transactions.

Practice deadlock prevention as discipline, not reaction. Consistent lock ordering is a team convention that should be enforced in code review. A single transaction that acquires locks in a different order from every other transaction is a latent deadlock waiting for the right concurrent load. The SKIP LOCKED pattern for queue consumers eliminates an entire class of contention by design.

Avoid distributed transactions where possible. The outbox pattern covers the most common cross-system consistency requirement (database + message queue) without 2PC's coordinator failure modes. For multi-service operations, sagas with compensating transactions are the pragmatic choice — they trade strict consistency for resilience and explicit failure handling.

Transaction correctness is not an advanced concern. It is a foundational property of any system that handles money, inventory, or any resource with finite supply. The cost of getting it right is a few hours of careful design. The cost of getting it wrong is production data corruption that may take days to discover and weeks to fully understand.


Sources

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-06-17 · Updated: 2026-04-18 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

PostgreSQL Advanced: Partitioning, Replication, WAL, and High-Availability Patterns

Hero image

Introduction

PostgreSQL handles a billion-row table differently than it handles a million-row table. The indexes that work at 10,000 rows per second write throughput fail under 100,000. The simple primary/replica setup that's fine for read-heavy workloads becomes a liability when you need zero-downtime failover during a primary crash. The VACUUM settings that keep an idle development database healthy leave a high-write production database bloated.

This post covers PostgreSQL at the scale where the defaults stop working: declarative table partitioning for billion-row tables, streaming replication architecture and failover with Patroni, Write-Ahead Log (WAL) configuration for performance and durability, and the VACUUM and autovacuum tuning that prevents table bloat from killing query performance. These are the PostgreSQL topics that most engineers learn reactively — after their first production incident.

Declarative Table Partitioning

Declarative partitioning (PostgreSQL 10+) splits a logical table into physical child tables called partitions. The query planner routes queries to only the relevant partitions — a process called partition pruning — dramatically reducing I/O for time-series and tenant-scoped data.

Range Partitioning (Time-Series Data)

-- Create parent table (no data stored here)
CREATE TABLE events (
    id          BIGSERIAL,
    tenant_id   UUID NOT NULL,
    event_type  TEXT NOT NULL,
    payload     JSONB,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (created_at);

-- Create partitions by month
CREATE TABLE events_2026_01 
    PARTITION OF events
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');

CREATE TABLE events_2026_02 
    PARTITION OF events
    FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');

CREATE TABLE events_2026_03 
    PARTITION OF events
    FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');

-- Indexes on partitions (each partition gets its own index)
CREATE INDEX idx_events_2026_01_tenant ON events_2026_01 (tenant_id, created_at DESC);
CREATE INDEX idx_events_2026_02_tenant ON events_2026_02 (tenant_id, created_at DESC);

-- Insert routes to the correct partition automatically
INSERT INTO events (tenant_id, event_type, payload, created_at)
VALUES ('abc-123', 'page_view', '{"url": "/home"}', '2026-01-15');

-- Query uses partition pruning — only scans events_2026_01
EXPLAIN SELECT * FROM events 
WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01'
AND tenant_id = 'abc-123';
-- "Append ... Seq Scan on events_2026_01 ..."  -- only January partition scanned

Old partitions can be detached (without data loss) and dropped when data exceeds retention:

-- Detach old partition: fast, no data movement
ALTER TABLE events DETACH PARTITION events_2026_01;
-- events_2026_01 is now a standalone table

-- Drop when ready
DROP TABLE events_2026_01;
-- O(1) operation — deletes files, no row-by-row DELETE

-- Automate with pg_partman extension
-- Creates and drops partitions on a schedule
SELECT partman.create_parent(
    p_parent_table => 'public.events',
    p_control => 'created_at',
    p_type => 'range',
    p_interval => 'monthly',
    p_retention => '6 months',     -- auto-drop partitions older than 6 months
    p_retention_keep_table => false
);

List Partitioning (Multi-Tenant Data)

-- Partition by tenant for tenant-isolated queries
CREATE TABLE orders (
    id          BIGSERIAL,
    tenant_id   TEXT NOT NULL,
    user_id     UUID NOT NULL,
    total_cents INTEGER NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
) PARTITION BY LIST (tenant_id);

CREATE TABLE orders_tenant_acme    PARTITION OF orders FOR VALUES IN ('acme');
CREATE TABLE orders_tenant_globex  PARTITION OF orders FOR VALUES IN ('globex');
CREATE TABLE orders_default        PARTITION OF orders DEFAULT;  -- catch-all

-- All queries for tenant 'acme' scan only orders_tenant_acme
SELECT * FROM orders WHERE tenant_id = 'acme' AND created_at > NOW() - INTERVAL '30 days';

Partition pruning reduces query scope. For a 2-year events table with monthly partitions (24 partitions), a query for one month scans 1/24th of the data. Without partitioning, the query scans the full table.

Architecture diagram

pg_stat_statements: Finding the Queries That Matter

Before tuning partitions, replication, or VACUUM, identify the queries consuming the most time. pg_stat_statements tracks cumulative statistics for every normalized query pattern — the single most valuable diagnostic view in PostgreSQL.

-- Enable (requires server restart after adding to shared_preload_libraries)
-- postgresql.conf: shared_preload_libraries = 'pg_stat_statements'

CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- Top 10 queries by total time
SELECT 
    left(query, 100) AS query_snippet,
    calls,
    round(total_exec_time::numeric, 2) AS total_ms,
    round(mean_exec_time::numeric, 2)  AS avg_ms,
    round((100 * total_exec_time / sum(total_exec_time) OVER ())::numeric, 2) AS pct_total,
    rows
FROM pg_stat_statements
WHERE query NOT ILIKE '%pg_stat_statements%'
ORDER BY total_exec_time DESC
LIMIT 10;

-- Queries with high variability (stddev >> mean = sporadic slowness)
SELECT 
    left(query, 100) AS query_snippet,
    calls,
    round(mean_exec_time::numeric, 2) AS avg_ms,
    round(stddev_exec_time::numeric, 2) AS stddev_ms,
    round((stddev_exec_time / nullif(mean_exec_time, 0))::numeric, 2) AS cv  -- coefficient of variation
FROM pg_stat_statements
WHERE calls > 100
ORDER BY stddev_exec_time / nullif(mean_exec_time, 0) DESC
LIMIT 10;

-- Reset statistics (do periodically; old data clouds current analysis)
SELECT pg_stat_statements_reset();

The workflow: identify the top 10 queries by total time → EXPLAIN ANALYZE each → add missing indexes or rewrite inefficient queries → verify improvement. Repeat weekly. In most production databases, 20% of queries account for 80% of query time.

Write-Ahead Log: Performance and Durability Configuration

The Write-Ahead Log (WAL) is the foundation of PostgreSQL's durability. Every change is written to the WAL before being applied to data pages. On crash, PostgreSQL replays WAL from the last checkpoint — recovering to a consistent state.

WAL configuration controls the durability-performance trade-off:

-- postgresql.conf: WAL settings
-- Full sync to disk on every transaction commit (safest, slowest)
synchronous_commit = on          -- default; safest

-- Async WAL: allow OS to delay sync (10-100x faster writes, <1s data loss window)
synchronous_commit = off         -- lose last ~1s of committed transactions on crash
                                 -- NOT appropriate for financial data

-- Checkpoint settings: how often WAL is flushed to data pages
checkpoint_completion_target = 0.9   -- spread checkpoint over 90% of interval
max_wal_size = 4GB              -- allow more WAL before forcing checkpoint
min_wal_size = 1GB

-- WAL level: required for logical replication
wal_level = logical             -- enables logical decoding (for CDC, replication)
                                -- 'replica' for physical replication only

-- WAL compression (PostgreSQL 15+): reduces WAL volume, saves disk/network
wal_compression = lz4

WAL size monitoring:

-- Current WAL write location
SELECT pg_current_wal_lsn(), pg_walfile_name(pg_current_wal_lsn());

-- WAL generation rate (bytes/second over last minute)
SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), '0/0') / extract(epoch from now())
AS wal_bytes_per_second;

-- Checkpoint frequency (should not checkpoint more than once every few minutes)
SELECT checkpoints_req, checkpoints_timed, 
       buffers_checkpoint, buffers_clean, buffers_backend,
       round(100.0 * checkpoints_req / (checkpoints_req + checkpoints_timed), 1) 
           AS pct_forced_checkpoints
FROM pg_stat_bgwriter;
-- High pct_forced_checkpoints: increase max_wal_size

A high rate of forced checkpoints (triggered by WAL reaching max_wal_size) indicates that max_wal_size is too small for the write rate. Forced checkpoints cause I/O spikes. Increase max_wal_size to allow PostgreSQL to spread checkpoint work over time.

Streaming Replication Architecture

PostgreSQL streaming replication sends WAL records from the primary to standbys in real time. Standbys apply WAL records and stay within seconds of the primary. Reads can be served from standbys (with hot_standby = on).

-- On primary: postgresql.conf
wal_level = replica
max_wal_senders = 10           -- max concurrent replication connections
wal_keep_size = 1GB            -- keep enough WAL for standbys to catch up after lag

-- pg_hba.conf: allow replication connections
-- TYPE  DATABASE    USER        ADDRESS         METHOD
host    replication replication standby-host/32 scram-sha-256

-- Create replication user
CREATE USER replication REPLICATION LOGIN PASSWORD 'strong-password';

-- On standby: recovery.conf (PostgreSQL 12+: primary_conninfo in postgresql.conf)
primary_conninfo = 'host=primary-host user=replication password=strong-password'
hot_standby = on               -- allow reads from standby
recovery_target_timeline = latest

Replication lag monitoring:

-- On primary: view connected standbys and their lag
SELECT client_addr, 
       state,
       sent_lsn,
       write_lsn,
       flush_lsn,
       replay_lsn,
       pg_wal_lsn_diff(sent_lsn, replay_lsn) AS replication_lag_bytes,
       write_lag,
       flush_lag,
       replay_lag        -- wall clock time behind primary
FROM pg_stat_replication;

-- On standby: check lag from standby's perspective
SELECT now() - pg_last_xact_replay_timestamp() AS replication_lag_seconds;

Alert if replay_lag > 30s — standbys this far behind risk serving stale data and may not promote quickly in a failover.

Automated Failover with Patroni

Manual failover (promoting a standby with pg_ctl promote) is too slow and error-prone for production SLAs. Patroni is the standard automated HA solution for PostgreSQL in 2026.

Patroni uses a distributed consensus store (etcd, ZooKeeper, or Consul) to elect a primary and coordinate failover. When the primary becomes unavailable, Patroni automatically promotes the most up-to-date standby:

# patroni.yml
scope: postgres-prod
name: pg-node-1

restapi:
  listen: 0.0.0.0:8008
  connect_address: 10.0.1.1:8008

etcd3:
  hosts: etcd-1:2379,etcd-2:2379,etcd-3:2379

bootstrap:
  dcs:
    ttl: 30
    loop_wait: 10
    retry_timeout: 10
    maximum_lag_on_failover: 1048576   # 1MB: don't promote standby >1MB behind
    postgresql:
      use_pg_rewind: true              # fast resync of old primary as new standby
      parameters:
        max_connections: 200
        shared_buffers: 8GB
        wal_level: replica
        max_wal_senders: 10

postgresql:
  listen: 0.0.0.0:5432
  connect_address: 10.0.1.1:5432
  data_dir: /var/lib/postgresql/data
  authentication:
    replication:
      username: replication
      password: strong-password
    superuser:
      username: postgres
      password: strong-password

tags:
  nofailover: false
  noloadbalance: false

Patroni failover timeline:
1. Primary becomes unresponsive (crash or network partition)
2. Patroni's TTL expires (30 seconds in config above)
3. Patroni elects the standby with the most recent WAL position
4. Standby is promoted: pg_ctl promote
5. pg_rewind resynchronizes the old primary as a new standby
6. Total failover time: typically 30-60 seconds

HAProxy for connection routing: applications connect to HAProxy, which routes reads and writes to the correct node based on Patroni health checks:

# haproxy.cfg
frontend postgres_write
  bind *:5432
  default_backend postgres_primary

frontend postgres_read
  bind *:5433
  default_backend postgres_standbys

backend postgres_primary
  option httpchk GET /master   # Patroni returns 200 on primary, 503 on standby
  server pg-1 10.0.1.1:5432 check port 8008
  server pg-2 10.0.1.2:5432 check port 8008 backup
  server pg-3 10.0.1.3:5432 check port 8008 backup

backend postgres_standbys
  balance roundrobin
  option httpchk GET /replica  # Patroni returns 200 on standbys
  server pg-2 10.0.1.2:5432 check port 8008
  server pg-3 10.0.1.3:5432 check port 8008
Comparison visual

VACUUM and Autovacuum Tuning

PostgreSQL's MVCC (Multi-Version Concurrency Control) never updates rows in place. An UPDATE writes a new version of the row. The old version remains until VACUUM reclaims it. Without regular VACUUM, dead rows accumulate — bloating tables and indexes, degrading query performance, and eventually triggering transaction ID wraparound (a hard crash if not addressed).

Autovacuum monitoring:

-- Tables with high dead tuple counts (need vacuuming)
SELECT schemaname, tablename,
       n_live_tup, 
       n_dead_tup,
       round(100.0 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0), 1) AS dead_pct,
       last_vacuum, 
       last_autovacuum,
       last_analyze,
       last_autoanalyze
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY dead_pct DESC;

-- Autovacuum currently running
SELECT pid, relid::regclass as table, phase, 
       heap_blks_scanned, heap_blks_total,
       round(100.0 * heap_blks_scanned / nullif(heap_blks_total, 0), 1) AS pct_done
FROM pg_stat_progress_vacuum;

Autovacuum tuning for high-write tables:

-- Default autovacuum: triggers at 20% dead tuples, too slow for large tables
-- A 100M-row table needs 20M dead tuples before autovacuum triggers!

-- Table-specific autovacuum settings: trigger more aggressively
ALTER TABLE events SET (
    autovacuum_vacuum_scale_factor = 0.01,   -- trigger at 1% dead rows
    autovacuum_vacuum_threshold = 1000,       -- or 1000 dead rows, whichever is smaller
    autovacuum_vacuum_cost_delay = 2,         -- 2ms between vacuum I/O bursts (default: 20ms)
    autovacuum_vacuum_cost_limit = 400,       -- more I/O budget for vacuum
    autovacuum_analyze_scale_factor = 0.01,  -- analyze at 1% too
    toast.autovacuum_vacuum_scale_factor = 0.01
);

-- Emergency manual VACUUM ANALYZE (does not block reads/writes)
VACUUM ANALYZE events;

-- VACUUM FULL: rewrites the table entirely (LOCKS the table, reclaims space)
-- Only for extreme bloat recovery — blocks all access
VACUUM FULL events;  -- use only during maintenance window

Transaction ID wraparound prevention:

-- Check tables at risk of transaction ID wraparound
SELECT oid::regclass as table,
       age(relfrozenxid) as xid_age,
       2000000000 - age(relfrozenxid) as xids_remaining  -- warn at 1B remaining
FROM pg_class
WHERE relkind = 'r'
ORDER BY age(relfrozenxid) DESC
LIMIT 20;
-- If age > 1,500,000,000: VACUUM FREEZE immediately

Transaction ID wraparound is a hard deadline: if a table reaches 2 billion transactions old without a VACUUM FREEZE, PostgreSQL enters read-only "wraparound protection" mode. It will not accept new transactions until the table is vacuumed. This is a high-severity production incident. Monitor XID age; alert at 1.5 billion.

Connection Pooling: PgBouncer at Scale

PostgreSQL creates a new OS process per connection. Each process uses 5-10MB of memory. At 500 connections, that's 2.5-5GB of RAM consumed just by connection overhead, before any query work. PgBouncer is the standard solution: a lightweight connection pooler that maintains a pool of persistent connections to PostgreSQL and maps many client connections onto fewer server connections.

# pgbouncer.ini
[databases]
mydb = host=postgres-primary port=5432 dbname=mydb
mydb_read = host=postgres-standby port=5432 dbname=mydb

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432

# Transaction pooling: most efficient, but breaks session-level features
# (SET LOCAL, LISTEN/NOTIFY, advisory locks, prepared statements)
pool_mode = transaction

max_client_conn = 1000       # clients → PgBouncer: allow many
default_pool_size = 20       # PgBouncer → PostgreSQL: keep small
max_db_connections = 100     # total connections to PostgreSQL

auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt

# Performance
server_lifetime = 3600       # recycle connections after 1 hour
server_idle_timeout = 600    # close idle server connections after 10min
client_idle_timeout = 0      # don't close idle clients

PgBouncer's three pooling modes:
- Session pooling: one server connection per client session. Minimal compatibility issues. Reduces max connections to pool size.
- Transaction pooling: server connection released back to pool after each transaction. Most efficient. Breaks SET LOCAL, LISTEN/NOTIFY, session-level prepared statements.
- Statement pooling: server connection released after each statement. Only for auto-commit workloads.

Transaction pooling is the production default. If your application uses session-level features that break under transaction pooling, use Pgpool-II (supports more PostgreSQL features) or ensure those features are limited to dedicated connections outside PgBouncer.

-- PgBouncer statistics (connect to pgbouncer admin database)
SHOW POOLS;
-- database | user | cl_active | cl_waiting | sv_active | sv_idle | sv_used | maxwait

SHOW STATS;
-- Shows requests/second, latency percentiles per database

-- Alert if: cl_waiting > 0 sustained (pool exhausted, clients queuing)
-- Alert if: maxwait > 100ms (queries waiting for connection > 100ms)

When cl_waiting is consistently above zero, the pool is exhausted. Options: increase default_pool_size (puts more load on PostgreSQL), add read replicas and route read traffic to them, or optimize queries to reduce transaction duration.

PostgreSQL Performance Tuning: Memory Settings

The two most impactful memory settings:

-- shared_buffers: PostgreSQL's shared memory cache (data pages)
-- Rule: 25% of total RAM, up to 8GB (OS page cache handles the rest)
-- 32GB RAM → shared_buffers = 8GB
shared_buffers = 8GB

-- work_mem: per sort/hash join operation memory
-- CAUTION: applies per-operation, not per-connection
-- 100 connections × 10 concurrent sorts × 256MB = 256GB RAM usage
-- Rule: start at 16MB, increase if explain shows disk sorts
work_mem = 16MB   -- increase per-query with: SET work_mem = '256MB'

-- effective_cache_size: hint to planner about total available RAM (shared_buffers + OS cache)
-- 75% of total RAM
effective_cache_size = 24GB  -- for a 32GB server

-- random_page_cost: cost estimate for random disk reads
-- SSD: 1.1 (nearly as fast as sequential); HDD: 4.0 (default)
random_page_cost = 1.1   -- for SSD-backed storage

-- parallel query workers
max_parallel_workers_per_gather = 4     -- parallel query workers per query
max_parallel_workers = 8               -- total parallel workers
max_worker_processes = 16              -- total background workers

The planner uses random_page_cost and effective_cache_size to decide between sequential scans and index scans. With the default random_page_cost = 4 on an SSD, the planner over-prefers sequential scans. Setting random_page_cost = 1.1 corrects this bias and dramatically improves index utilization.

Index bloat and REINDEX CONCURRENTLY:

-- Check index bloat
SELECT schemaname, tablename, indexname,
       pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
       idx_scan AS times_used,
       idx_tup_read, idx_tup_fetch
FROM pg_stat_user_indexes
JOIN pg_index USING (indexrelid)
WHERE idx_scan < 100  -- indexes rarely used (candidates for removal)
ORDER BY pg_relation_size(indexrelid) DESC;

-- Rebuild bloated index without locking (long-running operation)
REINDEX INDEX CONCURRENTLY idx_events_tenant_2026_01;
-- CONCURRENTLY: doesn't lock reads/writes, but takes longer

-- Create replacement index, then swap (zero downtime)
CREATE INDEX CONCURRENTLY idx_events_new ON events(tenant_id, created_at DESC)
    WHERE created_at > NOW() - INTERVAL '90 days';  -- partial index
-- Verify it's used, then DROP INDEX CONCURRENTLY the old one

Logical Replication and CDC

Logical replication decodes WAL changes into a logical row-change stream (INSERT/UPDATE/DELETE). Unlike physical streaming replication (which copies raw bytes), logical replication can replicate to different PostgreSQL versions, different schemas, or external systems.

Use cases:
- Zero-downtime major version upgrades (replicate to new version, cut over)
- CDC to Kafka or Debezium for event-driven architectures
- Real-time data warehouse loading
- Selective table replication

-- Publisher (primary): enable logical replication
wal_level = logical  -- required in postgresql.conf

-- Create a publication (which tables to replicate)
CREATE PUBLICATION orders_pub
FOR TABLE orders, order_items, payments;

-- Subscriber (replica):
CREATE SUBSCRIPTION orders_sub
CONNECTION 'host=primary-host dbname=mydb user=replication password=...'
PUBLICATION orders_pub;

-- Monitor replication slot lag (CRITICAL: don't let this grow large)
SELECT slot_name, 
       pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn) AS lag_bytes,
       active
FROM pg_replication_slots;
-- Alert if lag_bytes > 1GB: slot is preventing WAL cleanup → disk fills

Replication slot danger: slots prevent WAL files from being deleted until the subscriber has consumed them. A disconnected subscriber means WAL accumulates indefinitely — filling your disk and crashing PostgreSQL. Always monitor replication slot lag; drop slots that are no longer in use.

Conclusion

PostgreSQL's advanced capabilities — partitioning, streaming replication, Patroni failover, WAL tuning, logical decoding — are production necessities at scale, not optional enhancements. The engineers who understand these features prevent the incidents that others debug reactively.

The operational priorities: partition large tables before they become large (adding partitioning to an existing table requires a data migration, not a DDL change). Implement Patroni before you need it (configuring HA during an outage is too late). Monitor replication lag and XID age as tier-1 alerts — these have hard operational deadlines. Tune autovacuum for high-write tables proactively — waiting until bloat is visible means you're already paying the performance cost.

PostgreSQL continues to be the most capable open-source relational database in 2026. Understanding its internals — WAL, MVCC, VACUUM, the replication architecture — is what separates teams that run PostgreSQL reliably at scale from teams that migrate to managed databases hoping the problems go away. The managed database route (RDS, Cloud SQL, AlloyDB) abstracts the operational complexity at a cost premium; understanding the internals makes the abstractions navigable regardless of deployment model.

Sources

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-04-17 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Wednesday, April 15, 2026

Database Query Optimization: EXPLAIN ANALYZE, Indexes, and the Queries That Kill Production

Hero: EXPLAIN ANALYZE output with cost breakdown and slow query highlighted

A database query that runs in 3ms with 1,000 rows will often take 4,000ms with 1,000,000 rows. The query didn't change. The data changed. Most production database incidents aren't caused by code bugs — they're caused by queries that worked fine in staging (with 5,000 rows) and collapsed in production (with 50,000,000 rows).

This guide covers the tools and patterns for diagnosing and fixing slow queries in PostgreSQL and MySQL: reading query plans, choosing the right indexes, eliminating N+1 queries, and avoiding the schema patterns that guarantee future pain.

The Problem: Queries That Look Fine But Aren't

Consider this query:

SELECT u.name, u.email, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.created_at > '2025-01-01'
GROUP BY u.id, u.name, u.email
ORDER BY order_count DESC
LIMIT 20;

In development with 500 users and 2,000 orders: 4ms. In production with 2M users and 80M orders: 45 seconds. A sequential scan of 80M rows, a hash join, and a sort of 2M groups — all because there's no index on orders.user_id and users.created_at.

The tools to find and fix this exist in every database. Most developers don't know how to read them.

How It Works: Reading EXPLAIN ANALYZE

EXPLAIN ANALYZE is the most important debugging tool for slow queries. It shows the query plan the database chose and the actual execution statistics.

EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT u.name, u.email, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.created_at > '2025-01-01'
GROUP BY u.id, u.name, u.email
ORDER BY order_count DESC
LIMIT 20;

Sample output (annotated):

Limit  (cost=248542.33..248542.38 rows=20 width=48) (actual time=44812.221..44812.226 rows=20 loops=1)
  ->  Sort  (cost=248542.33..253447.89 rows=1962224 width=48) (actual time=44812.219..44812.221 rows=20 loops=1)
        Sort Key: (count(o.id)) DESC
        Sort Method: top-N heapsort  Memory: 26kB
        ->  HashAggregate  (cost=185899.07..205521.31 rows=1962224 width=48) (actual time=38211.442..43187.553 rows=1962224 loops=1)
              Group Key: u.id
              Batches: 5  Memory Usage: 4145kB  Disk Usage: 36608kB  ← SPILLING TO DISK
              ->  Hash Left Join  (cost=44729.61..163355.95 rows=4508624 width=24) (actual time=1203.442..28847.221 rows=80124532 loops=1)
                    Hash Cond: (o.user_id = u.id)
                    ->  Seq Scan on orders o  (cost=0.00..82441.32 rows=4508624 width=8) (actual time=0.021..8442.112 rows=80124532 loops=1)
                                                                                                                          ↑ SEQUENTIAL SCAN — 80M rows
                    ->  Hash  (cost=29972.87..29972.87 rows=1182939 width=24) (actual time=1190.221..1190.221 rows=1962224 loops=1)
                          Buckets: 2097152  Batches: 2  Memory Usage: 50421kB
                          ->  Seq Scan on users u  (cost=0.00..29972.87 rows=1182939 width=24) (actual time=0.012..892.442 rows=1962224 loops=1)
                                Filter: (created_at > '2025-01-01 00:00:00'::timestamp)
                                Rows Removed by Filter: 37776                     ← Only removes 37K rows from 2M scan
Planning Time: 2.442 ms
Execution Time: 44814.221 ms  ← 44 seconds

The key numbers to read:

What to look for Where it is What it means
Seq Scan on large table Node type Missing index — table scanned row by row
High actual time vs cost Each node Planner estimate was wrong
Disk Usage in aggregation HashAggregate Query spilling to disk — work_mem too low
Rows Removed by Filter Seq Scan Filter applied after reading all rows
loops=N where N > 1 Nested Loop Inner relation scanned N times
flowchart TD A[EXPLAIN ANALYZE output] --> B{Seq Scan?} B -- Yes on large table --> C[Add index on scan columns] B -- No --> D{Nested Loop with loops > 100?} D -- Yes --> E[N+1 query or missing join index] D -- No --> F{Disk Usage in aggregation?} F -- Yes --> G[Increase work_mem or optimize grouping] F -- No --> H{High actual vs estimated rows?} H -- Yes --> I[Run ANALYZE to update statistics] H -- No --> J[Query is probably fine] style C fill:#ef4444,color:#fff style E fill:#ef4444,color:#fff style G fill:#f59e0b,color:#fff style I fill:#f59e0b,color:#fff style J fill:#22c55e,color:#fff

Implementation: Fixing the Common Culprits

Fix 1: Add the Right Indexes

The query above needs two indexes:

-- Index for the WHERE clause on users
CREATE INDEX idx_users_created_at ON users(created_at);

-- Index for the JOIN condition on orders  
CREATE INDEX idx_orders_user_id ON orders(user_id);

After adding these, EXPLAIN ANALYZE shows:

Execution Time: 312 ms   ← Down from 44,814ms

Choosing index types:

-- B-tree (default): range queries, equality, ORDER BY
CREATE INDEX idx_orders_created_at ON orders(created_at);  -- WHERE created_at > '...'

-- Hash: equality only (faster for = than B-tree, no range support)
-- PostgreSQL 10+ only, rarely needed
CREATE INDEX idx_orders_status_hash ON orders USING HASH (status);

-- Partial index: index only rows that match a condition
-- Smaller index, faster for filtered queries
CREATE INDEX idx_orders_pending ON orders(user_id) 
    WHERE status = 'pending';  -- Only indexes pending orders

-- Composite index: column order matters
-- This index helps: WHERE user_id = ? AND created_at > ?
-- Also helps: WHERE user_id = ?  (leading column)
-- Does NOT help: WHERE created_at > ?  (non-leading column)
CREATE INDEX idx_orders_user_created ON orders(user_id, created_at);

-- Covering index: include non-indexed columns to avoid table heap access
-- Query only touches the index, never the table
CREATE INDEX idx_orders_covering ON orders(user_id, created_at) 
    INCLUDE (status, total_amount);

The most common mistake: Creating an index on a column used in a WHERE clause when the column has low cardinality. An index on status where status is one of three values ('pending', 'active', 'closed') helps nothing — the planner will still do a sequential scan because 33% of rows match, and a seq scan is faster than reading 33M index entries.

-- WRONG: low cardinality, index will be ignored
CREATE INDEX idx_users_is_active ON users(is_active);  -- Only 2 values

-- RIGHT: high cardinality, or restrict with partial index
CREATE INDEX idx_users_active ON users(id) WHERE is_active = true;
-- Now a small index covering only active users

Fix 2: The N+1 Query Destroyer

N+1 is the single most common database performance problem in ORMs. The pattern:

# ORM code that looks innocent
users = User.objects.filter(created_at__gt='2025-01-01')

for user in users:
    # THIS IS N+1 — one query per user
    print(user.orders.count())

What happens: 1 query to get N users, then N queries to get each user's order count. With 10,000 users: 10,001 database round trips. At 2ms per round trip: 20 seconds.

-- What the ORM is running (10,001 queries):
SELECT * FROM users WHERE created_at > '2025-01-01';
SELECT COUNT(*) FROM orders WHERE user_id = 1;
SELECT COUNT(*) FROM orders WHERE user_id = 2;
-- ... × 9,999 more

The fix: annotate or JOIN to get everything in one query:

# Django: annotate with COUNT in a single query
from django.db.models import Count

users = (
    User.objects
    .filter(created_at__gt='2025-01-01')
    .annotate(order_count=Count('orders'))
    .order_by('-order_count')[:20]
)
# 1 query total: the JOIN + COUNT SQL from earlier
# SQLAlchemy: same idea
from sqlalchemy import func

result = (
    session.query(User, func.count(Order.id).label('order_count'))
    .outerjoin(Order, Order.user_id == User.id)
    .filter(User.created_at > '2025-01-01')
    .group_by(User.id)
    .order_by(desc('order_count'))
    .limit(20)
    .all()
)

Detecting N+1 in production:

# Django Debug Toolbar shows query count per request
# For production monitoring, track queries-per-request in your APM

# Or log slow query patterns manually:
import django.db.backends.utils as db_utils
import logging

original = db_utils.CursorWrapper.execute

def patched_execute(self, sql, params=None):
    logging.debug(f"QUERY: {sql[:200]}")
    return original(self, sql, params)

db_utils.CursorWrapper.execute = patched_execute

Fix 3: JSONB Performance (PostgreSQL)

Storing semi-structured data in JSONB columns is convenient but has performance traps:

-- SLOW: no index on JSONB field, full table scan
SELECT * FROM events WHERE metadata->>'user_id' = '12345';

-- FIX 1: GIN index on entire JSONB column (handles any key lookup)
CREATE INDEX idx_events_metadata_gin ON events USING GIN (metadata);

-- FIX 2: Expression index on specific key (smaller, faster for single key)
CREATE INDEX idx_events_user_id ON events((metadata->>'user_id'));

-- FIX 3: If you always query the same keys, use a generated column
ALTER TABLE events 
ADD COLUMN user_id_extracted TEXT 
    GENERATED ALWAYS AS (metadata->>'user_id') STORED;
CREATE INDEX idx_events_user_id_col ON events(user_id_extracted);
-- Now it's just a regular column with a regular index

Fix 4: The Pagination Trap

OFFSET-based pagination is fine with small datasets. With large ones, it's a silent killer:

-- SLOW: scans and discards 999,980 rows to return rows 999,981-1,000,000
SELECT * FROM events ORDER BY created_at DESC LIMIT 20 OFFSET 999980;

-- FIX: cursor-based pagination using the last seen ID
-- First page:
SELECT * FROM events ORDER BY created_at DESC, id DESC LIMIT 20;

-- Subsequent pages (pass last_created_at and last_id from previous response):
SELECT * FROM events 
WHERE (created_at, id) < ('2025-06-15 10:23:44', 98765)
ORDER BY created_at DESC, id DESC 
LIMIT 20;

The cursor approach scans exactly 20 rows regardless of which page you're on. The OFFSET approach scans N+20 rows every time.

Query Patterns to Avoid

flowchart LR A[Dangerous Patterns] A --> B["SELECT * (fetches all columns)"] A --> C["WHERE LOWER(email) = ... (function prevents index use)"] A --> D["WHERE id::text = '123' (cast prevents index use)"] A --> E["LIKE '%search%' (leading wildcard = seq scan)"] A --> F["OR conditions on different columns (index skipped)"] B --> B2["SELECT only needed columns"] C --> C2["Use citext column type or expression index"] D --> D2["Match column type in WHERE clause"] E --> E2["Full-text search (tsvector/tsquery)"] F --> F2["Separate queries with UNION ALL"] style A fill:#ef4444,color:#fff
-- FUNCTION IN WHERE: prevents index use
-- SLOW:
SELECT * FROM users WHERE LOWER(email) = 'user@example.com';

-- FIX: use citext extension for case-insensitive storage
CREATE EXTENSION IF NOT EXISTS citext;
ALTER TABLE users ALTER COLUMN email TYPE citext;
-- Now: WHERE email = 'USER@EXAMPLE.COM' uses the index

-- OR: use an expression index
CREATE INDEX idx_users_email_lower ON users(LOWER(email));
SELECT * FROM users WHERE LOWER(email) = 'user@example.com';  -- Now uses index


-- LEADING WILDCARD: full table scan
-- SLOW:
SELECT * FROM products WHERE name LIKE '%widget%';

-- FIX: full-text search with GIN index
ALTER TABLE products ADD COLUMN search_vec tsvector
    GENERATED ALWAYS AS (to_tsvector('english', name || ' ' || description)) STORED;
CREATE INDEX idx_products_search ON products USING GIN(search_vec);
SELECT * FROM products WHERE search_vec @@ plainto_tsquery('english', 'widget');

VACUUM, Bloat, and Table Maintenance

PostgreSQL doesn't immediately reclaim storage when rows are updated or deleted. Old row versions are kept for MVCC (Multi-Version Concurrency Control) — concurrent readers might still need them. VACUUM reclaims this dead space. When it's not running aggressively enough, tables bloat, queries slow down, and eventually the transaction ID wraparound problem causes the database to refuse writes entirely.

-- Check table bloat
SELECT 
    schemaname, 
    tablename,
    pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS total_size,
    pg_size_pretty(
        pg_total_relation_size(schemaname||'.'||tablename) - 
        pg_relation_size(schemaname||'.'||tablename)
    ) AS index_size,
    n_dead_tup,
    n_live_tup,
    round(n_dead_tup::numeric / NULLIF(n_live_tup + n_dead_tup, 0) * 100, 1) AS dead_pct
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC;

High dead_pct means bloat. The fix: adjust autovacuum settings for high-traffic tables.

-- Per-table autovacuum tuning (override global settings for busy tables)
ALTER TABLE orders SET (
    autovacuum_vacuum_scale_factor = 0.01,   -- Vacuum when 1% of rows are dead (default: 20%)
    autovacuum_analyze_scale_factor = 0.005, -- Analyze when 0.5% of rows change
    autovacuum_vacuum_cost_delay = 2,        -- Less I/O throttling for this table
    autovacuum_vacuum_threshold = 50         -- Minimum dead rows before vacuum runs
);

-- For tables with extremely high write volume, trigger manual vacuum:
VACUUM ANALYZE orders;  -- Reclaims dead rows, updates statistics

-- VACUUM FULL reclaims more space but locks the table (avoid on production unless necessary)
-- Use pg_repack extension instead for zero-downtime table compaction

Connection Pooling and Query Performance

Database connections are expensive: each one holds memory on the server, maintains state, and requires a network handshake to establish. Without connection pooling, a spike to 500 concurrent web workers means 500 simultaneous database connections. PostgreSQL can handle ~100-200 connections efficiently; beyond that, performance degrades sharply.

PgBouncer is the standard connection pooler for PostgreSQL:

# pgbouncer.ini
[databases]
myapp = host=localhost port=5432 dbname=myapp

[pgbouncer]
listen_port = 6432
listen_addr = 0.0.0.0
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt

# Transaction mode: connection returned to pool after each transaction
# Best for stateless web apps; doesn't support SET, advisory locks
pool_mode = transaction

max_client_conn = 2000     # Max simultaneous app connections
default_pool_size = 25     # Actual connections to PostgreSQL
max_db_connections = 100   # Hard limit per database

With PgBouncer in transaction mode: 2,000 application workers share 25 actual PostgreSQL connections. The database sees a steady 25 connections regardless of web traffic spikes.

The query performance implication: connection overhead is removed from the hot path. Queries that were spending 2-3ms establishing connections now start immediately. At high throughput, this compounds: 3ms × 1,000 req/s = 3 seconds of connection overhead per second, eliminated.

Production Monitoring: Finding Slow Queries

Don't wait for incidents. Enable pg_stat_statements to track query performance continuously:

-- Enable in postgresql.conf:
-- shared_preload_libraries = 'pg_stat_statements'
-- pg_stat_statements.max = 10000
-- pg_stat_statements.track = all

CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- Find the 10 slowest queries by total time
SELECT 
    round(total_exec_time::numeric, 2) AS total_ms,
    round(mean_exec_time::numeric, 2) AS avg_ms,
    calls,
    round(stddev_exec_time::numeric, 2) AS stddev_ms,
    left(query, 120) AS query_preview
FROM pg_stat_statements
WHERE calls > 100
ORDER BY total_exec_time DESC
LIMIT 10;

-- Find queries with high variance (occasional spikes)
SELECT 
    left(query, 120) AS query,
    round(mean_exec_time::numeric, 2) AS avg_ms,
    round(stddev_exec_time::numeric, 2) AS stddev_ms,
    round(max_exec_time::numeric, 2) AS max_ms
FROM pg_stat_statements
WHERE calls > 50 AND stddev_exec_time > mean_exec_time
ORDER BY stddev_exec_time DESC
LIMIT 10;

Set up automated alerting when mean_exec_time for high-volume queries exceeds your SLO. A 50ms query that runs 10,000 times per minute contributes 500 seconds of total database time per minute — before any other queries.

Production Considerations

Index Maintenance

Indexes aren't free. Every write pays an index update cost. Over-indexing a write-heavy table hurts throughput. Audit unused indexes regularly:

-- Find indexes that are never used
SELECT schemaname, tablename, indexname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND indexname NOT LIKE 'pg_%'
ORDER BY schemaname, tablename;

-- Also check index bloat (indexes grow from updates/deletes)
SELECT 
    tablename, 
    indexname,
    pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
ORDER BY pg_relation_size(indexrelid) DESC
LIMIT 20;

Creating Indexes Without Downtime

CREATE INDEX in PostgreSQL locks the table for writes. For production tables, always use CONCURRENTLY:

-- This blocks writes until the index is built (avoid on production)
CREATE INDEX idx_orders_user_id ON orders(user_id);

-- This builds without blocking (takes 2-3× longer but safe for production)
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id);

CREATE INDEX CONCURRENTLY can fail mid-build. If it does, it leaves an invalid index that must be dropped:

-- Check for invalid indexes after CONCURRENT builds
SELECT indexname FROM pg_indexes 
WHERE tablename = 'orders' 
AND indexname IN (
    SELECT indexrelid::regclass::text FROM pg_index WHERE NOT indisvalid
);

-- Drop and recreate if invalid
DROP INDEX CONCURRENTLY idx_orders_user_id;
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id);

Query Planner Statistics and the ANALYZE Command

PostgreSQL's query planner makes decisions based on statistics about your data: how many distinct values a column has, how values are distributed, table row counts. These statistics go stale when large amounts of data are inserted or deleted. Stale statistics cause the planner to make wrong decisions — like choosing a sequential scan when an index would be faster.

-- View current statistics for a table
SELECT 
    attname AS column,
    n_distinct,                   -- Estimated distinct values (-1 to 1: negative = ratio of rows)
    correlation,                  -- Physical ordering correlation (1.0 = perfectly ordered, 0 = random)
    null_frac,                    -- Fraction of NULL values
    avg_width                     -- Average column value width in bytes
FROM pg_stats
WHERE tablename = 'orders'
ORDER BY attname;

-- Manually update statistics (fast, non-blocking)
ANALYZE orders;

-- ANALYZE with increased statistics for complex distributions
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 500;  -- Default is 100 targets
ANALYZE orders(status);

When a query plan suddenly degrades after a large data load, run ANALYZE tablename first. It takes seconds and often fixes the plan without any other changes.

The SET LOCAL Pattern for Query Tuning

PostgreSQL allows overriding planner settings per-query for debugging. This is useful for testing whether a different strategy would be faster:

BEGIN;

-- Force sequential scan (disable index usage) to compare
SET LOCAL enable_indexscan = OFF;
SET LOCAL enable_bitmapscan = OFF;
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'test@example.com';

ROLLBACK;  -- Revert settings

-- Or force a specific join strategy
BEGIN;
SET LOCAL enable_hashjoin = OFF;
SET LOCAL enable_mergejoin = OFF;
-- Forces nested loop join — useful when the planner chooses hash join incorrectly
EXPLAIN ANALYZE SELECT u.*, o.total FROM users u JOIN orders o ON u.id = o.user_id WHERE u.id = 123;
ROLLBACK;

These are diagnostic tools, not production settings. If a plan only works with enable_indexscan = OFF, the fix is updating statistics or hints via pg_hint_plan, not disabling index scans globally.

Conclusion

Query optimization is a skill built on a small number of core techniques used repeatedly:

  1. Read EXPLAIN ANALYZE before making any changes — optimize what's actually slow, not what you think is slow
  2. Index selectively — high-cardinality columns used in WHERE, JOIN, and ORDER BY; partial indexes for filtered queries
  3. Kill N+1 at the ORM level — annotate, eager load, or write the JOIN yourself
  4. Avoid index-defeating patterns — functions in WHERE, leading wildcards, low-cardinality indexes
  5. Monitor continuouslypg_stat_statements catches regressions before they become incidents

The 44-second query at the start of this guide becomes 312ms with two indexes. That's a 143× improvement. Most database performance problems aren't hard to fix — they're hard to find. The tools above make them findable.

One more habit that separates strong backend engineers from average ones: run EXPLAIN ANALYZE on your application's 10 highest-frequency queries before going to production, not after the first incident. Query plans change as data grows — a plan that looks fine at launch can degrade badly at 100× the initial data. Capturing the baseline plan lets you detect regressions before users do.


Sources

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-05-15 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Bigger Is Not the Same as Better. The Job That Moved Is the Phone, Not the Lab.

Bigger is a plan. The phone is the receipt. The brief for this cycle is a question: does bigger always mean better in AI? The 2026 answer i...