SCAN vs KEYS for Safe Key Enumeration
You need every key matching session:tenant-42:* so you can purge them after a tenant offboards, and the obvious command is KEYS session:tenant-42:*. On a laptop with a thousand keys it returns instantly; on a production node holding forty million keys it walks the entire keyspace in one uninterruptible pass, and because Redis executes commands on a single thread, every other client — every GET, every SET, every health-check PING — stalls behind it for hundreds of milliseconds or longer. This page shows how to enumerate keys by pattern using the SCAN family instead, which trades a single blocking call for many tiny, resumable ones that let the server keep serving traffic between batches. The technique underpins every safe bulk operation, from targeted invalidation to memory audits.
Prerequisites
- Redis 6.2+ or 7.x, reachable via
redis-cliand from your application host. redis-py5.x on Python 3.10+ (pip install "redis>=5,<6"). The async examples use theredis.asyncioclient.- Permission to run
SCAN,UNLINK, andINFO. NoCONFIG SETis required. - A pattern that identifies the target key family — for example
session:tenant-42:*— rather than an intent to walk the whole keyspace. - For clustered deployments, the ability to reach every primary node, since a scan runs per node.
Step-by-Step Implementation
1. Understand why KEYS * blocks in O(N)
KEYS matches its pattern against every key in the database in a single command, so its cost grows linearly with the number of keys, and that entire walk happens without yielding the event loop. The command below is a production incident waiting to happen — treat it as a demonstration of what not to run:
# DANGER: blocks the single-threaded server for the full O(N) walk.
redis-cli KEYS 'session:tenant-42:*'
Because the walk is atomic, latency for all other clients spikes to the duration of the scan. This is the same blocking hazard that makes KEYS unsuitable for the bulk-purge path discussed under explicit invalidation.
2. Learn the SCAN cursor contract
SCAN replaces one long walk with a series of short ones. Each call takes a cursor and returns a new cursor plus a slice of keys; you feed the returned cursor into the next call and stop when the cursor comes back as 0. The cursor is an opaque reverse-binary iterator over the hash table, not an offset, so never interpret or persist it as a number.
# First iteration starts at cursor 0; the reply's first line is the NEXT cursor.
redis-cli SCAN 0
# Feed that cursor back in; repeat until the returned cursor is 0 again.
redis-cli SCAN 176128
3. Tune throughput with the COUNT hint
COUNT is a hint for how much work each iteration should do — roughly how many keys Redis examines per call — not a hard limit on how many are returned. A larger COUNT means fewer round trips but more work per call; a smaller one keeps each call snappier. The default of 10 is conservative for a large keyspace:
# Examine ~500 keys per iteration to cut the number of round trips.
redis-cli SCAN 0 COUNT 500
4. Filter server-side with MATCH
Add MATCH to return only keys matching a glob pattern. The filter is applied on the server after keys are pulled from the bucket but before they are sent, so it saves bandwidth, not scan work — Redis still examines COUNT keys per call even if none match:
# COUNT governs work per call; MATCH filters what is returned.
redis-cli SCAN 0 MATCH 'session:tenant-42:*' COUNT 500
Because MATCH does not reduce the number of buckets examined, an iteration can legitimately return an empty key list with a non-zero cursor. That is normal — keep going until the cursor is 0.
5. Know the guarantee and its limits
SCAN offers a precise, weak guarantee: any key present for the entire duration of the scan is returned at least once. Keys added or removed mid-scan may or may not appear, and — critically — the same key can be returned more than once across iterations. Your consumer must therefore be idempotent. Deleting an already-deleted key or re-processing a duplicate must be harmless, which is why the batched purge in step 7 uses UNLINK on a de-duplicated set.
6. Drive the loop with async scan_iter
In application code, do not hand-roll the cursor loop. The redis.asyncio client exposes scan_iter, an async generator that manages the cursor for you and yields keys one at a time while awaiting between round trips, so the event loop stays free for other coroutines:
import redis.asyncio as redis
client = redis.Redis(host="redis-primary", port=6379, decode_responses=True)
async def find_keys(pattern: str, count: int = 500) -> list[str]:
"""Enumerate keys by pattern without ever issuing a blocking KEYS."""
found: list[str] = []
# scan_iter drives the cursor internally and awaits between batches.
async for key in client.scan_iter(match=pattern, count=count):
found.append(key)
return found
7. Purge matches in batched UNLINK calls
Collect keys from the scan into fixed-size batches and remove each batch with a single pipelined UNLINK, which frees memory on a background thread instead of blocking the loop the way DEL would. Batching bounds both the command size and the memory you buffer, and it pairs naturally with a key tagging scheme when related keys must all disappear together:
async def purge_matching(pattern: str, batch_size: int = 200) -> int:
"""Scan for a pattern and UNLINK matches in bounded batches."""
batch: list[str] = []
removed = 0
async for key in client.scan_iter(match=pattern, count=500):
batch.append(key)
if len(batch) >= batch_size:
removed += await client.unlink(*batch) # non-blocking free
batch.clear()
if batch: # flush the final partial batch
removed += await client.unlink(*batch)
return removed
8. Scan every node in a Redis Cluster
In a Redis cluster, SCAN is a per-node operation: each primary owns a subset of the 16,384 hash slots and knows only its own keys, so a single SCAN never sees the whole cluster. The redis-py RedisCluster client's scan_iter fans out across all primaries for you; run one scan per node so every shard is covered:
from redis.cluster import RedisCluster, ClusterNode
cluster = RedisCluster(
startup_nodes=[ClusterNode("redis-node-1", 6379)],
decode_responses=True,
)
# RedisCluster.scan_iter iterates each primary in turn, covering all slots.
for key in cluster.scan_iter(match="session:tenant-42:*", count=500):
cluster.unlink(key)
Failure Modes
Treating the cursor as a count or offset. A common bug is looping until the batch is empty rather than until the cursor is 0, which stops early because MATCH legitimately yields empty batches mid-scan. Diagnose by printing the cursor alongside each batch:
# A non-zero cursor with zero returned keys is normal — do not stop here.
redis-cli SCAN 0 MATCH 'session:tenant-42:*' COUNT 100
Fix by making the loop condition the cursor value; scan_iter handles this correctly on your behalf.
Duplicate keys processed twice. Because SCAN may return the same key across iterations, a non-idempotent consumer double-counts or double-charges. Diagnose by comparing raw returned counts against a de-duplicated count over a live keyspace:
redis-cli --scan --pattern 'session:tenant-42:*' | wc -l
redis-cli --scan --pattern 'session:tenant-42:*' | sort -u | wc -l
Fix by de-duplicating before acting, and by using idempotent operations like UNLINK whose repetition is harmless.
A KEYS sneaks back in during an incident. Under pressure someone runs KEYS from a console and stalls the node. Diagnose after the fact via the slow log, where the blocking call appears with a large microsecond duration:
redis-cli SLOWLOG GET 10 # look for KEYS entries with high latency
Fix by renaming or disabling KEYS in production with rename-command KEYS "" and standardizing on SCAN in every runbook.
Verification
Confirm the scan enumerated every intended key and that a purge actually removed them. First, count matches without blocking using the --scan helper, which drives the cursor for you:
redis-cli --scan --pattern 'session:tenant-42:*' | wc -l # expect the tenant's key count
Confirm no blocking enumeration entered the slow log during the operation:
redis-cli SLOWLOG RESET
# ...run your scan-based purge...
redis-cli SLOWLOG GET 10 # expect no KEYS entries
After a purge, verify the pattern is empty and that the work landed as background frees rather than main-thread blocks:
redis-cli --scan --pattern 'session:tenant-42:*' | head -n 1 # expect no output
redis-cli INFO stats | grep -E "expired_keys|keyspace_hits"
FAQ
Does SCAN ever block the server the way KEYS does?
Each individual SCAN call does a bounded amount of work — roughly proportional to its COUNT — and returns quickly, so it never monopolizes the single thread the way KEYS does. The full enumeration takes many calls and more wall-clock time overall, but the server interleaves other clients' commands between those calls, so no single request stalls the node. That trade — more total time for bounded per-call latency — is exactly why SCAN is safe in production.
What does a returned cursor of 0 mean?
A returned cursor of 0 means the iteration is complete: SCAN has walked the entire hash table and there is nothing left to visit. You start every scan by passing cursor 0 and you stop when the reply's cursor comes back as 0. Any other value is an opaque position to feed into the next call — never interpret it as a key count or a numeric offset, and never persist it across a resharding or DEBUG RELOAD.
Does MATCH filter on the server or the client?
MATCH filters on the server, so only matching keys travel back over the network, saving bandwidth. It does not, however, reduce the scanning work: Redis still examines roughly COUNT keys per call and applies the glob afterward. This is why a MATCH scan of a sparse pattern over a huge keyspace can return many empty batches with non-zero cursors before it finishes — the buckets are being examined even though few keys match.
When should I use TYPE, HSCAN, SSCAN, or ZSCAN instead?
Add the TYPE option to SCAN (Redis 6.0+) to enumerate only keys of a given type, such as SCAN 0 TYPE hash. To iterate the fields inside one large collection rather than top-level keys, use the sibling cursors: HSCAN for hash fields, SSCAN for set members, and ZSCAN for sorted-set members. They share the identical cursor contract and are the correct tool when a single key is too large to read whole with HGETALL or SMEMBERS.
How do I scan an entire Redis Cluster?
There is no cluster-wide SCAN; each primary only knows its own slots, so you scan every primary and union the results. The redis-py RedisCluster.scan_iter does this fan-out automatically by iterating each node in turn. If you script it manually, enumerate the primaries from CLUSTER NODES, run a full cursor loop against each, and remember that keys sharing a hash tag co-locate on one node, so a tag-scoped pattern only needs the single owning shard.
Up: Understanding Redis Cache Topology
Related
- Understanding Redis Cache Topology — standalone, Sentinel, and Cluster structure that determines where a scan runs.
- Redis Security Boundaries for Multi-Tenant Apps — isolating and purging one tenant's keys safely.
- Using Key Tags to Invalidate Related Data Sets — grouping related keys so a scan targets one slot.
- Redis Cluster Slot Allocation Basics — how the 16,384 slots map keys to the nodes a scan must cover.