Common Redis Mistakes

Redis looks deceptively simple — a handful of commands, string keys, instant responses — which is exactly why so many teams get bitten by it in production. Most Redis mistakes aren’t obscure edge cases; they’re small misunderstandings about TTL semantics, data types, or atomicity that quietly leak memory, corrupt counters, or freeze the server under load. This lesson walks through the mistakes that show up most often in real Redis deployments, explains why each one happens, and shows the exact command to use instead.

Overview: Why Redis Mistakes Happen

Almost every common Redis mistake traces back to one of three things about how the server actually works internally.

Single-threaded execution. Redis processes commands one at a time on a single main thread. Every command — a GET, a ZADD, an entire MULTI/EXEC transaction — runs to completion before the next command starts, with no other client’s command interleaving in the middle. This is why any single Redis command is atomic by default. It’s also why patterns that do “read in my app, decide, then write” instead of using one atomic Redis command are the single biggest source of race-condition bugs, and why any command that takes proportionally longer to run (like scanning the whole keyspace) blocks every other client for that entire duration.

Expiration is not instantaneous deletion. When you set a TTL, Redis stores an absolute expiration timestamp alongside the key rather than scheduling a timer. It removes expired keys through two mechanisms working together: lazy expiration, where a key is checked and deleted the moment a client tries to access it, and active expiration, a background cycle that periodically samples a small number of random keys with TTLs and deletes any that have expired, freeing memory even for keys nobody reads again. A detail that trips up nearly everyone: running a plain SET on a key that already carries a TTL clears that TTL, because SET fully replaces the key including its expiration metadata, unless you explicitly pass KEEPTTL.

Strict per-key typing. Every key holds exactly one data type — string, list, hash, set, sorted set, stream, and so on — and every command is type-checked before Redis runs it. There’s no implicit conversion. Calling a list command on a key that holds a string (or vice versa) doesn’t coerce anything; it returns a WRONGTYPE error and does nothing.

Syntax

The commands most relevant to avoiding these mistakes, and their time complexity:

Command Time Complexity Notes
SET key value [EX|PX|KEEPTTL] [NX|XX] O(1) Plain SET clears any existing TTL unless KEEPTTL is given
TTL key O(1) Returns seconds left, -1 if no TTL, -2 if key doesn’t exist
EXPIRE key seconds O(1) Returns 0 and does nothing if the key doesn’t exist
KEYS pattern O(N) N = total keys in the keyspace; blocks the single thread for the whole scan
SCAN cursor [MATCH pattern] [COUNT count] O(1) per call O(N) across a full iteration, but never blocks other clients
INCR key O(1) Atomic read-modify-write; safe under concurrent callers
LRANGE key start stop O(S+N) S = offset to start, N = number of elements returned
MULTI / EXEC O(1) + cost of queued commands Queues commands, then runs them as one atomic block

General shape of the patterns covered in this lesson:

SET <key> <value> [EX seconds | PX milliseconds | KEEPTTL] [NX | XX]
EXPIRE <key> <seconds>
TTL <key>
SCAN <cursor> [MATCH <pattern>] [COUNT <count>]
MULTI
<command 1>
<command 2>
EXEC

Examples

1. A plain SET silently wipes a TTL

You set a session key with a 60-second lifetime, then later update its value with a plain SET — expecting the TTL to still be running.

SET session:abc123 "active" EX 60
TTL session:abc123
SET session:abc123 "active-updated"
TTL session:abc123

Output:

OK
(integer) 60
OK
(integer) -1

The second TTL call returns -1: the update-with-plain-SET replaced the key entirely, TTL included, so the session now lives forever. The fix is KEEPTTL:

SET session:abc123 "active" EX 60
SET session:abc123 "active-updated" KEEPTTL
TTL session:abc123

Output:

OK
OK
(integer) 60

With KEEPTTL, the value changes but the original expiration keeps counting down.

2. Calling the wrong command for a key’s type

You push an item onto a list-shaped shopping cart key, then later try to read it back with GET out of habit.

RPUSH cart:1001 "item42"
GET cart:1001

Output:

(integer) 1
(error) WRONGTYPE Operation against a key holding the wrong kind of value

cart:1001 is a list, and GET only works on strings, so Redis refuses the call outright rather than guessing what you meant. The fix is to use the command that matches the type actually stored:

RPUSH cart:1001 "item42"
LRANGE cart:1001 0 -1

Output:

(integer) 1
1) "item42"

3. A non-atomic read-modify-write instead of an atomic command

You want to bump a page-view counter, so your application GETs the current value, adds one in your own code, and SETs the result back:

SET pageviews:home "10"
GET pageviews:home
SET pageviews:home "11"

Output:

OK
"10"
OK

Nothing errors here, which is exactly the danger: under a single connection this looks fine, but with two concurrent clients both reading 10 before either writes back, one increment is silently lost — a classic lost-update race. The single-threaded guarantee only protects one command at a time, not a GET followed later by a SET. Use INCR, which reads and writes inside one atomic operation:

SET pageviews:home "10"
INCR pageviews:home
GET pageviews:home

Output:

OK
(integer) 11
"11"

How It Works Step by Step

  • When you send SET key value KEEPTTL, Redis looks up the key, replaces its value in place, and explicitly skips clearing the expiration timestamp field — without KEEPTTL, that field is reset along with everything else.
  • When you call a command like GET, Redis first checks the internal type tag stored with the key’s object header. If the tag doesn’t match what the command expects, it returns WRONGTYPE before touching any data — the check happens before execution, so a wrong-type call never partially modifies anything.
  • When you call INCR, the parse, the read, the increment, and the write-back all happen inside a single command dispatch on the main thread. No other client’s command can run between the read and the write, which is what makes it safe under concurrency where GET-then-SET is not.
  • When a key’s TTL expires, Redis doesn’t delete it the instant the clock ticks over. It’s removed either lazily — the next time any command touches that key, Redis notices the timestamp has passed and deletes it before responding — or actively, via a background cycle that samples random keys with TTLs several times per second and evicts the expired ones so idle, forgotten keys don’t sit in memory forever.

Common Mistakes

Mistake: Using KEYS on a production dataset

MSET user:1:name "Ada" user:2:name "Grace" user:3:name "Linus"
KEYS user:*

Output (order of matched keys is not guaranteed):

OK
1) "user:1:name"
2) "user:2:name"
3) "user:3:name"

This works fine on a toy dataset of three keys. On a production instance with millions of keys, KEYS is O(N) and, because Redis is single-threaded, it blocks every other client for the entire scan — every request to your app can stall for seconds. Use SCAN instead, which walks the keyspace in small, non-blocking increments using a cursor:

MSET user:1:name "Ada" user:2:name "Grace" user:3:name "Linus"
SCAN 0

Output:

OK
1) "0"
2) 1) "user:1:name"
   2) "user:2:name"
   3) "user:3:name"

The first element of the reply is the next cursor to pass back in; a cursor of "0" means the iteration is complete. On a real dataset you’d keep calling SCAN with the returned cursor, in a loop, until it comes back as 0.

Mistake: Forgetting to set a TTL on cache data

SET cache:product:501 "cached-value"
TTL cache:product:501

Output:

OK
(integer) -1

A -1 means this key has no expiration at all — it will sit in memory forever unless something explicitly deletes it. This is one of the most common causes of slow, unexplained memory growth: caches that are meant to be transient but were never given a TTL. Always attach an expiration to cache-shaped data, either inline or as a follow-up call:

SET cache:product:501 "cached-value" EX 3600
TTL cache:product:501

Output:

OK
(integer) 3600

Mistake: Assuming EXPIRE on a missing key does something

EXPIRE user:9999:name 60

Output:

(integer) 0

EXPIRE returns 1 if the TTL was set and 0 if the key doesn’t exist — it never errors, so code that ignores the return value has no way to notice that nothing happened. Always check for 0 if your logic depends on the TTL actually being applied.

Mistake: Running destructive admin commands without thinking about scope

FLUSHALL

Output:

OK

FLUSHALL deletes every key in every database on the instance, instantly and irreversibly, with no confirmation prompt. It’s occasionally reached for out of habit when someone means to clear one test key. Prefer deleting specific keys with DEL, or a specific pattern via a SCAN-then-DEL loop, and treat any command that clears an entire database as something you type by hand, deliberately, never in a script that runs unattended.

Best Practices

  • Always attach a TTL to anything that’s conceptually a cache, and check TTL whenever you’re unsure — -1 means it will never expire on its own.
  • Use KEEPTTL whenever you’re updating a value on a key that must keep its existing expiration.
  • Prefer atomic commands (INCR, INCRBY, SET ... NX, GETSET/GETDEL) over separate read-then-write calls from your application; wrap multi-step operations in MULTI/EXEC when a single command can’t express what you need.
  • Never run KEYS against a production dataset of meaningful size — use SCAN with a small COUNT and loop until the cursor returns to 0.
  • Check the return value of EXPIRE, SETNX, and similar commands instead of assuming success — a 0 reply usually means the operation was silently skipped.
  • Confirm a key’s type before writing generic, reusable code paths against it; a mixed-type keyspace bug surfaces as a WRONGTYPE error at the worst possible moment.
  • Treat FLUSHALL, FLUSHDB, and CONFIG SET as manual, deliberate operations — keep them out of application code and automated scripts entirely.

A transaction is the right tool when several writes need to happen together as one atomic unit:

MULTI
SET order:9001:status "paid"
INCR orders:paid:count
EXEC

Output:

OK
QUEUED
QUEUED
1) OK
2) (integer) 1

MULTI starts queuing; each command replies QUEUED instead of running immediately; EXEC runs the whole queue as a single atomic block and returns each command’s real reply in order.

Practice Exercises

  • You’re caching a computed report under report:daily:2026-08-10 that should stop existing after 24 hours. Write a single command that stores the value and guarantees the expiration in one call.
  • A key visits:home holds a string counter that several parts of your app increment by reading the current value and writing back the result plus one. Rewrite that increment so it can never lose an update under concurrent access, and explain why the original approach could.
  • You need to find every key matching session:* on a production instance holding five million keys, without blocking any other client. Describe the command and looping strategy you’d use instead of KEYS, and what the returned cursor tells you about when to stop.

Summary

  • Redis is single-threaded, so any one command is atomic, but a GET-then-SET sequence split across two round trips is not — use atomic commands like INCR or wrap the sequence in MULTI/EXEC.
  • A plain SET on an existing key clears its TTL; use SET ... KEEPTTL when you want to update the value without disturbing the expiration.
  • TTL returns -1 for a key with no expiration and -2 for a key that doesn’t exist — always check for -1 on data that’s meant to be temporary.
  • Every key has exactly one type, and mismatched commands return WRONGTYPE rather than silently coercing — check or design around a key’s type instead of guessing.
  • EXPIRE on a missing key returns 0 and does nothing; it never errors, so ignoring the return value hides real bugs.
  • KEYS is O(N) and blocks the whole server; SCAN does the same job incrementally without blocking, and is the only one of the two that’s safe in production.
  • Destructive, server-wide commands like FLUSHALL belong in deliberate manual use, never in application code or unattended scripts.