Redis vs PostgreSQL for Idempotency State

Part of: Idempotency Key Storage & TTL Management

Every idempotency implementation needs a place to record “this request has already been seen.” The two dominant choices are an in-memory store like Redis and a relational store like PostgreSQL, and the choice is not cosmetic — it determines what happens when the store crashes mid-write, how tightly the dedup check couples to your business transaction, and how much latency you add to every request. This page compares them directly and then shows the hybrid pattern most production payment systems converge on.

This decision sits inside Idempotency Key Storage & TTL Management and assumes you have already read choosing TTL values for idempotency keys, since TTL behavior differs meaningfully between the two stores.


Decision matrix

Redis vs PostgreSQL decision matrix A six-row comparison table. Redis wins on latency and native TTL. PostgreSQL wins on durability, transactional coupling with business data, and long-term system-of-record status. Atomicity primitives and operational cost are roughly comparable, with different trade-offs. Dimension Redis PostgreSQL Write latency sub-ms (in-memory) low-ms (WAL fsync) Durability on crash AOF everysec: up to 1s loss WAL: zero committed loss Atomicity primitive SET key val NX EX ttl INSERT ... ON CONFLICT TTL handling native, per-key column + scheduled sweep Txn coupling to business data none — separate store same ACID transaction Operational cost extra cluster to run reuses existing DB Hybrid: Redis fast-path rejects obvious duplicates, PostgreSQL commits the durable, transactionally-coupled record

Comparison

Dimension Redis PostgreSQL
Write latency Sub-millisecond, in-memory Low-millisecond, bounded by WAL fsync (typically 1–5 ms on SSD)
Durability on crash With appendfsync everysec, up to 1 second of writes can be lost; always trades latency for zero loss Zero committed-data loss — WAL fsync on commit is the ACID guarantee
Atomicity primitive SET key value NX EX ttl — atomic set-if-absent-with-expiry in one round trip INSERT ... ON CONFLICT (key) DO NOTHING against a unique constraint — atomic at the row level
TTL handling Native per-key expiry, no extra infrastructure No native TTL — requires an expires_at column and a scheduled sweep job
Transactional coupling None — Redis cannot participate in the same transaction as your business database writes Full ACID coupling — the idempotency check and the business write commit or roll back together
Cost / ops Requires running and monitoring a separate Redis cluster (or managed service) Reuses infrastructure you likely already operate and back up

Durability is the sharpest distinguishing line. Redis’s AOF and RDB persistence mechanisms are good, but even appendfsync everysec accepts a 1-second loss window on crash — acceptable for a fast-path filter, not acceptable as the sole record of “did we charge this card.” PostgreSQL’s write-ahead log guarantees a committed row survives any crash, which is why it remains the system of record for unique-constraint-based deduplication.

Atomicity is comparable in mechanism but different in scope. Redis’s SET NX is atomic against Redis alone. PostgreSQL’s INSERT ... ON CONFLICT is atomic against Redis alone in the same sense, but critically it can sit inside a larger transaction alongside the business write — the same guarantee the transactional outbox pattern depends on.

Transactional coupling is where PostgreSQL wins decisively for anything touching money. If the idempotency record and the ledger update are separate stores, a crash between the two writes reintroduces the dual-write problem this whole site exists to eliminate — see the DynamoDB conditional-write pattern for how a single-store, non-relational alternative handles the same coupling requirement with ConditionExpression.


The hybrid pattern: Redis fast-path, PostgreSQL system of record

Most high-throughput payment systems run both: Redis rejects the overwhelming majority of duplicates in under a millisecond without touching the database, while PostgreSQL remains the durable, transactionally-coupled source of truth that Redis’s cache is built from.

Step 1 — Check Redis first

import redis

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

def fast_path_check(idempotency_key: str) -> str | None:
    """Returns cached response if this key was already handled, else None."""
    return r.get(f"idem:{idempotency_key}")

Step 2 — On a Redis miss, fall through to PostgreSQL inside the business transaction

@Transactional
public PaymentResult capturePayment(String idempotencyKey, PaymentRequest req) {
    int inserted = jdbcTemplate.update(
        "INSERT INTO idempotency_keys (key, response, expires_at) " +
        "VALUES (?, '{}'::jsonb, now() + interval '24 hours') " +
        "ON CONFLICT (key) DO NOTHING",
        idempotencyKey
    );
    if (inserted == 0) {
        // Another request already claimed this key inside Postgres —
        // fetch and return its stored response instead of reprocessing.
        return fetchExistingResult(idempotencyKey);
    }

    PaymentResult result = chargeCard(req);

    jdbcTemplate.update(
        "UPDATE idempotency_keys SET response = ?::jsonb WHERE key = ?",
        result.toJson(), idempotencyKey
    );
    return result;
}

Step 3 — Populate Redis after the PostgreSQL commit succeeds

func populateFastPath(ctx context.Context, rdb *redis.Client, key, responseJSON string) error {
    // Only cache after Postgres has durably committed the result.
    return rdb.Set(ctx, "idem:"+key, responseJSON, 24*time.Hour).Err()
}

Step 4 — Handle a Redis outage gracefully

If Redis is unreachable, fall through directly to the PostgreSQL path rather than failing the request — Redis is an optimization, not a dependency.

async function checkIdempotency(key) {
  try {
    const cached = await redisClient.get(`idem:${key}`);
    if (cached) return JSON.parse(cached);
  } catch (err) {
    // Redis down: log and fall through to Postgres, do not fail the request here.
    logger.warn({ err, key }, "redis fast-path unavailable, falling back to postgres");
  }
  return null; // caller proceeds to the Postgres-backed transactional path
}

Verification and testing

Confirm the Redis fast-path actually short-circuits the database

redis-cli SET idem:pay_hybrid_1 '{"status":"captured"}' EX 86400 NX
# Time a request carrying this key; a query log should show zero Postgres queries
tail -f /var/log/postgresql/postgresql.log &
curl -X POST /charge -H "Idempotency-Key: pay_hybrid_1" -d '{}'
# Expected: no matching query appears in the Postgres log

Confirm PostgreSQL still enforces atomicity when Redis is cold

INSERT INTO idempotency_keys (key, response, expires_at)
VALUES ('pay_hybrid_2', '{}'::jsonb, now() + interval '24 hours')
ON CONFLICT (key) DO NOTHING
RETURNING key;
-- Run twice concurrently in two psql sessions; only one should RETURN a row

Simulate a Redis outage and confirm graceful fallback

redis-cli SHUTDOWN NOSAVE
curl -X POST /charge -H "Idempotency-Key: pay_hybrid_3" -d '{}'
# Expected: request still succeeds via the Postgres path, latency rises but no errors

Failure scenarios and debugging

Failure Scenario Remediation Steps Observability Hooks
Redis and PostgreSQL disagree — Redis has a key that PostgreSQL rolled back Always populate Redis only after the PostgreSQL commit succeeds (Step 3), never before or in parallel; treat Redis strictly as a read-through cache of committed state. redis_postgres_state_mismatch_total (Counter, from periodic reconciliation scan); structured log field commit_then_cache_order
Redis outage removes the fast-path, all traffic falls through to PostgreSQL, latency spikes Confirm the fallback path (Step 4) does not fail closed; add a connection-pool size buffer sized for full fallback load; alert on Redis availability separately from request error rate. redis_fast_path_unavailable_total (Counter); postgres_connection_pool_saturation_ratio (Gauge, alert if > 0.8)
PostgreSQL ON CONFLICT check passes but the business write inside the same transaction fails, leaving a claimed key with no result Wrap both statements in the same transaction (as shown in Step 2) so a failed charge rolls back the idempotency claim too, freeing the key for a legitimate retry. idempotency_key_claimed_without_result_total (Gauge, alert if > 0 for > 60s); trace span capturePayment with rows_affected attribute
TTL mismatch — Redis key expires before the PostgreSQL row, causing a spurious re-check that hits the database more than expected Set Redis TTL equal to or shorter than the PostgreSQL expires_at window, never longer, so Redis never claims freshness the system of record has already discarded. idempotency_ttl_drift_seconds (Gauge, difference between Redis TTL and Postgres expires_at remaining)
High-cardinality key growth exhausts Redis memory before PostgreSQL’s slower sweep catches up Set maxmemory-policy volatile-ttl in Redis so eviction always targets soonest-to-expire keys, and confirm the TTL sizing matches actual key volume growth. redis_evicted_keys_total (Counter); redis_used_memory_bytes (Gauge)

SRE / observability checklist

  1. redis_fast_path_hit_ratio — Gauge tracking the fraction of requests resolved by Redis without touching PostgreSQL; a sudden drop indicates cache population is broken.
  2. redis_postgres_state_mismatch_total — Counter from a periodic reconciliation job comparing a sample of Redis keys against their PostgreSQL rows; should be 0.
  3. postgres_connection_pool_saturation_ratio — Gauge; alert at 0.8 since a Redis outage forces full fallback load onto this pool.
  4. idempotency_key_claimed_without_result_total — Gauge for rows claimed in PostgreSQL with no completed response after 60 seconds; a stuck-claim detector.
  5. idempotency_ttl_drift_seconds — Gauge comparing Redis TTL remaining against PostgreSQL expires_at remaining for the same key; large drift signals a configuration mismatch.
  6. Redis persistence lag — monitor rdb_last_save_time / AOF aof_last_write_status so a Redis crash’s data-loss window is known and bounded, not assumed.