Skip to main content

Redis TTL and Key Expiration: Automatic Cache Cleanup

Redis TTL (time-to-live) automatically deletes keys after a specified duration. Without TTLs, your Redis cache grows infinitely and consumes all available memory. With proper expiration, Redis maintains a bounded cache that evicts stale data. TTLs are essential for sessions (expire after 1 hour), temporary locks (expire after 10 seconds), and cache invalidation (refresh every 5 minutes).

I learned the importance of TTLs the hard way: a production Redis instance ran out of memory because cached user sessions lacked expiration. After adding TTLs to all transient data, memory stabilized at 40% utilization. This guide teaches you to design expiration strategies that fit your application.

Setting Expiration with setex and expire

Set a TTL when creating a key or add expiration to an existing key.

import redis
import time

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

# Set a key with expiration in one operation (atomic)
r.setex('session:abc123', 3600, json.dumps(session)) # Expires in 1 hour (3600 seconds)

# Set multiple keys with same expiration using a pipeline
pipe = r.pipeline()
pipe.setex('email:validate:[email protected]', 600, 'token_xyz') # 10 minutes
pipe.setex('email:validate:[email protected]', 600, 'token_abc')
pipe.execute()

# Add expiration to an existing key
r.set('user:preferences:123', json.dumps(preferences))
r.expire('user:preferences:123', 86400) # Expire in 24 hours

# Set expiration at a specific Unix timestamp (useful for absolute expiry times)
expiry_timestamp = int(time.time()) + 3600 # 1 hour from now
r.expireat('temp:data:456', expiry_timestamp)

# Get remaining TTL (in seconds)
ttl = r.ttl('session:abc123')
if ttl == -1:
print("Key exists but has no expiration")
elif ttl == -2:
print("Key does not exist")
else:
print(f"Key expires in {ttl} seconds")

# Get TTL in milliseconds
ttl_ms = r.pttl('session:abc123')
print(f"Key expires in {ttl_ms} milliseconds")

# Remove expiration from a key (make it permanent)
r.persist('user:preferences:123')

setex() is atomic: the key is created with expiration in a single operation, avoiding race conditions. Always use setex() for new keys that need expiration instead of set() followed by expire().

Sliding Window Sessions

Extend a session's TTL on each user activity (login refresh pattern).

def get_or_extend_session(session_token, user_id):
"""Retrieve session; extend expiration if valid."""
session_key = f'session:{session_token}'

# Get the current session
session_json = r.get(session_key)

if not session_json:
return None # Session expired

session = json.loads(session_json)

# Extend the session (sliding window: reset TTL to 1 hour from now)
r.expire(session_key, 3600)

return session

# On each request, get_or_extend_session resets the 1-hour timer
user_session = get_or_extend_session('token_xyz', user_id=123)
if user_session:
print(f"User {user_session['email']} is logged in. Session extended.")
else:
print("Session expired. Please log in again.")

Sliding window keeps active sessions alive while expiring idle sessions automatically. If a user is active every 30 minutes, the 1-hour window renews continuously. If they go idle, it expires.

Expiration Strategies for Different Data Types

Temporary Tokens and Verification Codes

import secrets

def send_email_verification(email):
"""Send a verification email with a 10-minute token."""
token = secrets.token_urlsafe(32)

# Store token with 10-minute expiration
r.setex(f'email:verify:{token}', 600, email)

# Send email with link: /verify?token={token}
send_email(email, f"https://example.com/verify?token={token}")

return token

def verify_email(token):
"""Verify email token (must be within 10 minutes)."""
email = r.get(f'email:verify:{token}')

if not email:
return False # Token expired or invalid

# Token is valid; mark email as verified
r.delete(f'email:verify:{token}') # Consume token

# Update user record in MongoDB
db.users.update_one({'email': email}, {'$set': {'verified': True}})

return True

Short-lived tokens (5–15 minutes) prevent token reuse and limit the window for brute-force attacks.

Rate-Limit Counters with Sliding Windows

def rate_limit_check(user_id, limit=100, window=60):
"""Check if user exceeded rate limit. Auto-resets window."""
counter_key = f'rate_limit:{user_id}'

# Increment counter
count = r.incr(counter_key)

# Set expiration on first request in this window
if count == 1:
r.expire(counter_key, window)

return count > limit

# In API handler:
if rate_limit_check(user_id=123, limit=100, window=60):
return {"error": "Rate limit exceeded"}, 429

The counter automatically resets every 60 seconds. When the window expires, the key is deleted and a new counter begins.

Cache with Stale-While-Revalidate Pattern

import time

def get_cached_feed(user_id, revalidate_interval=300, max_stale=3600):
"""Get user's feed from cache. Revalidate if stale."""
cache_key = f'feed:{user_id}'

# Get cached feed and its age
cached = r.get(cache_key)
last_updated = r.get(f'{cache_key}:timestamp')

if cached and last_updated:
age = int(time.time()) - int(last_updated)

if age < revalidate_interval:
# Cache is fresh, serve it
return json.loads(cached), 'fresh'

elif age < max_stale:
# Cache is stale but still usable; revalidate in background
# (Return stale data immediately, fetch fresh in background)
return json.loads(cached), 'stale'

# Cache is missing or too old; fetch from database
feed = fetch_feed_from_mongodb(user_id)

# Cache with expiration (max_stale allows serving stale data)
r.setex(cache_key, max_stale, json.dumps(feed))
r.setex(f'{cache_key}:timestamp', max_stale, str(int(time.time())))

return feed, 'fresh'

# Usage
feed, source = get_cached_feed(user_id=123)
print(f"Feed from {source}: {len(feed)} posts")

This pattern serves stale data while revalidating in the background, minimizing user latency.

Eviction Policies: What Happens When Redis Runs Out of Memory

When Redis reaches its memory limit, it evicts keys based on an eviction policy. Configure this in redis.conf or via CONFIG SET.

# Check current eviction policy
policy = r.config_get('maxmemory-policy')
print(f"Eviction policy: {policy}")

# Set eviction policy (examples)
# 'noeviction': Reject writes when memory full (default)
# 'allkeys-lru': Evict least-recently-used keys
# 'volatile-lru': Evict least-recently-used keys WITH expiration
# 'allkeys-random': Evict random keys
# 'volatile-ttl': Evict keys with shortest TTL
# 'volatile-random': Evict random keys WITH expiration
# 'allkeys-lfu': Evict least-frequently-used keys (newer)

r.config_set('maxmemory-policy', 'volatile-lru') # Evict stale TTL keys first

# Set memory limit
r.config_set('maxmemory', '1gb')

For cache-only Redis (not the source of truth), use volatile-lru or allkeys-lru: evict old keys automatically. For session storage, use volatile-ttl: prioritize expiring keys with short lifespans. Never use noeviction unless you monitor memory carefully.

Key Takeaways

  • Use setex() to create keys with TTL in one atomic operation; use expire() to add expiration to existing keys
  • Set short TTLs for temporary data (tokens: 5–15 minutes; sessions: 15–60 minutes; caches: 5–24 hours)
  • Use sliding window sessions to keep active users logged in while expiring idle users automatically
  • Monitor TTL with ttl() and pttl(); use persist() only for permanent data
  • Configure eviction policy to volatile-lru or allkeys-lru for cache Redis; never allow memory to grow unbounded

Frequently Asked Questions

How do I choose a TTL value?

Use the shortest TTL that still provides value. For user sessions, 15–60 minutes balances security and usability. For API caches, 5–30 minutes depends on how quickly data changes. For tokens, 5–15 minutes limits the attack window. For browser caches, hours to days.

What happens if I forget to set a TTL?

The key exists forever. Without monitoring, your Redis fills with stale data and runs out of memory. Always set TTLs for transient data; use persist() only for permanent data like user profiles (even then, prefer to set longer TTLs like 24 hours for periodic refresh).

Can I change a key's TTL?

Yes, call expire() again to reset it. Each expire() call overwrites the previous TTL. Use this for sliding window sessions: every request calls expire(key, 3600) to reset the 1-hour timer.

Does expired data consume memory until Redis deletes it?

Redis deletes expired keys lazily (on access) or actively (background cleanup). There is a small delay between expiration and deletion, but Redis prioritizes cleanup near its memory limit. Use maxmemory-policy to control eviction.

Can I set per-field expiration on hashes?

No. Expiration is per key. To expire hash fields individually, store each field as a separate key with its own TTL, or use a separate sorted set to track field expiration times.

Further Reading