Introduction
WATCH enables the check-and-set (CAS) pattern in Redis — read a value, compute a new value, and write it back only if the original value has not changed. This is the foundation for correct concurrent updates without pessimistic locking.
Key Concepts
- Check-and-Set (CAS) — read → compute → conditionally write; abort if someone else wrote in between
- Optimistic locking — assumes most transactions will not conflict; retries on the rare conflict
- WATCH scope — watches apply per-connection; different clients each manage their own watches
- Retry loop — the application must loop until EXEC succeeds (returns non-nil)
Real World Context
A leaderboard service needs to update a player's high score only if the new score beats the current one. Without CAS, two concurrent updates for the same player could both read the current score, both compute a new score, and both write — the lower update could overwrite the higher one.
Deep Dive
The canonical WATCH retry loop in Python:
pythonimport redis r = redis.Redis() def update_high_score(player_id, new_score): key = f"score:{player_id}" while True: with r.pipeline() as pipe: try: pipe.watch(key) current = pipe.get(key) current_score = int(current) if current else 0 if new_score <= current_score: pipe.unwatch() return False # No update needed pipe.multi() pipe.set(key, new_score) pipe.execute() # Returns None if watched key changed return True except redis.WatchError: continue # Retry
The redis-py client raises WatchError when EXEC returns nil, making the retry loop clean to implement.
Note: redis.WatchError is a redis-py (Python client) convenience exception. At the Redis protocol level, a failed WATCH causes EXEC to return nil — client libraries may wrap this as an exception, a null check, or a special return value depending on the language.
A raw Redis CLI session showing a conflict scenario:
bash# Client A WATCH score:player1 GET score:player1 # => 100 # Client B (interleaves here) SET score:player1 200 # Client A resumes MULTI SET score:player1 150 EXEC # => (nil) -- Client A's transaction aborted
Common Pitfalls
- Unbounded retry loops. Under extreme contention, a loop with no limit could spin forever. Always add a maximum retry count and back off.
- Not calling UNWATCH before returning early. If you decide not to proceed with the transaction, call UNWATCH to release the watch on the current connection.
- Watching volatile keys. If a watched key expires between WATCH and EXEC, Redis treats the expiry as a modification and aborts the transaction.
Best Practices
- Cap retry loops at 3-5 attempts; after that, return a conflict error to the caller.
- Under sustained high contention (>50% retry rate), switch to Lua scripting — it handles the check-and-set atomically without retries.
- Watch only the minimal set of keys required for correctness; each watched key adds abort risk.
Summary
- WATCH implements check-and-set: read before MULTI, write inside MULTI/EXEC
- EXEC returning nil means a watched key changed; the application must retry
- redis-py raises WatchError on nil EXEC, simplifying retry loop code
- Always unwatch explicitly when aborting before MULTI
- High-contention workloads should consider Lua scripting as an alternative
Code Examples
import redis
r = redis.Redis()
def update_high_score(player_id, new_score):
key = f"score:{player_id}"
while True:
with r.pipeline() as pipe:
try:
pipe.watch(key)
current = pipe.get(key)
current_score = int(current) if current else 0
if new_score <= current_score:
pipe.unwatch()
return False
pipe.multi()
pipe.set(key, new_score)
pipe.execute()
return True
except redis.WatchError:
continue