Showing posts with label databases. Show all posts
Showing posts with label databases. 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

Redis Advanced Patterns in 2026: Streams, Pub/Sub, Lua Scripts, and Vector Search

Hero image

Introduction

Redis ships with a reputation it only partially deserves. Most teams use it as a dumb key-value store — SET key value EX 300, GET key, done. That pattern is useful, but it barely scratches what Redis can do. The same process that holds your session tokens also gives you a persistent, consumer-group-aware message log, a pub/sub broadcast bus, an atomic scripting engine, and — with Redis Stack — a vector database capable of sub-millisecond approximate nearest neighbor search across millions of embeddings.

The version most production systems are running today — Redis 7.x and Redis Stack 2.x — is a fundamentally different beast than the Redis from five years ago. Streams, introduced in Redis 5.0, are now mature enough to replace Kafka for the majority of workloads that don't need multi-day retention or petabyte-scale throughput. RediSearch's vector search, available in Redis Stack, has gone from an experiment to a serious alternative to Pinecone and Weaviate for teams that want to avoid managing a separate vector store. Lua scripting has always been there, but most developers still reach for MULTI/EXEC pipelines when a well-written Lua script would eliminate race conditions entirely.

This post covers the patterns that graduate Redis from "fast cache" to "production workhorse": advanced caching with stampede prevention, Streams for durable event processing with consumer groups and dead-letter queues, Pub/Sub for real-time fan-out, Lua scripts for atomic compound operations, vector search for semantic similarity workloads, and Cluster mode for high availability at scale. Every code example is complete and production-ready. Where Redis competes with specialized tools — Kafka, RabbitMQ, Pinecone — the tradeoffs are explicit.


1. Advanced Caching Patterns

The SET key value EX ttl pattern handles maybe 60 percent of caching use cases. The remaining 40 percent — write-through, write-behind, stampede prevention, layered caches, per-datatype TTL strategies — is where caching actually gets interesting and where naive implementations silently degrade under load.

Cache Strategies

Cache-aside (lazy loading) is the most common pattern: the application checks the cache on read, populates it on miss. Simple to reason about, easy to implement, but it means the first request after a cache miss hits the database. Under traffic spikes, dozens or hundreds of requests can all miss simultaneously on the same key and all hit the database in parallel — this is a cache stampede.

Write-through updates the cache synchronously on every write. Cache and database are always in sync. The cost: every write pays the penalty of two writes (cache + DB), and you cache data that may never be read.

Write-behind (write-back) writes to the cache first and flushes to the database asynchronously. Dramatically reduces write latency, but risks data loss if Redis restarts before the flush. Appropriate for metrics accumulation or click counters; inappropriate for financial records.

XFetch: Probabilistic Early Expiration

Cache stampede is a deceptively hard problem. The naive fix — a distributed lock that forces only one caller to recompute while others wait — adds latency and creates its own contention. The XFetch algorithm from Vattani, Chierichetti, and Lowenstein (2015) solves this without locks: it probabilistically recomputes the cache early, before expiration, based on how expensive the recomputation is and how close the TTL is.

The probability of early recomputation grows as the key approaches expiration. Expensive recomputations (high beta) trigger early refresh sooner. The result: the cache is refreshed in the background before it expires, and stampedes never happen.

# cache_xfetch.py
import redis
import time
import math
import random
import json
from typing import Callable, Any, Optional

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

def xfetch(
    key: str,
    ttl: int,
    recompute: Callable[[], Any],
    beta: float = 1.0,
) -> Any:
    """
    XFetch: probabilistic early expiration to prevent cache stampedes.

    Args:
        key:        Redis key to cache under
        ttl:        Desired TTL in seconds
        recompute:  Callable that fetches the canonical value (DB query, API call, etc.)
        beta:       Recomputation cost factor. Higher = recompute sooner.
                    1.0 is a sensible default. Set higher for expensive recomputations.

    Returns:
        Cached or freshly computed value.
    """
    # Fetch current cached value and its remaining TTL atomically
    pipe = r.pipeline(transaction=False)
    pipe.get(key)
    pipe.ttl(key)
    cached_raw, remaining_ttl = pipe.execute()

    if cached_raw is not None:
        cached = json.loads(cached_raw)
        expiry = time.time() + remaining_ttl  # absolute expiry time

        # XFetch early-expiration check:
        # Recompute early with probability proportional to how close we are to expiry
        # and how expensive the recomputation is (delta * beta).
        delta = ttl - remaining_ttl  # time elapsed since last refresh
        if delta <= 0:
            delta = 1  # guard against zero/negative delta on fresh keys

        # The longer a recomputation takes (larger delta) and the closer
        # the key is to expiry, the more likely we are to refresh now.
        jitter = -beta * delta * math.log(random.random())
        if time.time() + jitter >= expiry:
            # Probabilistically decided to refresh early — recompute and cache
            value = recompute()
            r.set(key, json.dumps(value), ex=ttl)
            return value

        return cached

    # Cache miss: recompute and cache
    value = recompute()
    r.set(key, json.dumps(value), ex=ttl)
    return value


# --- Layered cache: L1 (local dict) → L2 (Redis) → L3 (database) ---

import functools
from collections import OrderedDict

class LRUCache:
    """Minimal in-process LRU cache for L1 layer."""
    def __init__(self, maxsize: int = 512):
        self.cache: OrderedDict = OrderedDict()
        self.maxsize = maxsize

    def get(self, key: str) -> Optional[Any]:
        if key in self.cache:
            self.cache.move_to_end(key)
            return self.cache[key]
        return None

    def set(self, key: str, value: Any) -> None:
        if key in self.cache:
            self.cache.move_to_end(key)
        self.cache[key] = value
        if len(self.cache) > self.maxsize:
            self.cache.popitem(last=False)

l1 = LRUCache(maxsize=512)

def get_with_layered_cache(
    key: str,
    db_fetch: Callable[[], Any],
    l2_ttl: int = 300,
) -> Any:
    """
    Three-layer cache lookup:
      L1 → in-process LRU dict (microseconds)
      L2 → Redis (sub-millisecond)
      L3 → database (milliseconds to seconds)
    """
    # L1 check
    value = l1.get(key)
    if value is not None:
        return value

    # L2 check (Redis), with stampede protection
    value = xfetch(key, ttl=l2_ttl, recompute=db_fetch)

    # Populate L1
    l1.set(key, value)
    return value

TTL Strategies by Data Volatility

TTL is not one-size-fits-all. A sensible tiering:

Data type TTL Rationale
User session 30 minutes (sliding) Active sessions stay warm; idle sessions expire
Product catalog 10 minutes Changes rarely; stale for a few minutes is acceptable
User profile 5 minutes Changes infrequently; short TTL keeps data fresh
Real-time prices 5 seconds Data staleness is a business risk
Feature flags 60 seconds Need to propagate quickly after changes
Computed aggregates 30 minutes Expensive to recompute; tolerate some staleness
Architecture diagram
flowchart LR A[Request] --> B{L1 Cache\nlocal dict} B -- Hit --> Z[Return value] B -- Miss --> C{L2 Cache\nRedis} C -- Hit --> D[Populate L1] D --> Z C -- Miss --> E[L3: Database] E --> F[Populate L2\nXFetch TTL] F --> D

2. Redis Streams for Event Processing

Redis Streams, stable since Redis 5.0 and hardened through 7.x, is a persistent, append-only log with consumer group semantics. It is not a replacement for Kafka at petabyte scale or with multi-day retention requirements. It is a serious replacement for Kafka in the 95 percent of systems where throughput stays under 1 million events per day, retention is hours-to-days rather than weeks-to-months, and the operational cost of running a Kafka cluster (Zookeeper or KRaft, broker replication, topic management, consumer group lag monitoring) is not justified.

A single Redis node benchmarks at over 1 million XADD operations per second. With consumer groups, you get competing consumers, at-least-once delivery semantics, and a built-in pending entries list (PEL) that tracks unacknowledged messages. This is Kafka-lite without the JVM.

Core Commands

  • XADD stream * field value [field value ...] — append entry, auto-generate ID
  • XREAD COUNT n STREAMS stream 0 — read from beginning
  • XREADGROUP GROUP grp consumer COUNT n STREAMS stream > — read undelivered messages to this group
  • XACK stream grp id — acknowledge processing complete
  • XPENDING stream grp - + n — list unacknowledged messages
  • XCLAIM stream grp consumer min-idle-ms id — reassign a stale pending message

Full Producer + Consumer Group with DLQ

// streams-consumer.js — Node.js (ioredis)
import Redis from "ioredis";

const redis = new Redis({ host: "localhost", port: 6379 });
const STREAM = "events:orders";
const GROUP = "order-processor";
const DLQ_STREAM = "events:orders:dlq";
const MAX_RETRIES = 3;
const CLAIM_IDLE_MS = 30_000; // reclaim messages idle > 30s

// --- Setup ---
async function ensureConsumerGroup() {
  try {
    // MKSTREAM creates the stream if it doesn't exist
    await redis.xgroup("CREATE", STREAM, GROUP, "$", "MKSTREAM");
    console.log(`Consumer group '${GROUP}' created`);
  } catch (err) {
    if (!err.message.includes("BUSYGROUP")) throw err;
    // Group already exists — fine
  }
}

// --- Producer ---
async function produce(order) {
  const id = await redis.xadd(
    STREAM,
    "*",                      // auto-generate ID (timestamp-based)
    "order_id", order.id,
    "customer", order.customer,
    "amount", String(order.amount),
    "payload", JSON.stringify(order),
  );
  console.log(`Produced message ${id}`);
  return id;
}

// --- Process one message ---
async function processMessage(id, fields) {
  // fields comes back as flat array: [key, val, key, val, ...]
  const data = {};
  for (let i = 0; i < fields.length; i += 2) {
    data[fields[i]] = fields[i + 1];
  }

  console.log(`Processing order ${data.order_id} for ${data.customer}`);

  // Simulate processing (replace with real business logic)
  if (Math.random() < 0.1) {
    throw new Error(`Simulated failure for order ${data.order_id}`);
  }

  console.log(`Order ${data.order_id} processed successfully`);
}

// --- Move to DLQ ---
async function sendToDLQ(id, fields, reason) {
  await redis.xadd(
    DLQ_STREAM,
    "*",
    "original_id", id,
    "reason", reason,
    "failed_at", String(Date.now()),
    ...fields,
  );
  console.warn(`Message ${id} sent to DLQ: ${reason}`);
}

// --- Consumer loop ---
async function runConsumer(consumerName) {
  await ensureConsumerGroup();

  console.log(`Consumer '${consumerName}' starting`);

  while (true) {
    // 1. Check for stale pending messages (unacked for > CLAIM_IDLE_MS)
    const pending = await redis.xpending(
      STREAM, GROUP, "-", "+", 10
    );

    for (const entry of pending) {
      const [msgId, owner, idleMs, deliveryCount] = entry;

      if (idleMs > CLAIM_IDLE_MS) {
        if (deliveryCount >= MAX_RETRIES) {
          // Exceeded retry limit → DLQ
          const claimed = await redis.xclaim(
            STREAM, GROUP, consumerName, CLAIM_IDLE_MS, msgId
          );
          if (claimed.length > 0) {
            const [claimedId, claimedFields] = claimed[0];
            await sendToDLQ(claimedId, claimedFields, `Max retries (${MAX_RETRIES}) exceeded`);
            await redis.xack(STREAM, GROUP, claimedId);
          }
        } else {
          // Reclaim and retry
          await redis.xclaim(STREAM, GROUP, consumerName, CLAIM_IDLE_MS, msgId);
          console.log(`Reclaimed stale message ${msgId} (attempt ${deliveryCount + 1})`);
        }
      }
    }

    // 2. Read new messages (> means "only undelivered to this group")
    const results = await redis.xreadgroup(
      "GROUP", GROUP,
      consumerName,
      "COUNT", "10",
      "BLOCK", "2000",   // block up to 2 seconds waiting for new messages
      "STREAMS", STREAM,
      ">",
    );

    if (!results) continue; // timeout with no messages — loop back

    for (const [_stream, messages] of results) {
      for (const [id, fields] of messages) {
        try {
          await processMessage(id, fields);
          await redis.xack(STREAM, GROUP, id); // ack only on success
        } catch (err) {
          // Don't ack — leave in PEL for retry via XCLAIM loop above
          console.error(`Failed to process ${id}: ${err.message}`);
        }
      }
    }
  }
}

// --- Entry point ---
const consumerName = process.argv[2] || "consumer-1";
runConsumer(consumerName).catch(console.error);
flowchart TD P[Producer] -->|XADD| S[(Redis Stream)] S -->|XREADGROUP| CG[Consumer Group] CG --> W1[Worker 1] CG --> W2[Worker 2] CG --> W3[Worker 3] W1 -->|Success: XACK| S W2 -->|Failure: stays in PEL| PE[Pending Entries List] PE -->|idle > 30s: XCLAIM| W3 W3 -->|retries > 3: XADD| DLQ[(Dead Letter Queue)] DLQ --> MON[DLQ Monitor / Alerting]

Redis Streams vs Kafka: When to Choose Which

Factor Redis Streams Kafka
Throughput 1M+ msg/s single node 10M+ msg/s multi-broker
Retention Hours to days (memory-backed) Weeks to months (disk)
Operational cost Zero — already running Redis Significant (brokers, ZK/KRaft)
Consumer groups Yes Yes
Replay Yes (XRANGE from any ID) Yes
Schema registry No Confluent Schema Registry
Exactly-once No (at-least-once) Yes (with transactions)

Choose Redis Streams when: throughput is under 1M events/day, retention under 48 hours, you already run Redis, and exactly-once semantics are not required. Choose Kafka when: throughput exceeds what a single Redis node handles, you need multi-week retention, or exactly-once delivery is a hard requirement.


3. Pub/Sub and Real-Time Patterns

Redis Pub/Sub and Redis Streams are complements, not alternatives. The key distinction: Pub/Sub is fire-and-forget — messages published to a channel are delivered only to subscribers active at that moment and are not persisted. Streams are persistent logs. A subscriber that goes offline for five seconds during a Streams workload misses nothing; a subscriber that goes offline for five seconds during a Pub/Sub workload misses everything published in that window.

Use Pub/Sub for: live notifications, presence indicators, real-time dashboards, and any pattern where a momentary gap is acceptable. Use Streams for: anything requiring guaranteed delivery or replay.

SUBSCRIBE, PUBLISH, PSUBSCRIBE

# pubsub_demo.py
import redis
import threading
import time

r_pub = redis.Redis(host="localhost", port=6379, decode_responses=True)
r_sub = redis.Redis(host="localhost", port=6379, decode_responses=True)

def subscriber():
    pubsub = r_sub.pubsub()

    # Subscribe to exact channel
    pubsub.subscribe("notifications:global")

    # Pattern subscription — catches notifications:user:*, notifications:team:*, etc.
    pubsub.psubscribe("notifications:*")

    for message in pubsub.listen():
        if message["type"] in ("message", "pmessage"):
            print(f"[{message['channel']}] {message['data']}")

def publisher():
    time.sleep(0.5)  # Let subscriber connect
    r_pub.publish("notifications:global", "System maintenance at 22:00 UTC")
    r_pub.publish("notifications:user:42", "Your export is ready")
    r_pub.publish("notifications:team:engineering", "Deploy window open")

t = threading.Thread(target=subscriber, daemon=True)
t.start()
publisher()
time.sleep(1)

Keyspace Notifications

Keyspace notifications let you subscribe to Redis key lifecycle events — expiry, deletion, set operations — without polling. Enable them in redis.conf or at runtime:

# Enable expired + generic key events
redis-cli CONFIG SET notify-keyspace-events "Ex"
# Watch for key expiry events
pubsub = r_sub.pubsub()
pubsub.psubscribe("__keyevent@0__:expired")

for message in pubsub.listen():
    if message["type"] == "pmessage":
        expired_key = message["data"]
        print(f"Key expired: {expired_key}")
        # Trigger: session cleanup, cache invalidation, reminder dispatch

Practical applications: session invalidation (trigger logout cleanup when session key expires), job timeout detection (set a key with the job TTL; expiry fires if the job never deletes it), and distributed lock monitoring.

Fan-Out Architecture

Redis Pub/Sub is the broadcast backbone for real-time fan-out. A single publisher can reach thousands of subscribers in under a millisecond. The pattern for a presence indicator in a chat application:

  1. On connect: SET presence:{user_id} online EX 30 + PUBLISH presence:channel "{user_id}:online"
  2. Heartbeat: EXPIRE presence:{user_id} 30 every 15 seconds
  3. On disconnect: key expires → keyspace notification fires → PUBLISH presence:channel "{user_id}:offline"
  4. All connected clients receive the publish and update the UI

In Redis Cluster mode, Pub/Sub messages are broadcast to all shards — the cluster itself handles routing, so your application code is identical in standalone and cluster deployments.


4. Lua Scripts for Atomic Operations

Every Redis MULTI/EXEC transaction has a fundamental limitation: the commands inside it are queued and sent as a batch, but the application must still make multiple round trips (WATCH, MULTI, commands, EXEC) and cannot branch based on intermediate values. If GET counter returns 5, you cannot conditionally SET counter 10 inside the same transaction without an optimistic lock retry loop.

Lua scripts run inside Redis's single-threaded execution model. From the moment EVAL fires, no other Redis command executes until the script completes. You get true atomicity, you can branch on intermediate values, and you eliminate round trips. The entire script is a single command from the client's perspective.

Rate Limiter: Sliding Window in Lua

# rate_limiter.py
import redis
import time

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

# Sliding window rate limiter
# Uses a sorted set where each member is a unique request ID
# and the score is the request timestamp in milliseconds.
RATE_LIMIT_SCRIPT = """
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window_ms = tonumber(ARGV[2])
local max_requests = tonumber(ARGV[3])
local request_id = ARGV[4]

-- Remove entries outside the sliding window
redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window_ms)

-- Count current requests in window
local count = redis.call('ZCARD', key)

if count < max_requests then
    -- Allow: add this request to the window
    redis.call('ZADD', key, now, request_id)
    redis.call('PEXPIRE', key, window_ms)
    return {1, max_requests - count - 1}  -- {allowed, remaining}
else
    -- Deny
    return {0, 0}
end
"""

# Load script once, use SHA thereafter (saves bandwidth)
RATE_LIMIT_SHA = r.script_load(RATE_LIMIT_SCRIPT)

def check_rate_limit(
    user_id: str,
    max_requests: int = 100,
    window_seconds: int = 60,
) -> tuple[bool, int]:
    """
    Check if user_id is within rate limit.
    Returns (allowed: bool, remaining: int).
    """
    key = f"ratelimit:{user_id}"
    now_ms = int(time.time() * 1000)
    request_id = f"{now_ms}-{id(object())}"  # unique per request

    result = r.evalsha(
        RATE_LIMIT_SHA,
        1,             # number of KEYS arguments
        key,           # KEYS[1]
        now_ms,        # ARGV[1]
        window_seconds * 1000,  # ARGV[2]: window in ms
        max_requests,  # ARGV[3]
        request_id,    # ARGV[4]
    )

    allowed = bool(result[0])
    remaining = int(result[1])
    return allowed, remaining

Distributed Lock with Expiry

The canonical Redis distributed lock pattern uses SET key token NX EX ttl. The token (a UUID) ensures only the lock holder can release it — another process cannot accidentally release a lock it doesn't hold. The Lua script makes the check-and-delete atomic:

import uuid

RELEASE_LOCK_SCRIPT = """
-- Only release if we hold the lock (token matches)
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
else
    return 0
end
"""
RELEASE_LOCK_SHA = r.script_load(RELEASE_LOCK_SCRIPT)

def acquire_lock(resource: str, ttl_seconds: int = 10) -> str | None:
    """Acquire lock. Returns token if acquired, None if not."""
    token = str(uuid.uuid4())
    acquired = r.set(f"lock:{resource}", token, nx=True, ex=ttl_seconds)
    return token if acquired else None

def release_lock(resource: str, token: str) -> bool:
    """Release lock only if we hold it."""
    result = r.evalsha(RELEASE_LOCK_SHA, 1, f"lock:{resource}", token)
    return bool(result)

# Usage
token = acquire_lock("job:export:user:42", ttl_seconds=30)
if token:
    try:
        pass  # do work
    finally:
        release_lock("job:export:user:42", token)
Comparison visual
sequenceDiagram participant C1 as Client 1 participant C2 as Client 2 participant R as Redis rect rgb(255, 235, 235) Note over C1,R: Race condition — MULTI/EXEC with WATCH C1->>R: WATCH counter C2->>R: WATCH counter C1->>R: GET counter → 5 C2->>R: GET counter → 5 C1->>R: MULTI / INCR counter / EXEC → OK (counter=6) C2->>R: MULTI / INCR counter / EXEC → nil (conflict, retry needed) end rect rgb(235, 255, 235) Note over C1,R: Lua atomic — no conflict possible C1->>R: EVAL "if GET counter >= limit then return 0 end INCR counter return 1" Note over R: Executes atomically; C2 blocked until complete R-->>C1: 1 (allowed) C2->>R: EVAL same script R-->>C2: 0 (limit reached) or 1 (incremented) end

Conditional Leaderboard Update in Lua

# Only update a user's leaderboard score if the new score beats their current best
UPDATE_BEST_SCORE_SCRIPT = """
local key = KEYS[1]
local member = ARGV[1]
local new_score = tonumber(ARGV[2])

local current = redis.call('ZSCORE', key, member)

if current == false or new_score > tonumber(current) then
    redis.call('ZADD', key, new_score, member)
    return 1  -- updated
else
    return 0  -- not updated (new score wasn't better)
end
"""
UPDATE_BEST_SCORE_SHA = r.script_load(UPDATE_BEST_SCORE_SCRIPT)

def update_best_score(leaderboard: str, user_id: str, score: float) -> bool:
    result = r.evalsha(
        UPDATE_BEST_SCORE_SHA, 1,
        leaderboard,
        user_id,
        score,
    )
    return bool(result)

5. Redis as a Vector Database

Redis Stack ships with RediSearch, which since version 2.4 includes a production-grade vector search engine. It supports HNSW (Hierarchical Navigable Small World) and flat (brute-force) indexes, hybrid search combining vector similarity with metadata filters, and ANN (approximate nearest neighbor) search returning results in under a millisecond for millions of vectors on a single node.

This matters because teams running semantic search, recommendation engines, or RAG (retrieval-augmented generation) pipelines increasingly face the question: run a dedicated vector database (Pinecone, Weaviate, Qdrant), or use Redis Stack and keep the stack simple? For datasets under 10 million vectors where Redis is already in the stack, the answer is often Redis.

Setup: Creating a Vector Index

# vector_search.py
import redis
import numpy as np
from redis.commands.search.field import VectorField, TagField, TextField
from redis.commands.search.indexDefinition import IndexDefinition, IndexType
from redis.commands.search.query import Query

r = redis.Redis(host="localhost", port=6379, decode_responses=False)

VECTOR_DIM = 1536       # OpenAI text-embedding-3-small dimension
INDEX_NAME = "idx:docs"
DOC_PREFIX = "doc:"

def create_index():
    try:
        r.ft(INDEX_NAME).dropindex(delete_documents=False)
    except Exception:
        pass  # Index didn't exist

    schema = (
        TextField("$.text", as_name="text"),
        TagField("$.category", as_name="category"),
        VectorField(
            "$.embedding",
            "HNSW",           # HNSW for ANN (fast); FLAT for exact (small datasets)
            {
                "TYPE": "FLOAT32",
                "DIM": VECTOR_DIM,
                "DISTANCE_METRIC": "COSINE",   # or L2, IP (inner product)
                "INITIAL_CAP": 100_000,        # preallocate for 100k vectors
                "M": 16,                       # HNSW connectivity parameter
                "EF_CONSTRUCTION": 200,        # HNSW build-time quality
            },
            as_name="embedding",
        ),
    )

    r.ft(INDEX_NAME).create_index(
        schema,
        definition=IndexDefinition(
            prefix=[DOC_PREFIX],
            index_type=IndexType.JSON,
        ),
    )
    print(f"Index '{INDEX_NAME}' created")


def store_document(doc_id: str, text: str, category: str, embedding: np.ndarray):
    """Store a document with its embedding."""
    import json
    key = f"{DOC_PREFIX}{doc_id}"
    r.json().set(key, "$", {
        "text": text,
        "category": category,
        "embedding": embedding.astype(np.float32).tolist(),
    })


def vector_search(
    query_embedding: np.ndarray,
    top_k: int = 5,
    category_filter: str = None,
) -> list[dict]:
    """
    KNN vector search with optional metadata filter.

    Hybrid search: combine vector similarity with tag filter.
    This is where Redis outperforms many dedicated vector DBs —
    metadata filtering happens at the index level, not post-hoc.
    """
    query_bytes = query_embedding.astype(np.float32).tobytes()

    # Build filter expression
    if category_filter:
        # Hybrid: vector similarity AND metadata filter
        filter_expr = f"(@category:{{{category_filter}}})"
    else:
        filter_expr = "*"

    # KNN query syntax: @field_name:[VECTOR_RANGE radius $param]
    # or KNN top_k: @field_name:[KNN k $param]
    q = (
        Query(f"{filter_expr}=>[KNN {top_k} @embedding $vec AS score]")
        .sort_by("score")
        .return_fields("text", "category", "score")
        .paging(0, top_k)
        .dialect(2)
    )

    results = r.ft(INDEX_NAME).search(q, query_params={"vec": query_bytes})

    return [
        {
            "id": doc.id,
            "text": doc.text,
            "category": doc.category,
            "score": float(doc.score),   # cosine distance (lower = more similar)
        }
        for doc in results.docs
    ]

Performance Characteristics

On a single Redis node with 16 GB of RAM, HNSW handles 1 million 1536-dimension vectors in approximately 6 GB of memory and returns KNN results in under 2 milliseconds at the 99th percentile. Flat (brute-force) indexes are exact but O(n) — use flat for datasets under 50,000 vectors where perfect recall matters, HNSW for everything larger.

Redis Vector Search vs Dedicated Vector DBs

Factor Redis Stack Pinecone Weaviate Qdrant
Setup complexity Low (already in stack) Zero (managed) Medium Medium
Max scale (practical) ~10M vectors/node Unlimited (managed) Unlimited Unlimited
Hybrid search Yes Yes Yes Yes
Persistence RDB/AOF Managed Yes Yes
Cost at 1M vectors $0 (existing Redis) ~$70/mo (s1.x1) Self-host costs Self-host costs
Operational overhead Minimal Zero Moderate Moderate

Choose Redis for vectors when: you already run Redis Stack, dataset is under 10M vectors, and you want to avoid a separate service. Choose Pinecone when: you need fully managed, unlimited scale with zero ops. Choose Weaviate or Qdrant when: you need advanced filtering, multi-modal search, or open-source self-hosted control beyond what Redis offers.


6. Cluster Mode and High Availability

A standalone Redis node is a single point of failure. For production systems where Redis is on the critical path — and if you are using it as a message bus, session store, or real-time cache, it is — you need either Sentinel (automatic failover for standalone) or Redis Cluster (sharding + HA combined).

Hash Slots

Redis Cluster distributes keys across 16,384 hash slots. Each key maps to a slot via CRC16(key) % 16384. Slots are distributed across primary nodes — a three-node cluster gives approximately 5,461 slots per node. Reads from replicas are allowed with READONLY mode but are eventually consistent.

# Check which slot a key maps to
redis-cli CLUSTER KEYSLOT "user:123:session"
# → 8490 (example)

# Check which node owns that slot
redis-cli -c CLUSTER NODES | grep "8490"

Hash Tags for Co-location

Multi-key commands (MGET, MSET, Lua scripts referencing multiple keys) only work in Cluster mode if all keys hash to the same slot. Hash tags force co-location: only the portion of the key inside {} is used for slot calculation.

# Without hash tags — these keys may land on different nodes
# MGET user:123:profile user:123:session  ← may fail in cluster mode

# With hash tags — both keys hash on "user:123"
# MGET {user:123}:profile {user:123}:session  ← always same slot

user_id = 123
profile_key = f"{{user:{user_id}}}:profile"
session_key = f"{{user:{user_id}}}:session"
cart_key    = f"{{user:{user_id}}}:cart"

# Now safe to use in pipelines and Lua scripts in cluster mode
pipe = r.pipeline(transaction=True)
pipe.get(profile_key)
pipe.get(session_key)
pipe.get(cart_key)
results = pipe.execute()

Sentinel vs Cluster

Sentinel provides automatic failover for a single primary + N replicas. It does not shard data. Use Sentinel when your dataset fits on one node and you want automatic failover without the complexity of Cluster. Three Sentinel processes (odd number for quorum) monitor the primary; if the primary is unreachable from quorum Sentinels, a failover is triggered and a replica is promoted. Failover takes 30–60 seconds by default (down-after-milliseconds + failover-timeout).

Cluster provides sharding across multiple primaries, each with optional replicas. Use Cluster when your dataset exceeds single-node memory, when you need horizontal write throughput, or when you want HA and sharding in a single deployment model.

Connection Pooling

Every application connecting to Redis should use a connection pool. Creating a new TCP connection per command adds 1–3 ms of overhead — significant when Redis commands themselves take under 0.1 ms.

# redis_pool.py
import redis

# Connection pool — create once at application startup
pool = redis.ConnectionPool(
    host="localhost",
    port=6379,
    db=0,
    max_connections=50,        # tune based on worker count × commands-per-request
    decode_responses=True,
    socket_timeout=1.0,        # command timeout
    socket_connect_timeout=2.0,
)

# All clients share the pool
def get_redis() -> redis.Redis:
    return redis.Redis(connection_pool=pool)

For Redis Cluster with ioredis in Node.js:

// cluster-client.js
import Redis from "ioredis";

const cluster = new Redis.Cluster(
  [
    { host: "redis-node-1", port: 6379 },
    { host: "redis-node-2", port: 6379 },
    { host: "redis-node-3", port: 6379 },
  ],
  {
    redisOptions: {
      password: process.env.REDIS_PASSWORD,
      connectTimeout: 2000,
    },
    clusterRetryStrategy: (times) => Math.min(times * 100, 3000),
    // Read from replicas for read-heavy workloads
    scaleReads: "slave",
  }
);

export default cluster;

Sentinel Failover Configuration

# redis-sentinel.conf (minimal production config)
sentinel monitor mymaster 10.0.1.10 6379 2      # quorum = 2
sentinel down-after-milliseconds mymaster 5000   # 5s to declare primary down
sentinel failover-timeout mymaster 60000         # 60s max for failover
sentinel parallel-syncs mymaster 1               # replicas to sync in parallel

With down-after-milliseconds 5000 and a typical failover completing in 15–20 seconds, expect a 20–30 second window of write unavailability during an unplanned primary failure. For applications that cannot tolerate this, use Cluster with min-replicas-to-write 1 to fail writes fast.


Conclusion

Redis earns its place on the critical path of production systems not because it is fast (it is), but because it provides the right primitives at each layer of the application stack. The patterns in this post cover the full range: advanced caching with XFetch eliminates stampedes without coordination overhead; Streams give you Kafka-level durability for the majority of real-world event volumes with none of the operational weight; Pub/Sub is the right tool for fire-and-forget fan-out where persistence would add latency with no benefit; Lua scripts make compound operations truly atomic without multi-round-trip transaction protocols; and Redis Stack's vector search removes the need for a separate vector store for datasets up to tens of millions of embeddings.

The pattern-selection heuristic is straightforward: if you need persistence and replay, use Streams. If you need broadcast with no durability requirement, use Pub/Sub. If you need atomic compound operations on multiple keys, use Lua. If you need sub-millisecond semantic search and you already run Redis Stack, use the vector index before reaching for Pinecone.

Operational considerations that matter more than any individual pattern: connection pooling (don't create connections per request), hash tags for co-location in Cluster mode (or multi-key commands will fail), and TTL hygiene (keys without TTLs will grow Redis memory indefinitely). Monitor redis-cli INFO memory for used_memory_rss versus maxmemory, and set maxmemory-policy allkeys-lru in cache-only deployments so Redis degrades gracefully under memory pressure rather than refusing writes.

The full code in this post is production-ready. Drop the XFetch implementation into any cache layer, the consumer group worker into any event-driven service, and the Lua rate limiter into any API gateway. The primitives are stable across Redis 7.x.


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-10 · 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

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...