PostgreSQL and Redis: A Systems Design Approach to Combining Relational and In-Memory Stores
Your PostgreSQL queries are fast enough—until they aren’t. You add Redis for caching, and suddenly you’re debugging stale data at 3 AM because your cache invalidation strategy was “hope for the best.” The real challenge isn’t choosing between PostgreSQL and Redis; it’s designing systems that use both effectively.
This scenario plays out constantly in production systems. A feature starts simple: read from Postgres, return the data. Then traffic scales. You add an index, optimize the query, maybe add a read replica. Eventually you hit a wall where even optimized queries can’t deliver the sub-10ms response times your application needs. So you introduce Redis—a cache layer that promises to solve everything.
What follows is rarely smooth. Your cache gets out of sync with the database. You implement cache invalidation, but now you’re dealing with race conditions. Session data lives in Redis, but some features need transactional guarantees from Postgres. You’re storing JSON in both systems, but with different schemas. The codebase becomes littered with cache-warming scripts, invalidation hooks, and background jobs trying to keep two data stores aligned.
The problem isn’t that you chose the wrong tools. PostgreSQL and Redis are both excellent at what they do. The problem is that combining them requires understanding not just their individual capabilities, but their fundamental architectural differences—and designing around the tension between them. You’re no longer just writing queries; you’re architecting distributed state across two systems with incompatible guarantees.
The path forward starts with understanding why these tradeoffs exist in the first place.
Understanding the Fundamental Tradeoffs: ACID vs Speed
When architecting a system that combines PostgreSQL and Redis, you’re fundamentally choosing which data gets which guarantees. PostgreSQL prioritizes correctness through ACID transactions, while Redis optimizes for raw speed. Understanding this tradeoff shapes every architectural decision that follows.

PostgreSQL’s Durability Model
PostgreSQL writes data to disk and uses write-ahead logging (WAL) to guarantee that committed transactions survive crashes. When you execute a transaction, PostgreSQL ensures atomicity (all-or-nothing execution), consistency (enforcing constraints), isolation (concurrent transactions don’t interfere), and durability (commits persist). This comes at a cost: disk I/O. Even with SSD storage, a typical PostgreSQL read query takes 1-5 milliseconds, and writes take longer due to fsync operations that flush data to physical storage.
This disk-based architecture also enables PostgreSQL’s rich feature set: complex joins, foreign keys, check constraints, and robust indexing strategies. You’re trading milliseconds for correctness guarantees that prevent data corruption, maintain referential integrity, and support complex transactional workflows.
Redis’s In-Memory Architecture
Redis stores data entirely in RAM, enabling sub-millisecond latency—typically 50-200 microseconds for simple GET operations. This 10-100x performance advantage makes Redis ideal for high-throughput scenarios where you’re serving thousands of reads per second from a single instance.
The tradeoff: Redis offers weaker durability guarantees. Even with persistence enabled (RDB snapshots or AOF logging), you accept the possibility of losing seconds or minutes of recent writes during crashes. Redis also lacks the relational model—no joins, no foreign keys, no multi-table transactions. You’re trading structure and guarantees for speed.
When the Performance Gap Matters
The sub-millisecond distinction matters when you’re building real-time features: leaderboards that update every second, session stores handling 10,000+ requests/second, or rate limiters that must respond in microseconds. For a typical web application serving user profiles, the difference between 200 microseconds and 2 milliseconds is invisible to end users.
Evaluate your actual access patterns. If you’re reading the same user session data 50 times per page load, Redis’s speed compounds. If you’re reading invoice data once per request, PostgreSQL’s latency is negligible compared to network overhead and application logic.
The Cost Dimension
Memory costs 10-20x more than SSD storage per gigabyte. A 100GB PostgreSQL database on disk might cost $10/month, while 100GB of Redis memory runs $200-300/month on managed services. This economic reality means you can’t afford to keep everything in Redis—which forces the architectural question: what data justifies the premium for speed?
The answer sets the foundation for cache-aside patterns, where PostgreSQL remains the source of truth while Redis serves as a performance multiplier for hot data.
Cache-Aside Pattern: The Right Way to Layer Redis Over PostgreSQL
Cache-aside (also called lazy loading) is the foundational pattern for combining PostgreSQL and Redis: your application checks Redis first, falls back to PostgreSQL on a miss, then populates the cache. Despite its simplicity, production implementations fail when they don’t handle race conditions, thundering herds, and partial failures.
The pattern’s appeal lies in its straightforward logic: read from cache, miss, query database, populate cache. But this simplicity masks critical implementation details. A naive cache-aside implementation without proper error handling will turn your performance optimization into a reliability liability. When Redis goes down, should your application stop serving requests? Absolutely not—your cache layer should degrade gracefully, not cascade failures.
The Basic Implementation
Here’s a production-ready cache-aside implementation that handles the critical edge cases:
import redisimport psycopg2import jsonfrom typing import Optionalfrom contextlib import contextmanager
class CacheAsideStore: def __init__(self, redis_client: redis.Redis, pg_conn): self.redis = redis_client self.pg = pg_conn
def get_user(self, user_id: int) -> Optional[dict]: cache_key = f"user:{user_id}"
# Check cache first try: cached = self.redis.get(cache_key) if cached: return json.loads(cached) except redis.RedisError as e: # Log but don't fail - degrade gracefully to database print(f"Redis error: {e}")
# Cache miss - query database with self.pg.cursor() as cur: cur.execute( "SELECT id, name, email, created_at FROM users WHERE id = %s", (user_id,) ) row = cur.fetchone()
if not row: return None
user = { "id": row[0], "name": row[1], "email": row[2], "created_at": row[3].isoformat() }
# Populate cache with TTL try: self.redis.setex( cache_key, 3600, # 1 hour TTL json.dumps(user) ) except redis.RedisError: # Cache population failure shouldn't block the request pass
return userThe critical design decision here: Redis failures degrade gracefully to PostgreSQL. Your cache should accelerate reads, not introduce new failure modes. If Redis is down, requests get slower but they don’t fail. This requires wrapping both the cache read and cache write in separate try-except blocks. Many implementations only protect the read, then fail loudly when cache population fails—don’t make that mistake.
Notice that database queries return None for missing records without caching that negative result. Caching non-existent keys invites cache pollution from sequential ID scanning attacks. If you need to cache negative results to prevent database hammering from invalid lookups, use a short TTL (30-60 seconds) and a sentinel value.
TTL Strategies Based on Data Characteristics
Not all data deserves the same cache duration. Match your TTL to your staleness tolerance and update frequency. A user profile that changes monthly doesn’t need the same refresh rate as inventory counts that fluctuate every minute.
class TTLStrategy: # User profiles - infrequent updates, high read volume USER_PROFILE_TTL = 3600 # 1 hour
# Product inventory - needs freshness, moderate staleness acceptable INVENTORY_TTL = 300 # 5 minutes
# Price data - critical accuracy, very short TTL PRICE_TTL = 60 # 1 minute
# Static content - rarely changes STATIC_CONTENT_TTL = 86400 # 24 hours
@staticmethod def get_product(redis_client, pg_conn, product_id: int): cache_key = f"product:{product_id}"
cached = redis_client.get(cache_key) if cached: return json.loads(cached)
# Fetch from database with pg_conn.cursor() as cur: cur.execute( "SELECT id, name, price, inventory_count FROM products WHERE id = %s", (product_id,) ) row = cur.fetchone() product = { "id": row[0], "name": row[1], "price": float(row[2]), "inventory": row[3] }
redis_client.setex( cache_key, TTLStrategy.INVENTORY_TTL, json.dumps(product) )
return productThe TTL you choose creates a direct trade-off: longer TTLs reduce database load but increase staleness windows; shorter TTLs keep data fresh but increase cache miss rates. For product inventory, a 5-minute TTL means your displayed stock counts could lag reality by 5 minutes. That’s acceptable for most e-commerce scenarios, but unacceptable for concert tickets or limited drops where milliseconds matter. Know your business requirements and encode them in your TTL strategy.
Preventing Thundering Herd with Probabilistic Early Expiration
When a hot cache key expires, hundreds of concurrent requests can simultaneously query PostgreSQL. This thundering herd problem turns your cache into a liability: the moment a popular key expires, your database gets slammed with identical queries. The solution is probabilistic early expiration—randomly refresh the cache before TTL expires based on how close to expiration you are.
import randomimport time
def get_with_early_expiration(redis_client, pg_conn, key: str, fetch_fn, ttl: int, beta: float = 1.0): """ Implements probabilistic early expiration to prevent thundering herd. beta controls aggressiveness: higher = more eager refreshing """ cache_data = redis_client.get(key)
if cache_data: data = json.loads(cache_data)
# Check if we should probabilistically refresh ttl_remaining = redis_client.ttl(key) delta = ttl - ttl_remaining
# XFetch formula: probability increases as expiration approaches if delta * beta * random.random() < ttl_remaining: return data
# Either cache miss or probabilistic refresh triggered fresh_data = fetch_fn()
try: redis_client.setex(key, ttl, json.dumps(fresh_data)) except redis.RedisError: pass
return fresh_dataThe XFetch algorithm works by calculating the probability of refresh based on how much time has elapsed since the key was set. When a key is fresh (delta is small), the probability of refreshing early is near zero. As the key ages and approaches expiration (delta approaches ttl), the probability increases. This spreads refreshes over time instead of creating a stampede at the exact TTL boundary.
The beta parameter controls refresh aggressiveness. Set beta to 1.0 for standard behavior. Increase to 2.0 for extremely hot keys where you want even more aggressive early refreshing. Decrease to 0.5 if you can tolerate occasional thundering herds in exchange for fewer database queries.
💡 Pro Tip: For extremely hot keys (thousands of requests per second), combine probabilistic early expiration with request coalescing—use a short-lived lock to ensure only one request refreshes the cache while others wait for the result.
Cache Warming vs Lazy Loading
Lazy loading works for most use cases, but cache warming makes sense when you can predict access patterns. Warm your cache during deployment or off-peak hours for homepage data, featured products, or other high-traffic keys:
def warm_top_products(redis_client, pg_conn): """Run this during deployment or via scheduled job""" with pg_conn.cursor() as cur: cur.execute(""" SELECT id, name, price, inventory_count FROM products WHERE featured = true ORDER BY view_count DESC LIMIT 100 """)
pipeline = redis_client.pipeline() for row in cur.fetchall(): product = { "id": row[0], "name": row[1], "price": float(row[2]), "inventory": row[3] } pipeline.setex( f"product:{row[0]}", 3600, json.dumps(product) ) pipeline.execute()Cache warming prevents the cold start problem: when you deploy new application servers or when Redis restarts, the first user requests after startup trigger cache misses and database queries. For high-traffic applications, this creates a brief spike in database load that can cascade into timeouts. Warming mitigates this by preloading predictable keys.
The trade-off: cache warming consumes memory for data that might not get accessed, and it adds complexity to your deployment process. Use it selectively for keys you know will be hit immediately—homepage carousels, navigation menus, top products. Don’t warm your entire dataset; that defeats the purpose of lazy loading.
Cache-aside gets you 80% of the performance benefit with minimal complexity. But it introduces a new problem: when data changes in PostgreSQL, your cache holds stale values until TTL expires. The next section covers cache invalidation strategies that maintain consistency without sacrificing performance.
Cache Invalidation Strategies That Actually Work
Phil Karlton famously said there are only two hard things in computer science: cache invalidation and naming things. When you’re running PostgreSQL and Redis in production, cache invalidation isn’t just hard—it’s the difference between serving stale data to users and maintaining consistency at scale.
Write-Through vs Write-Behind: Choosing Your Consistency Model
Write-through caching updates both PostgreSQL and Redis synchronously on every write. The application waits for both operations to complete before returning success:
def update_user_profile(user_id: int, profile_data: dict): # Update PostgreSQL first with db.cursor() as cursor: cursor.execute( "UPDATE users SET name = %s, email = %s WHERE id = %s", (profile_data['name'], profile_data['email'], user_id) ) db.commit()
# Immediately update Redis cache_key = f"user:{user_id}" redis_client.hset(cache_key, mapping=profile_data) redis_client.expire(cache_key, 3600)Write-through guarantees consistency but adds latency. Every write becomes a distributed transaction across two systems, and if Redis is slow or unavailable, your entire write operation suffers. In practice, this means P99 latencies can spike unpredictably based on Redis performance, even though PostgreSQL handled the write quickly.
Write-behind (lazy invalidation) takes the opposite approach—invalidate the cache key and let the next read repopulate it:
def update_user_profile(user_id: int, profile_data: dict): with db.cursor() as cursor: cursor.execute( "UPDATE users SET name = %s, email = %s WHERE id = %s", (profile_data['name'], profile_data['email'], user_id) ) db.commit()
# Delete from cache, forcing next read to hit PostgreSQL redis_client.delete(f"user:{user_id}")Write-behind reduces write latency but introduces a window where cache misses spike. The first read after invalidation hits PostgreSQL, which can cause latency spikes if many keys are invalidated simultaneously. For most applications, write-behind with cache-aside reads provides the best balance—writes stay fast, and you only pay the PostgreSQL cost on cache misses.
The critical implementation detail: always write to PostgreSQL first, then invalidate Redis. Reversing this order creates a race where a read could repopulate the cache with stale data between your invalidation and database write. If Redis invalidation fails, log the error but let the operation succeed—TTLs will eventually expire the stale data, which is better than failing writes.
Event-Driven Invalidation with PostgreSQL LISTEN/NOTIFY
PostgreSQL’s LISTEN/NOTIFY mechanism enables event-driven cache invalidation without polling. Set up a trigger that publishes invalidation events whenever data changes:
CREATE OR REPLACE FUNCTION notify_cache_invalidation()RETURNS trigger AS $$BEGIN PERFORM pg_notify( 'cache_invalidation', json_build_object( 'table', TG_TABLE_NAME, 'id', NEW.id, 'operation', TG_OP )::text ); RETURN NEW;END;$$ LANGUAGE plpgsql;
CREATE TRIGGER user_cache_invalidationAFTER INSERT OR UPDATE OR DELETE ON usersFOR EACH ROW EXECUTE FUNCTION notify_cache_invalidation();Your application listens for these events and invalidates Redis keys accordingly:
import psycopg2import json
def listen_for_invalidations(): conn = psycopg2.connect("dbname=myapp user=postgres") conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
cursor = conn.cursor() cursor.execute("LISTEN cache_invalidation;")
while True: conn.poll() while conn.notifies: notify = conn.notifies.pop(0) payload = json.loads(notify.payload)
cache_key = f"{payload['table']}:{payload['id']}" redis_client.delete(cache_key)This decouples cache invalidation from application code and ensures consistency even when writes bypass your application layer—migrations, admin tools, batch jobs, and direct database access all trigger the same invalidation logic. The downside is operational complexity: you need a dedicated process listening to PostgreSQL notifications, and you need to handle reconnections when that process crashes or the database restarts.
LISTEN/NOTIFY also has scaling limitations. Notifications aren’t replicated to read replicas, so your listener must connect to the primary. If you’re running multiple application instances, each one receives every notification, which can amplify invalidation load on Redis. For high-write workloads, consider using a message queue like RabbitMQ or Kafka to distribute invalidation work across workers.
Tag-Based Invalidation for Related Data
Single-key invalidation breaks down when data has dependencies. A user’s posts, comments, and profile all need invalidation when their account is deleted. Redis Sets enable tag-based invalidation:
def cache_user_data(user_id: int, data: dict): cache_key = f"user:{user_id}" tag_key = f"tags:user:{user_id}"
# Store the data redis_client.hset(cache_key, mapping=data)
# Track all keys related to this user redis_client.sadd(tag_key, cache_key) redis_client.sadd(tag_key, f"posts:user:{user_id}") redis_client.sadd(tag_key, f"comments:user:{user_id}")
def invalidate_user_data(user_id: int): tag_key = f"tags:user:{user_id}"
# Get all related keys related_keys = redis_client.smembers(tag_key)
# Delete them atomically if related_keys: redis_client.delete(*related_keys, tag_key)Tag-based invalidation scales to complex dependency graphs. You can tag keys with multiple categories—by user, by organization, by feature—and invalidate entire groups atomically. The memory overhead is typically negligible: a tag set with 100 members costs about 5KB, and you avoid the correctness issues that come from manually tracking dependencies in application code.
💡 Pro Tip: Use pipelining when invalidating multiple keys to reduce round-trips. Redis atomic operations like
MULTI/EXECprevent partial invalidations that leave the cache in an inconsistent state.
Handling Race Conditions in Production
The race condition between database writes and cache updates remains your biggest challenge. Even with write-behind invalidation, you can hit edge cases where concurrent operations create inconsistency:
-
Read-After-Write Race: Thread A writes to PostgreSQL and invalidates the cache. Before the invalidation completes, Thread B reads the old value from cache and serves stale data.
-
Double-Write Race: Thread A and Thread B both update the same record. Thread A writes to PostgreSQL first but Thread B’s cache update completes first, leaving Thread A’s stale data in cache.
Defensive strategies include setting aggressive TTLs (5-15 minutes) as a safety net, using versioning or timestamps in cached values to detect staleness, and implementing read-your-writes consistency by checking cache timestamps against the current transaction. For critical data, consider not caching it at all—PostgreSQL with proper indexing can serve reads in single-digit milliseconds, making caching unnecessary for many use cases.
The hardest truth about cache invalidation: perfect consistency is impossible in distributed systems. Your goal isn’t to eliminate all staleness—it’s to bound how stale data can become and ensure eventual consistency. Set TTLs, monitor cache hit rates and staleness metrics, and design your system to degrade gracefully when the cache lies.
Session Storage and Ephemeral Data: Playing to Redis’s Strengths
Redis excels at storing ephemeral data—information that’s valuable in the moment but doesn’t need the durability guarantees of a relational database. Session storage is the canonical example: a user’s authentication token and shopping cart need millisecond access times and automatic expiration, but reconstructing them from a login or abandoned cart table is acceptable if Redis fails.
Session Management: The Textbook Redis Use Case
Session data has three characteristics that make PostgreSQL a poor fit: high read/write volume per user, automatic expiration requirements, and tolerance for data loss. Storing sessions in PostgreSQL creates unnecessary load on your primary datastore and complicates TTL management.
import redisimport jsonfrom datetime import timedelta
class SessionManager: def __init__(self, redis_client): self.redis = redis_client
def create_session(self, user_id, session_data): session_id = f"session:{user_id}:{generate_token()}" self.redis.setex( session_id, timedelta(hours=24), json.dumps(session_data) ) return session_id
def get_session(self, session_id): data = self.redis.get(session_id) if data: # Sliding expiration: reset TTL on access self.redis.expire(session_id, timedelta(hours=24)) return json.loads(data) return None
def extend_session(self, session_id, additional_data): # Atomic update using pipeline pipe = self.redis.pipeline() pipe.get(session_id) pipe.expire(session_id, 0) # Check existence current_data, ttl = pipe.execute()
if current_data: merged = {**json.loads(current_data), **additional_data} self.redis.setex(session_id, timedelta(hours=24), json.dumps(merged))Real-Time Features with Native Data Structures
Redis’s specialized data structures enable real-time features that would be prohibitively expensive in PostgreSQL. Sorted sets handle leaderboards with sub-millisecond updates, while counters enable rate limiting without database locks.
class Leaderboard: def __init__(self, redis_client): self.redis = redis_client
def update_score(self, game_id, user_id, score): key = f"leaderboard:{game_id}" self.redis.zadd(key, {user_id: score}) self.redis.expire(key, timedelta(days=30))
def get_top_players(self, game_id, limit=10): key = f"leaderboard:{game_id}" # Returns [(user_id, score), ...] in descending order return self.redis.zrevrange(key, 0, limit-1, withscores=True)
def get_user_rank(self, game_id, user_id): key = f"leaderboard:{game_id}" rank = self.redis.zrevrank(key, user_id) return rank + 1 if rank is not None else None
class RateLimiter: def check_rate_limit(self, user_id, max_requests=100, window_seconds=60): key = f"ratelimit:{user_id}" pipe = self.redis.pipeline() pipe.incr(key) pipe.expire(key, window_seconds) current_count, _ = pipe.execute() return current_count <= max_requestsWhen to Persist Redis Data
Not all Redis data is disposable. Leaderboards, user preferences, and feature flags often need durability. The pattern here isn’t either/or—use Redis for read-heavy access patterns and periodically snapshot to PostgreSQL for disaster recovery.
A background job writing sorted set data to PostgreSQL every 5 minutes gives you Redis performance with PostgreSQL durability. The Redis data remains the source of truth for reads, while PostgreSQL serves as a recovery mechanism and enables historical analysis.
💡 Pro Tip: Use Redis pub/sub to trigger PostgreSQL writes only when data changes, rather than polling. Subscribe to keyspace notifications for specific key patterns to capture updates without application-level coordination.
This hybrid approach acknowledges that different data has different durability requirements. The next section examines how to systematically decide what lives where based on access patterns and consistency needs.
Data Modeling Decisions: What Lives Where
The most common mistake in hybrid PostgreSQL-Redis architectures is treating the data store choice as binary. In reality, the same logical entity often spans both systems, with different aspects living in each store based on access patterns and consistency requirements.

User Profiles: Split by Mutability and Access Frequency
User profile data demonstrates this split clearly. Core attributes—email, password hash, registration date—belong in PostgreSQL. These change rarely, require transactional guarantees, and need relational integrity with other tables. Meanwhile, session tokens, active device fingerprints, and temporary preferences live in Redis with TTLs. The session data changes frequently, needs millisecond reads on every request, and carries no durability requirement beyond the session lifetime.
The key insight: PostgreSQL stores the source of truth while Redis stores the working state. When a user logs in, you validate credentials against PostgreSQL once, then maintain session state in Redis for thousands of subsequent requests.
Analytics: Separation by Aggregation Level
Raw event data flows into PostgreSQL where you can query arbitrary time ranges, join with user tables, and maintain compliance logs. But real-time dashboards querying “requests in the last 5 minutes by endpoint” hit Redis sorted sets updated via stream processing. The aggregates refresh continuously with short TTLs, making stale data acceptable while providing sub-millisecond reads.
This pattern extends to leaderboards, trending content, and rate limiting counters—anywhere pre-computed aggregates serve most queries while the full dataset enables deep analysis.
Write Buffering: Redis as a Staging Layer
High-throughput ingestion systems use Redis lists or streams as write buffers. Application servers push events to Redis instantly, then background workers batch-insert to PostgreSQL every few seconds. This decouples write spikes from database load and allows you to apply transformations, deduplication, or filtering before persistence.
The tradeoff is explicit: you accept potential data loss during the buffering window in exchange for handling 10x higher write rates.
Anti-Patterns to Avoid
Never store data in Redis that requires relational joins or complex querying—you’ll end up rebuilding a query engine in application code. Similarly, caching entire tables or “just in case” datasets wastes memory on data that’s rarely accessed. Cache based on observed access patterns, not theoretical ones.
The decision framework reduces to: PostgreSQL for data you query flexibly or can’t afford to lose; Redis for data you access predictably and can regenerate. Understanding these operational considerations makes the difference between an architecture that scales and one that collapses under load.
Operational Considerations: Monitoring and Failure Modes
Running PostgreSQL and Redis together requires observability into both systems and architectural decisions about how to handle failures. The most common operational mistake is treating Redis as mission-critical infrastructure when it should degrade gracefully.
Monitoring What Matters
Track Redis cache hit rates as your primary health metric. A well-tuned cache should maintain 85-95% hit rates for steady-state traffic. Sharp drops indicate either traffic pattern changes or insufficient memory allocation. Monitor keyspace_hits and keyspace_misses in Redis INFO stats, and calculate hit rate as hits / (hits + misses). Set alerts when hit rates drop below your baseline by more than 10%.
Eviction metrics tell you when Redis runs out of memory. The evicted_keys counter increments whenever Redis evicts data under memory pressure. If you’re seeing evictions on LRU-cached data, that’s expected behavior. If you’re seeing evictions on session data with TTLs, you’ve undersized your Redis instance.
PostgreSQL metrics shift when Redis sits in front. Connection pool utilization should drop significantly because Redis absorbs read traffic. If you’re still seeing connection pool saturation after implementing caching, you’re either missing cache opportunities or experiencing cache storms from poor invalidation patterns.
💡 Pro Tip: Create a composite SLO that measures latency at the 99th percentile with Redis operational and at the 95th percentile with Redis down. This forces you to design for degraded performance rather than complete failure.
Designing for Redis Outages
Your application should treat Redis failures as performance degradation, not total outages. Implement circuit breakers that fall back to direct PostgreSQL queries when Redis becomes unavailable. A simple pattern: track consecutive Redis failures, and after three failures within 10 seconds, stop attempting cache operations for 30 seconds.
The worst failure mode is cascading collapse. When Redis goes down, all traffic hits PostgreSQL simultaneously. This creates a thundering herd that overwhelms your database connections and increases query latency, often triggering application timeouts that cause retries, worsening the problem. Combat this with request coalescing—when multiple requests need the same uncached data, only one request hits PostgreSQL while others wait for that result.
Connection Pooling Adjustments
With Redis handling reads, PostgreSQL connection pools need reconfiguration. If Redis serves 80% of reads, your database can operate with 40-60% of its original connection pool size. This leaves headroom for handling cache misses and degraded mode. Use PgBouncer or a similar pooler in transaction mode to maximize connection reuse during high cache-miss periods.
Backup Strategy Alignment
PostgreSQL remains your source of truth and requires point-in-time recovery capability. Redis backups depend on what you’re storing. For pure caches, skip Redis backups entirely—repopulating from PostgreSQL is acceptable. For session data or rate limiting counters, use Redis persistence (RDB snapshots or AOF) with replication to a standby node. Schedule Redis backups during low-traffic windows since BGSAVE consumes memory and I/O.
With monitoring and failure handling in place, the question becomes: do you always need both systems running together?
When to Choose One Over the Other (and When to Use Both)
The decision between PostgreSQL, Redis, or both comes down to three factors: your consistency requirements, query complexity, and acceptable data loss window.
PostgreSQL-Only Architectures
Choose PostgreSQL as your sole datastore when you need ACID guarantees across all operations and complex relational queries. Financial systems, inventory management, and order processing all fall into this category—any scenario where losing a transaction means losing money or trust.
PostgreSQL handles caching internally through its shared buffers and query planner statistics. For read-heavy workloads under 10,000 QPS, a properly tuned PostgreSQL instance with connection pooling (PgBouncer) and read replicas often outperforms a poorly implemented Redis layer. The operational simplicity of managing one database typically outweighs marginal performance gains until you hit real scale constraints.
The breaking point arrives when query latency degrades user experience despite optimization, or when read replica lag causes consistency issues in your application logic.
Redis-Only Architectures
Redis works as a primary datastore when you can accept eventual consistency and data loss in exchange for microsecond latency. Real-time leaderboards, rate limiting counters, and pub/sub messaging systems fit this model. If your data can be regenerated from other sources or represents transient state, Redis persistence (RDB snapshots plus AOF) provides sufficient durability for most use cases.
The limitation surfaces when you need complex filtering, aggregations, or joins across datasets. Redis’s data structures optimize for key-based access patterns, not ad-hoc analytical queries. Once you start maintaining denormalized copies of the same data across multiple Redis keys to support different query patterns, you’ve outgrown a Redis-only approach.
Hybrid Architecture Patterns
The most resilient production systems use both databases for different data tiers. PostgreSQL stores your system of record—the authoritative source for business-critical data. Redis sits in front as a read-through cache for hot data, session store for user state, and write-through buffer for high-velocity metrics.
This separation lets you scale read and write paths independently. User sessions and shopping carts live in Redis with TTLs, while completed orders persist in PostgreSQL. Product catalog data caches in Redis with 5-minute TTLs, but inventory counts query PostgreSQL directly to prevent overselling.
The tradeoff is operational complexity: you now monitor two systems, handle cache consistency, and design failure modes for when Redis goes down. This investment pays off when you’re serving millions of requests daily, but introduces unnecessary overhead for applications serving thousands.
Key Takeaways
- Start with PostgreSQL for your source of truth; add Redis only when you have measured performance needs that justify the operational complexity
- Design your cache invalidation strategy before writing caching code—reactive invalidation with LISTEN/NOTIFY prevents most stale data issues
- Use Redis for session storage, rate limiting, and real-time features where its data structures provide significant value beyond simple caching
- Monitor cache hit rates and have a degraded mode plan for Redis outages to prevent cascading failures to PostgreSQL