Backend Development

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:

JavaScript
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:

Python
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:

Shell
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:

Shell
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

  • SCAN returns a cursor and a batch of keys. You call it again with the cursor until the cursor comes back as 0.
  • COUNT is a hint for how much work each call does, not how many keys come back.
  • MATCH is 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 — UNLINK returns 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:

Python
# 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 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:

JavaScript
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:

Python
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:

Python
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

  1. Never run KEYS against a production instance.
  2. Use SCAN with MATCH, and loop until the cursor is 0 — not until a reply is empty.
  3. Delete in batches with UNLINK.
  4. Expect duplicates, and keys that change during the scan.
  5. From the shell, split on newlines, not spaces.
  6. In a cluster, scan every primary.
  7. If you delete by pattern often, change the key design instead.

References and further reading

  1. SCAN, Redis documentation
  2. UNLINK, Redis documentation
  3. KEYS, Redis documentation
  4. node-redis: v4 to v5 migration guide, Redis, on GitHub
  5. Redis cluster specification, Redis documentation

Written by

Thanush BD

Software engineer in Bengaluru working where software meets environmental engineering. At Ecologic Engineering I build audit, compliance, monitoring and water-treatment software — mostly backend: data models, APIs, pipelines and scheduled jobs.

Related case studies

  • Software Engineering

    How to Approach an Unfamiliar Coding Problem

    A repeatable process for problems you have never seen: restate the task, pin down constraints, work examples by hand, start simple, then improve and test.

    7 min read

    • problem-solving
    • algorithms
    • testing
    • python