Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 047 / 100

Redis with Python: a Python Backend guide for developers

Caching, rate limits and queues.

Expona Admin8 min read

Introduction

Redis is one of the most powerful tools in a backend engineer's toolkit, yet many teams use it without fully leveraging its capabilities. Redis with Python: a Python backend guide for developers will walk you through production patterns we've refined across e-commerce, SaaS and ride-hailing platforms—not just basic key-value storage, but connection pooling, strategic TTLs, pipeline batching, and lightweight queue architectures.

By the end of this post, you'll know how to build resilient caching layers, enforce rate limits, and handle asynchronous work without adding another service to your infrastructure. We've learned these lessons the hard way: through performance incidents, memory bloat, and stalled background jobs.

Redis-py with Connection Pooling

The first mistake many teams make is creating a new Redis connection for every operation. In a high-traffic application, this leads to socket exhaustion, unpredictable latency spikes, and eventually connection timeouts that cascade through your system.

The solution is connection pooling. Redis-py handles this elegantly with the ConnectionPool class, which maintains a pool of reusable connections. When you use redis.Redis() directly, it creates a pool under the hood with sensible defaults, but production deployments need explicit control.

python
import redis
from redis import ConnectionPool, Redis

# Initialize a connection pool with explicit configuration
pool = ConnectionPool(
    host='localhost',
    port=6379,
    db=0,
    max_connections=50,
    socket_connect_timeout=5,
    socket_keepalive=True,
    socket_keepalive_options={
        1: (9, 3, 60),  # TCP_KEEPIDLE, TCP_KEEPINTVL, TCP_KEEPCNT
    }
)

# Use the pool for all Redis operations
redis_client = Redis(connection_pool=pool)

# In a web framework, initialize once at startup
# Don't create new connections in request handlers
result = redis_client.get('user:123:cache')

Set max_connections based on your concurrent request volume. A rule of thumb: if you're handling 100 concurrent requests and each might use Redis, start with a pool size of 20–30. Connection pooling works because most operations complete quickly, and connections are recycled back into the pool immediately.

In FastAPI or Django, initialize the pool once at application startup and inject it as a dependency or context variable. Never create a new pool per request. If you're running in containers, set socket_keepalive=True to prevent idle connection drops from infrastructure proxies.

Set TTLs on Every Cache Key

Redis stores data in memory. Without time-to-live (TTL) management, your cache becomes a memory leak. We've seen production incidents where a single misconfigured cache key grew unchecked, eventually evicting legitimate data through Redis' maxmemory policy.

The discipline here is simple: every cache key must have a TTL. No exceptions. Make it a code review rule.

python
import redis
from datetime import timedelta

redis_client = redis.Redis(host='localhost', port=6379)

# Bad: no TTL, key lives forever or until memory pressure forces eviction
redis_client.set('user:123:profile', profile_json)

# Good: explicit TTL, key expires after 1 hour
redis_client.setex('user:123:profile', timedelta(hours=1), profile_json)

# Also good: set then expire separately (useful when value is complex)
pipe = redis_client.pipeline()
pipe.set('user:123:profile', profile_json)
pipe.expire('user:123:profile', timedelta(hours=1))
pipe.execute()

# For frequently accessed, stable data: longer TTL
# For volatile user state: shorter TTL
CACHE_TTL = {
    'product:catalog': timedelta(hours=6),
    'user:session': timedelta(hours=1),
    'rate_limit:user': timedelta(minutes=1),
}

for key, ttl in CACHE_TTL.items():
    if redis_client.exists(key):
        redis_client.expire(key, ttl)

Different data types need different TTLs. Catalog data that changes infrequently can live for hours. User sessions should expire within minutes to hours. Rate limit counters typically live for seconds to minutes. Document these decisions in your codebase or a shared wiki—inconsistent TTL strategies cause confusion and cache invalidation bugs.

Use setex for simple values to set and expire in one atomic operation. For complex logic, use a pipeline (we'll cover that next) to ensure both the set and expire happen together.

Use Pipelines to Batch Commands

Every Redis command incurs a network round trip. In high-frequency scenarios—batch processing, migrations, or complex cache updates—these round trips add up to measurable latency. Pipelines let you batch multiple commands and send them to Redis in a single request.

We've seen teams reduce operation time from 500ms to 50ms by switching from individual commands to pipelined operations.

python
import redis
from datetime import timedelta

redis_client = redis.Redis(host='localhost', port=6379)

# Bad: 10 round trips
for user_id in range(1, 11):
    key = f'user:{user_id}:score'
    redis_client.set(key, 0)
    redis_client.expire(key, timedelta(hours=24))

# Good: 1 round trip, batched in a pipeline
pipe = redis_client.pipeline()
for user_id in range(1, 11):
    key = f'user:{user_id}:score'
    pipe.set(key, 0)
    pipe.expire(key, timedelta(hours=24))
pipe.execute()

# Practical example: update user cache and rate limits atomically
def update_user_state(redis_client, user_id, profile_data, rate_limit_resets):
    pipe = redis_client.pipeline()
    
    pipe.set(f'user:{user_id}:profile', profile_data)
    pipe.expire(f'user:{user_id}:profile', timedelta(hours=2))
    
    for endpoint, reset_time in rate_limit_resets.items():
        pipe.set(f'rate:{user_id}:{endpoint}', 0)
        pipe.expire(f'rate:{user_id}:{endpoint}', reset_time)
    
    results = pipe.execute()
    return results

Pipelines are especially valuable when you're syncing multiple related pieces of state. Instead of risk and latency from partial updates (some commands succeed, network hiccup, others don't), pipelines send everything at once and either all succeed or all fail together.

Be mindful of pipeline size. Sending 10,000 commands in one pipeline risks memory strain on both client and server. For large batch operations, chunk them into pipelines of 100–500 commands.

Redis Streams for Lightweight Queues

Many teams reach for dedicated message brokers like RabbitMQ or Kafka for async work. For simpler use cases—job queues, event logs, or lightweight task distribution—Redis streams are a compelling alternative. They're part of Redis core, eliminating another dependency.

Streams are ordered, append-only logs. Consumers read from a stream, acknowledge messages, and Redis tracks consumer groups and pending messages automatically.

python
import redis
import json
from datetime import datetime

redis_client = redis.Redis(host='localhost', port=6379)

# Producer: add events to a stream
def log_user_action(user_id, action, metadata):
    stream_key = 'user:actions:stream'
    event = {
        'user_id': str(user_id),
        'action': action,
        'timestamp': datetime.utcnow().isoformat(),
        'metadata': json.dumps(metadata),
    }
    message_id = redis_client.xadd(stream_key, event)
    return message_id

# Consumer: process events from a stream with a consumer group
def setup_consumer_group(group_name='analytics'):
    stream_key = 'user:actions:stream'
    try:
        redis_client.xgroup_create(stream_key, group_name, id='0')
    except redis.ResponseError:
        pass  # Group already exists

def consume_events(group_name='analytics', consumer_name='worker1', count=10):
    stream_key = 'user:actions:stream'
    
    # Read unprocessed messages
    messages = redis_client.xreadgroup(
        groupname=group_name,
        consumername=consumer_name,
        streams={stream_key: '>'},
        count=count,
        block=1000,  # milliseconds
    )
    
    for stream_key, message_list in messages or []:
        for message_id, data in message_list:
            try:
                # Process the event
                user_id = int(data[b'user_id'])
                action = data[b'action'].decode()
                print(f"Processing {action} for user {user_id}")
                
                # Acknowledge successful processing
                redis_client.xack(stream_key, group_name, message_id)
            except Exception as e:
                # Don't acknowledge; message stays pending for retry
                print(f"Error processing message: {e}")
                continue

# Main loop
if __name__ == '__main__':
    setup_consumer_group()
    log_user_action(123, 'purchase', {'product_id': 456, 'amount': 99.99})
    consume_events()

Streams excel for scenarios like:

  • Order processing pipelines with multiple stages (payment, fulfillment, shipping).
  • Real-time analytics ingestion with consumer groups handling different transformations.
  • Event sourcing where you need the full history and multiple consumers processing in parallel.

Consumer groups handle acknowledgment tracking and pending message management, so you don't have to. If a consumer crashes mid-processing, Redis remembers which messages weren't acknowledged and reassigns them to other consumers in the group.

For rate-limited or low-volume async work, streams are simpler than Kafka. For high-volume, mission-critical queues, Kafka still offers better durability guarantees and cross-datacenter replication.

Common Mistakes

Not monitoring Redis memory usage: Set up alerts on used_memory and used_memory_peak. Memory exhaustion leads to eviction policies kicking in, which silently deletes cache data and breaks application logic.

Ignoring key naming conventions: Without a consistent naming scheme (e.g., type:id:field), keys become unmanageable. Use a shared constant for key prefixes across your team.

Forgetting about serialization: Redis stores strings. Complex objects must be serialized (usually JSON) and deserialized. Don't assume pickle—it's Python-specific and breaks with version upgrades. Use JSON or MessagePack for portability.

Setting TTLs too short: If cache TTL expires faster than data changes in your database, you lose the caching benefit. Balance freshness against database load.

Blocking calls in production: Using blpop or xread with block=0 (infinite wait) in a web request handler blocks threads. Always use reasonable timeouts, or move blocking work to dedicated worker processes.

Key Takeaways

  • Initialize Redis connection pooling once at application startup; reuse the pool for all operations.
  • Enforce TTLs on every cache key without exception—it's a code review requirement.
  • Batch multiple Redis commands into pipelines to reduce network latency and improve atomicity.
  • Use Redis streams for lightweight async queues and event logging; they eliminate external dependencies for many use cases.
  • Monitor Redis memory, key distribution, and client connection count in production.
  • Document TTL policies and key naming conventions so the team stays aligned as the system grows.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles