Redis vs. Postgres: When You Need Both, and When Postgres Is Enough

Published on
August 10, 2026

Redis and Postgres are not competitors, and almost nobody chooses one instead of the other. The real question is whether the Redis in your stack has stopped earning its keep now that Postgres has UNLOGGED tables, SKIP LOCKED and LISTEN/NOTIFY. For a single-node service under moderate load, the honest answer is often yes: you can delete Redis, move the cache into the relational database you already run, and end up with a simpler system.

It stops being yes at a specific, predictable point. Several of the things that move that point only show up in Java, and they are the reason this comparison reads differently for a Spring team than it does for a Rails one.

Redis vs. Postgres: Where Postgres Genuinely Won

The "just use Postgres for everything" position is not a contrarian pose, and the features behind it are not new. UNLOGGED tables arrived in PostgreSQL 9.1 in 2011, SKIP LOCKED in 9.5 in 2016, and LISTEN/NOTIFY is older still. What changed is that enough teams tried them in anger to make the argument credible.

Caching: an UNLOGGED table skips the write-ahead log entirely, and with jsonb you get a serviceable key-value store inside the database you already operate. Queueing: SELECT ... FOR UPDATE SKIP LOCKED has a queue as its documented use case. Fanout: LISTEN/NOTIFY gives you publish/subscribe tied to transaction commit, which Redis pub/sub does not offer. Scheduling and vectors: pg_cron runs periodic jobs and pgvector stores embeddings, both as widely deployed extensions instead of core features.

The manual is careful about the queue case, and the caveat deserves quoting in full. Skipping locked rows "provides an inconsistent view of the data, so this is not suitable for general purpose work, but can be used to avoid lock contention with multiple consumers accessing a queue-like table." A qualified endorsement, but a real one.

Underneath the feature list sits a more durable argument that has nothing to do with performance. Every additional datastore is another thing to provision, monitor, back up, patch and fail over: another set of credentials, another page in the runbook. For a team of four that cost is not abstract. A team that removes Redis is rarely claiming Postgres is faster; they are claiming the second system was not worth its weight.

The most honest benchmark on the subject makes exactly that case. Writing at dizzy.zone in September 2025, the author put PostgreSQL 17.6 against Redis 8.2 under k6, measured Redis as faster on reads, on writes and on a mixed workload, and still concluded: "Well, I think I'll still use postgres."

Redis vs. Postgres: What the Benchmarks Actually Say

Numbers here need care: the genre has attracted a lot of low-effort content, including a 2026 post that promises a deep dive into PostgreSQL 18 but names no actual PG 18 feature and publishes no measurements. Two sources hold up.

Raphael De Lio, now a Redis developer advocate, measured read latency with pgbench in 2024. The dizzy.zone run quoted above measured throughput, and it is the more revealing because it recorded where each system ran out of room.

MeasurementRedisPostgres
Read latency (De Lio)0.095 ms p50~0.679 ms average, UNLOGGED
Throughput ceiling (dizzy.zone; 2 CPU, 8 GB, 30M rows)Never the bottleneck; the HTTP layer gave up first~7,400 req/s, both cores saturated

Both read figures are sub-millisecond, and they are not strictly comparable: a p50 for Redis against a pgbench average for Postgres, which says nothing about the tail. For the WAL cost specifically, figures De Lio cites from Greg Sabino Mullane put unlogged writes at 2.06 ms against 5.95 ms logged.

The throughput row is the one that matters, and in most write-ups it is relegated to a footnote. Postgres reached roughly 7,400 requests per second by spending both of its cores; Redis served the same load without becoming the constraint. The lesson is not that Postgres is fast enough, but that Postgres is fast enough while it has spare CPU. On a host doing nothing but cache lookups that is a fine trade. On a host also running your transactional workload it is a resource conflict you have chosen to accept.

Four Places Postgres Stops Working

1. UNLOGGED tables do not replicate

This ends the discussion for most production systems, and the documentation is flat about it: unlogged tables "are not crash-safe: an unlogged table is automatically truncated after a crash or unclean shutdown. The contents of an unlogged table are also not replicated to standby servers."

Two consequences, rarely stated together. First, the cache cannot be read from a replica. It exists only on the primary, so every cache read loads the one node you were trying to protect. Second, the cache is empty after any unclean shutdown. Not stale; truncated. Every request becomes a cache miss at once, at the moment the database is least able to cope. That is the thundering herd problem, generated by design. An in-memory store with RDB or AOF persistence comes back warm instead.

2. Your cache competes with your transactional workload

A cache inside Postgres shares everything with the queries it is meant to protect: the same buffer pool, CPUs, autovacuum budget and connection limit. Cache reads evict OLTP pages from shared buffers; cache writes consume the same I/O. A traffic spike degrades both at once, which is the opposite of what you want. Isolation is not an incidental property of a separate cache. It is most of the point.

3. Queue tables and autovacuum

Every enqueue, dequeue and acknowledgement is a WAL-logged transaction, and every UPDATE or DELETE under MVCC leaves a dead tuple behind. Autovacuum's defaults assume a table whose live row count is roughly stable: the threshold is the lesser of autovacuum_vacuum_max_threshold (new in PostgreSQL 18, default 100,000,000) and 50 tuples plus 20% of the live row count. A queue table under sustained load keeps very few rows, so that threshold sits near its 50-tuple floor and autovacuum runs almost continuously, still without keeping pace with the churn.

Plain VACUUM reclaims that space for reuse but returns it to the operating system only when free pages happen to sit at the end of the table and an exclusive lock is available. Autovacuum "will never issue VACUUM FULL", so that same table can hold far more disk than its live rows need, with bloated indexes alongside. This is a scaling cliff, not a correctness problem, and where it sits depends on your throughput.

Postgres also documents no per-row expiration. There is no TTL, so anything that should disappear on a schedule needs a sweeper you write and operate yourself, whereas Redis offers a per-key TTL as a primitive.

4. The connection model

PostgreSQL "starts ('forks') a new process for each connection", and max_connections defaults to 100. Every connection is an operating-system process with its own memory footprint, which is why a pooler is mandatory at any real scale. Valkey and Redis multiplex many clients over a single event loop instead. That difference is architectural, and it is where the Java-specific problems begin.

Where Redis vs. Postgres Changes for Java Teams

Nearly every article on this subject is written from Go, Node, Python or Rails. That matters, because Java's connection-pool discipline changes several of the answers, and they change in the same direction.

Advisory locks belong to a session you do not control

A session-level advisory lock, per the documentation, "is held until explicitly released or the session ends." In a runtime where each request opens a connection, works and closes it, that is straightforward. In a Java service it is not: your application never owns a session. HikariCP owns a set of long-lived physical connections and lends you one for a checkout.

Returning that connection to the pool does not end the session, so it does not release the lock. If an exception skips your unlock, the lock survives the request and the next thread to borrow that connection inherits a session already holding it. The lock outlives the work it protected, and nothing in your logs says so.

Adding PgBouncer changes the failure instead of fixing it. In session pooling its server_reset_query defaults to DISCARD ALL, described in the documentation as having "the same effect as executing the following sequence of statements", a sequence that includes SELECT pg_advisory_unlock_all(). That runs when the client disconnects, not when HikariCP hands the connection back. In transaction pooling the reset query is not used at all, and consecutive transactions are not guaranteed to reach the same backend, which makes session-level advisory locks unsafe outright: your lock and your unlock can land on different sessions.

None of this is a bug. It is what happens when a session-scoped primitive meets an infrastructure layer whose job is recycling sessions. pg_advisory_xact_lock avoids the whole class of problem by releasing at transaction end, at the cost of not being able to hold a lock across transaction boundaries. A distributed lock in Redis has no such coupling, because ownership is a key with an expiry instead of a property of a connection.

// Postgres: the lock belongs to the session, and the pool owns the session.
try (Connection conn = dataSource.getConnection()) {
    try (PreparedStatement ps = conn.prepareStatement("SELECT pg_advisory_lock(?)")) {
        ps.setLong(1, orderId);
        ps.execute();
    }
    try {
        processOrder(orderId);
    } finally {
        // Without this finally block, an exception leaves the lock held on a
        // connection that goes straight back into the pool for the next thread.
        try (PreparedStatement ps = conn.prepareStatement("SELECT pg_advisory_unlock(?)")) {
            ps.setLong(1, orderId);
            ps.execute();
        }
    }
}

// Redisson: the lock is a key with a TTL, renewed by a watchdog.
// No connection is held, and no pooling mode changes the semantics.
RLock lock = redisson.getLock("order:" + orderId);
lock.lock();
try {
    processOrder(orderId);
} finally {
    lock.unlock();
}

Redisson renews the lock in the background while the owning JVM is alive. The watchdog defaults to 30 seconds and is configurable through Config.lockWatchdogTimeout, so when a process crashes, renewal stops and the lock expires on its own.

Neither lock is safe without a fencing token, and only one offers one

Lease-based locks, Redis included, carry a well-known hazard: a client stalled by a long garbage-collection pause can lose its lease and resume believing it still holds the lock. Advisory locks have no lease, so they avoid that failure, but a session can still be terminated by a failover or a network drop while your application believes the lock is held. Either way the protected resource cannot tell a current holder from a stale one.

The standard remedy is a fencing token, a number that increases on every acquisition and lets the resource reject writes from a lapsed holder. Redisson implements it in RFencedLock, which "maintains the fencing token to avoid cases when Client acquired the lock was delayed due to long GC pause or other reason and can't detect that it doesn't own the lock anymore", and it is in the open-source edition. Postgres has no equivalent.

If your lock exists for politeness, stopping two workers duplicating effort, advisory locks are fine. If it exists for correctness, this is the strongest single argument in the comparison. See fenced locks and the Redlock algorithm.

LISTEN/NOTIFY versus a connection pool

LISTEN/NOTIFY collides with pooling more directly still, because a listener must hold a session open indefinitely, which is precisely what a pool exists to prevent. Four further constraints are worth knowing before designing around it:

  • The payload is small. "In the default configuration it must be shorter than 8000 bytes." Anything larger becomes an identifier the receiver reads back, a second query against the database you were relieving.
  • It does not run on a standby. LISTEN and NOTIFY are among the operations that raise errors on a hot standby, so replicas cannot receive notifications.
  • It is incompatible with two-phase commit. "A transaction that has executed NOTIFY cannot be prepared for two-phase commit." That rules NOTIFY out of a distributed transaction and pushes you toward an outbox table instead.
  • A stalled listener can break unrelated writes. Notifications queue until every listening session consumes them, and "if this queue becomes full, transactions calling NOTIFY will fail at commit." The docs fairly call it "quite large (8GB in a standard installation)"; the point is that one dead listener will eventually fill it, and the failure lands on commits that have nothing to do with it.

Valkey and Redis pub/sub is not durable either, and Redisson says so plainly: with RTopic, "all messages sent during absence of connection are lost." Both are at-most-once. The difference is operational: a subscriber consumes an event-loop slot instead of a backend process, so subscriber count does not draw down the connection budget your transactional queries depend on. Where durability matters, Redis Streams add persistence, acknowledgements and a pending-entries list, and Redisson PRO's Reliable Queue layers visibility timeouts, delivery limits and a dead-letter queue on top.

Cache invalidation across JVMs

Java applications keep in-process caches: Caffeine, a Hibernate second-level cache, a plain ConcurrentHashMap. Run four instances behind a load balancer and each holds its own copy, so each must be told when an entry changes.

Postgres can push that signal; LISTEN/NOTIFY is the right shape for it. The cost is the one already described: every JVM pins a backend process for the life of the application, none of this works against a replica, and you write the invalidation protocol yourself. Redisson's local cache, RLocalCachedMap, is described in its documentation as a "near cache" that "executes read operations up to 45x faster", and it broadcasts invalidations over pub/sub without consuming a database connection. See distributed caching in Java and the cache-aside and write-behind strategies.

So Do You Need Redis? A Decision Rule

You can skip Redis if you run a single Postgres instance with no read replicas; your cached reads are in the low thousands per second with CPU headroom on the database host; you have no in-process caches needing cross-JVM invalidation; your queue depth is thousands instead of millions; and no SLA commits you to a sub-millisecond p99. Most internal tools and nearly every early-stage product fit that description, and adding Redis buys very little.

You need Valkey or Redis when multiple JVMs must agree on state; you need distributed locks for correctness instead of politeness; your cache must survive a restart or be readable from replicas; you are broadcasting to many subscribers; or you need something the relational database does badly under sustained load. Rate limiting, leaderboards over sorted sets and a shared session store are all buildable in Postgres. They simply push high-churn traffic at the one component you most want to keep quiet.

The middle position is where almost every team lands, and it is not a compromise: Postgres is the system of record, Valkey or Redis is the hot path. ACID guarantees, constraints, joins and transactions live in the relational database; cached reads, locks, queues and fanout live in Redis, where none of them contend with your OLTP workload. The read path between them is the cache-aside pattern, and the division is popular because it isolates the two failure domains you most want kept apart.

The inversion, whether Redis can be the system of record and Postgres dropped instead, has a different answer. We cover it in using Valkey or Redis as a primary database in Java.

Frequently Asked Questions

Is Redis faster than Postgres?

Yes, by less than most people expect. Published measurements put Redis reads at 0.095 ms p50 against roughly 0.679 ms average for an UNLOGGED Postgres table, both sub-millisecond. The more useful difference is headroom: on two-CPU nodes, Postgres saturated both cores near 7,400 requests per second while Redis never became the bottleneck.

Can Postgres replace Redis as a cache?

On a single node, usually yes: an UNLOGGED table with jsonb is a workable key-value cache and removes a dependency. It stops working once you run read replicas, because the documentation states that the contents of an unlogged table "are also not replicated to standby servers", so the cache exists only on the primary. Unlogged tables are also truncated after any unclean shutdown, so the cache returns empty.

Is Redis a database?

Redis is an in-memory data store that can be configured for durability through RDB snapshots and AOF logging, and it can serve as a primary database. What it is not is a general-purpose relational database: there are no joins, no foreign-key constraints and no SQL query planner. We cover the trade-offs in using Valkey or Redis as a primary database in Java.

What are the disadvantages of Redis?

It is another system to operate, monitor and fail over, which is the main cost and the strongest reason not to adopt it. Memory costs more than disk, so large datasets are expensive to hold. There are no joins, so access patterns must be designed in advance. Standard pub/sub is at-most-once, and durability depends on a persistence configuration you have to get right.

Can I use Postgres SKIP LOCKED instead of a Redis queue?

At modest volume, yes. Postgres documents SKIP LOCKED for exactly this case, a queue-like table with multiple consumers, while warning that it gives an inconsistent view of the data. The constraint is churn: every enqueue and dequeue leaves dead tuples behind, and autovacuum never issues VACUUM FULL, so disk use on a busy queue table drifts steadily away from its live row count.

Do Postgres advisory locks work with a connection pool?

Session-level advisory locks are held "until explicitly released or the session ends", and with HikariCP your application never owns the session. Returning the connection does not release the lock, so a missed unlock leaks it to the next borrower. Under PgBouncer's transaction pooling the reset query is not used and consecutive transactions may reach different backends, which makes session-level advisory locks unsafe. Use pg_advisory_xact_lock instead.

Should I use Redis and Postgres together?

Most production systems do, and the failure that avoids is specific: when the cache lives inside the relational database, a traffic spike degrades cached reads and transactional queries together, because they share a buffer pool, CPUs and a connection limit. Splitting them keeps a cache problem a cache problem. The cost is a second system to operate.

For implementation details, see how to use Redis locks in Java, Spring Boot caching with Valkey and Redis, and Reliable Queue for Valkey and Redis. To see which capabilities are in the open-source edition and which are not, compare Redisson and Redisson PRO.