How to Delete Redis Keys by Pattern Without Blocking Production
Delete Redis keys that match a pattern with SCAN and UNLINK in Node.js and Python, why KEYS stalls production, and cache designs that avoid it entirely.
To delete every Redis key that matches a pattern, find the keys with SCAN and a MATCH pattern, a few hundred at a time, and delete each batch with UNLINK. Don’t use KEYS in production: it walks the whole keyspace in one command, and Redis serves no other client until it finishes.
On PCB Dashboard, a compliance platform I work on, cached API responses are cleared this way — with SCAN rather than KEYS — so invalidating the cache never blocks Redis. This article shows the technique in Node.js and Python, measures why it matters, and ends with designs that avoid pattern deletes altogether.
Everything here was tested on 25 October 2026 with Redis 8.2.3, node-redis 4.7.1, 5.12.1 and 6.3.0, ioredis 6.0.0, redis-py 8.1.0, Python 3.13 and Node.js 22.
The short answer
In Node.js with node-redis 5 or later:
export async function deleteByPattern(client, pattern, { count = 1000 } = {}) {
let deleted = 0;
// node-redis v5+ yields an array of keys for each SCAN reply (v4 yielded single keys).
for await (const keys of client.scanIterator({ MATCH: pattern, COUNT: count })) {
if (keys.length) deleted += await client.unlink(keys);
}
return deleted;
}
In Python with redis-py:
def delete_by_pattern(r, pattern, count=1000, batch=500):
deleted, pending = 0, []
for key in r.scan_iter(match=pattern, count=count):
pending.append(key)
if len(pending) >= batch:
deleted += r.unlink(*pending)
pending.clear()
if pending:
deleted += r.unlink(*pending)
return deleted
From a shell, for a one-off cleanup, the line most tutorials give is:
redis-cli --scan --pattern 'cache:report:*' | xargs -L 500 redis-cli unlink
It has a quiet bug: xargs splits on spaces, so a key that contains a space is never deleted. In a test with 3,001 matching keys, one of them containing a space, it deleted 3,000 and left that one behind. Splitting on newlines only fixes it, and works with both GNU and macOS xargs:
redis-cli --scan --pattern 'cache:report:*' | tr '\n' '\0' | xargs -0 -n 500 redis-cli unlink
Why KEYS stalls production
Redis executes commands one at a time. KEYS pattern is a single command that inspects every key in the database, so while it runs, every other client — your API, your workers, your session lookups — waits.
I measured it on a 2017 MacBook Pro (Intel Core i5-7267U) with a Redis instance holding 2 million keys, 1 million of them matching cache:report:*. A second process sent PING in a loop throughout, to see what another client would experience:
| Approach | Longest single command (SLOWLOG) | Worst wait seen by another client | Total time to delete 1M keys |
|---|---|---|---|
KEYS, then UNLINK in batches |
430 ms | 246 ms | 4.5 s |
SCAN (COUNT 1000) + UNLINK in batches of 500 |
0.9 ms | 37 ms | 6.2 s |
SCAN takes longer in total, but it never holds Redis for more than about a millisecond at a time. Users notice the 430 ms pause; nobody notices that a background cleanup took two seconds longer. Your numbers will differ by machine and dataset; the gap between one long command and many short ones won’t.
How SCAN actually works
SCANreturns a cursor and a batch of keys. You call it again with the cursor until the cursor comes back as0.COUNTis a hint for how much work each call does, not how many keys come back.MATCHis applied after Redis has picked the batch, so a call can return no keys even though matching keys remain.- A key can be returned more than once, and keys added or removed during the scan may or may not be seen. Deleting a key twice is harmless —
UNLINKreturns 0 for a key that is already gone.
The empty-reply bug
With 200,000 keys of which only 10 matched the pattern, a full scan made 1,993 calls, and 1,983 of them returned no keys. A loop that stops at the first empty reply stops almost immediately and deletes almost nothing:
# Wrong: stops as soon as one reply is empty
cursor = 0
while True:
cursor, keys = r.scan(cursor=cursor, match="cache:report:*", count=100)
if not keys:
break # bug: matching keys may remain
r.unlink(*keys)
# Right: stop only when the cursor comes back as 0
cursor = 0
while True:
cursor, keys = r.scan(cursor=cursor, match="cache:report:*", count=100)
if keys:
r.unlink(*keys)
if cursor == 0:
break
The iterator helpers — scanIterator in node-redis, scanStream in ioredis, scan_iter in redis-py — handle the cursor for you, which is one more reason to use them.
DEL or UNLINK?
DEL frees the memory of each key before replying. For small strings that is instant; for a hash, list or set with many elements it can take long enough to show up as latency. UNLINK removes the key from the keyspace immediately and frees the memory in a background thread. For bulk deletes, prefer UNLINK (available since Redis 4.0).
Node.js: node-redis and ioredis
Both clients wrap the cursor loop for you, but node-redis changed what its iterator yields between major versions. In node-redis 4, scanIterator yields one key at a time; in node-redis 5 and later it yields an array of keys per SCAN reply (tested with 4.7.1, 5.12.1 and 6.3.0). Code written for v4 that collects keys into an array ends up with an array of arrays after an upgrade, so check this whenever you upgrade.
With ioredis, scanStream gives the same batches:
import Redis from "ioredis";
const redis = new Redis();
let deleted = 0;
for await (const keys of redis.scanStream({ match: "cache:report:*", count: 1000 })) {
if (keys.length) deleted += await redis.unlink(...keys);
}
Python: redis-py
scan_iter hides the cursor and yields single keys, so batch them yourself before calling unlink, as in the short answer. The asyncio client works the same way, with async for:
import redis.asyncio as redis
async def delete_by_pattern(r, pattern, count=1000, batch=500):
deleted, pending = 0, []
async for key in r.scan_iter(match=pattern, count=count):
pending.append(key)
if len(pending) >= batch:
deleted += await r.unlink(*pending)
pending.clear()
if pending:
deleted += await r.unlink(*pending)
return deleted
Redis Cluster: scan every primary
In a cluster, SCAN only covers the node you send it to. To delete by pattern across a cluster, run the scan against every primary node, and delete the keys through a cluster-aware client so that each key goes to the node that owns its slot. The cluster specification in the references explains how keys map to slots.
Better: designs that don’t need pattern deletes
Pattern deletes are usually a sign that the cache has no other way to forget things. Three designs avoid them.
1. Versioned namespaces. Put a version number in every key, keep the current version in one key, and invalidate by incrementing it. Old entries are never read again and expire through their TTL:
import redis
r = redis.Redis(decode_responses=True)
TTL = 3600 # entries from old versions are never read again and expire on their own
def cache_key(tenant, name):
# Start below 1: INCR on a missing key returns 1, so a default of "1" would never change.
version = r.get(f"cache-version:{tenant}") or "0"
return f"cache:{tenant}:v{version}:{name}"
def get_report(tenant, report_id, build):
key = cache_key(tenant, f"report:{report_id}")
cached = r.get(key)
if cached is not None:
return cached
value = build()
r.set(key, value, ex=TTL)
return value
def invalidate_tenant(tenant):
r.incr(f"cache-version:{tenant}") # one O(1) command, however many keys the tenant has
The comment on the default matters. With a default of "1", the first invalidation silently does nothing: INCR on a missing key creates it with the value 1, so the key name doesn’t change. A test that reads, invalidates and reads again catches it.
2. A set of keys per owner. When you cache data for a tenant, also add the key to a set such as cache-index:{tenant}. Invalidation reads that set and unlinks exactly those keys — no scanning.
3. Short TTLs on everything. If data that is a few minutes stale is acceptable, expiry alone may be enough.
Versioned namespaces are the simplest when you invalidate a whole group at once. Key sets suit invalidating a precise list of entries. Whichever you choose, keep SCAN + UNLINK for one-off cleanups and migrations.
Checklist
- Never run
KEYSagainst a production instance. - Use
SCANwithMATCH, and loop until the cursor is0— not until a reply is empty. - Delete in batches with
UNLINK. - Expect duplicates, and keys that change during the scan.
- From the shell, split on newlines, not spaces.
- In a cluster, scan every primary.
- If you delete by pattern often, change the key design instead.
References and further reading
- SCAN, Redis documentation
- UNLINK, Redis documentation
- KEYS, Redis documentation
- node-redis: v4 to v5 migration guide, Redis, on GitHub
- Redis cluster specification, Redis documentation