Key-value stores like Redis or DynamoDB — what are they actually good for beyond caching?
Caching is the on-ramp, not the destination — key-value stores double as session storage, rate limiters, leaderboards, locks, and the backbone of single-table designs at massive scale.
LEARN BY DOINGPut your intuition to work3 steps
Key-value stores like Redis or DynamoDB — what are they actually good for beyond caching?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#The model is simpler than a database — and that's the point
A key-value store gives you exactly one primary operation: given a key, get (or set) its value, in roughly constant time. There's no query planner, usually no joins, and often no ad hoc WHERE clause on arbitrary fields. That minimalism is what makes Redis and DynamoDB fast and horizontally scalable — and it's also why "just use Redis for everything" is the wrong lesson to take from how useful they are. The right lesson is narrower: they're extremely good at a specific family of problems, and caching is just the most famous member of that family.
#Beyond caching: what key-value stores are actually for
Session storage. A session token as the key, a small blob (or hash) of session data as the value, a TTL so it self-expires — this is a near-perfect key-value workload: point lookups by a random ID, short-lived, high volume.
Rate limiting and counters. Atomic increment (INCR in Redis) is O(1) and safe under concurrency without your application taking a lock. Combine it with EXPIRE and you get a fixed-window rate limiter in three lines; sliding-window variants use sorted sets keyed by timestamp instead.
import timeimport redisr = redis.Redis()def is_allowed(user_id: str, limit: int = 100, window_seconds: int = 60) -> bool:window = int(time.time()) // window_secondskey = f"ratelimit:{user_id}:{window}"current = r.incr(key)if current == 1:r.expire(key, window_seconds)return current <= limit
Leaderboards and ranking. Redis's sorted set (ZSET) keeps members ordered by score with O(log N) inserts and range queries — "top 10" or "this player's rank" are both cheap operations that would otherwise mean an ORDER BY scan and a window function in a relational database.
ZADD leaderboard:weekly 15420 "player:8831"ZADD leaderboard:weekly 18930 "player:1027"ZREVRANGE leaderboard:weekly 0 9 WITHSCORESZREVRANK leaderboard:weekly "player:8831"
Distributed coordination — carefully. SET key value NX EX 30 acquires a lock only if no one holds it, with a time-to-live so a crashed holder doesn't wedge the lock forever. This is genuinely useful for "only one worker should process this job" style coordination, though single-instance Redis locks are not linearizable across a failover — treat them as a best-effort mutex, not a correctness guarantee for money-moving logic.
SET order:4471:lock "worker-7" NX EX 30
Idempotency keys. Storing "have I already processed request X?" as a key with a short TTL is exactly the point-lookup-with-expiry shape a key-value store is built for, and it's how most payment APIs implement idempotent retries.
Pub/sub and ephemeral messaging. Redis's PUBLISH/SUBSCRIBE gives you fire-and-forget fan-out for things like live dashboards or presence — not a durable queue, but often exactly enough for "notify anyone listening right now."
High-scale OLTP with DynamoDB's single-table design. This is the one that surprises people: DynamoDB is a key-value/wide-item store used as the primary database for some of the highest-traffic systems in the world, by designing the access patterns first and encoding every relationship into a composite partition key + sort key, rather than modeling entities and normalizing.
#The trade-offs you're accepting
| Concern | Relational database | Key-value store (Redis / DynamoDB) |
|---|---|---|
| Query flexibility | Any WHERE/JOIN the planner can execute | Access patterns fixed at design time (Dynamo) or limited to key structure (Redis) |
| Secondary indexes | Free-ish, add as needed | DynamoDB GSIs cost extra writes and storage; Redis has none natively |
| Consistency | Strong by default (single node) | DynamoDB defaults to eventually consistent reads; Redis replicas lag async |
| Durability | WAL + fsync by default | Redis persistence (RDB/AOF) is opt-in and tunable; a misconfigured instance can lose recent writes on crash |
| Schema | Enforced | None — every access pattern must be designed into the key shape upfront |
#DynamoDB: modeling access patterns instead of entities
The single-table pattern uses one physical table where the partition key (PK) and sort key (SK) are made to mean different things for different item types, letting you answer several "queries" with the same primary-key structure:
import boto3table = boto3.resource("dynamodb").Table("AppData")# One physical table, several logical entity types, distinguished by key shape:# PK = "USER#123" SK = "PROFILE" -> the user's profile# PK = "USER#123" SK = "ORDER#2026-06-02#4471" -> one of that user's orderstable.put_item(Item={"PK": "USER#123","SK": "ORDER#2026-06-02#4471","orderId": "4471","totalCents": 8900,"status": "fulfilled",})# "All orders for user 123, most recent first" — one Query, no table scan:response = table.query(KeyConditionExpression="PK = :pk AND begins_with(SK, :prefix)",ExpressionAttributeValues={":pk": "USER#123", ":prefix": "ORDER#"},ScanIndexForward=False,)
That query only works because the access pattern — "orders for a user, newest first" — was decided before the table was designed. Add a new access pattern later ("all orders with a given status, across all users") and you likely need a Global Secondary Index projected ahead of time, not an ad hoc query. That upfront modeling cost is the real price of DynamoDB's scalability, and it's the opposite order of operations from a relational schema, where you model entities first and let the query planner figure out how to answer whatever you ask later.