SCAN & Key Operations
Verified on Redis 8.10.2 (
redis:8.10.2-alpine), ioredis 6.0.0, Node 24, Apple-silicon Mac. SCAN cursors and the order of returned keys are not stable between runs; the numbers you see will differ from the ones below, and the harness ignores them on purpose.
Core key operations
Section titled “Core key operations”These commands operate on any key regardless of its type.
EXISTS
Section titled “EXISTS”Returns 1 if the key exists, 0 if it does not. You can check multiple keys at once — the return value is the count of keys that exist (a key named twice counts twice).
127.0.0.1:6379> SET user:1 "Ada"OK127.0.0.1:6379> EXISTS user:1(integer) 1127.0.0.1:6379> EXISTS user:99(integer) 0127.0.0.1:6379> EXISTS user:1 user:99 user:1(integer) 2DEL and UNLINK
Section titled “DEL and UNLINK”DEL deletes one or more keys and returns the count of keys that were actually deleted (keys that did not exist are not counted).
127.0.0.1:6379> SET a "1"OK127.0.0.1:6379> SET b "2"OK127.0.0.1:6379> DEL a b nonexistent(integer) 2127.0.0.1:6379> EXISTS a(integer) 0127.0.0.1:6379> SET junk "x"OK127.0.0.1:6379> UNLINK junk nonexistent(integer) 1UNLINK returns the same counts but does the expensive part — freeing memory — on a background thread. For a small string the difference is nil. For a big value it is dramatic. I built a set with 2,000,000 members (70 MB by MEMORY USAGE) and timed the server-side cost with SLOWLOG (threshold 0):
| Command on a 2M-member set | Time the command thread was blocked |
|---|---|
DEL b1:big1 | 133,902 µs (134 ms) |
UNLINK b1:big2 | 11 µs |
That is the “single command thread” cost from the What is Redis lesson in numbers: DEL on a big collection freezes every other client for 134 ms; UNLINK returned immediately and the memory was reclaimed afterwards by the bio_lazy_free thread (INFO memory then shows lazyfreed_objects). Prefer UNLINK for anything that might be large. (Redis can also do this automatically for DEL, expiry, and eviction via the lazyfree-lazy-* settings.)
Returns the data type of the value stored at a key: string, list, set, zset, hash, or stream.
127.0.0.1:6379> SET mystr "hello"OK127.0.0.1:6379> TYPE mystrstring127.0.0.1:6379> TYPE nonexistent:keynone127.0.0.1:6379> DEL mystr(integer) 1RENAME
Section titled “RENAME”Renames a key. If the destination key already exists, it is overwritten. Use RENAMENX to rename only if the destination does not exist. The value keeps its TTL (the countdown moves with it), and renaming a missing key is an error:
127.0.0.1:6379> SET old:key "value" EX 100OK127.0.0.1:6379> RENAME old:key new:keyOK127.0.0.1:6379> EXISTS old:key(integer) 0127.0.0.1:6379> GET new:key"value"127.0.0.1:6379> TTL new:key(integer) 100127.0.0.1:6379> RENAME nokey other(error) ERR no such key127.0.0.1:6379> SET dest "keepme"OK127.0.0.1:6379> SET src "fromsrc"OK127.0.0.1:6379> RENAMENX src dest(integer) 0127.0.0.1:6379> GET dest"keepme"127.0.0.1:6379> RENAME src destOK127.0.0.1:6379> GET dest"fromsrc"127.0.0.1:6379> DEL new:key dest(integer) 2RENAME src dest silently replaced dest — the old value of dest is gone. RENAME is atomic, which is why “write to a temp key, then rename over the live one” is the standard way to publish a rebuilt value with no half-written moment.
SET user:1 "Ada"
EXISTS user:1
EXISTS user:99
SET a "1"
SET b "2"
DEL a b nonexistent
SET mystr "hello"
TYPE mystr
SET old:key "value"
RENAME old:key new:key
GET new:keyKEYS — and why you must not use it in production
Section titled “KEYS — and why you must not use it in production”KEYS pattern returns all keys matching a glob-style pattern. It is very convenient for development and debugging.
127.0.0.1:6379> MSET user:2 "Bob" session:abc "tok" session:xyz "tok2"OK127.0.0.1:6379> KEYS user:*1) "user:1"2) "user:2"127.0.0.1:6379> KEYS *1) "user:1"2) "user:2"3) "session:abc"4) "session:xyz"The order is whatever the hash table gives you — never rely on it. KEYS is a blocking, O(N) scan of the entire keyspace. Measured on 8.10.2 (the First Commands lesson has the table): 16 ms of frozen server at 200,000 keys, 156 ms at 1,000,000. Never run it against a production server — use SCAN instead.
SCAN — safe keyspace iteration
Section titled “SCAN — safe keyspace iteration”SCAN is a cursor-based iterator. Each call processes a small batch of keys and returns:
- A new cursor (to pass to the next call)
- A list of keys from this batch
When the returned cursor is 0, the iteration is complete. Here is a real full walk over ten keys with COUNT 3 (your cursors and key order will differ):
127.0.0.1:6379> MSET k1 a k2 b k3 c k4 d k5 e k6 f k7 g k8 h k9 i k10 jOK127.0.0.1:6379> SCAN 0 MATCH k* COUNT 31) "2"2) 1) "k8" 2) "k1"127.0.0.1:6379> SCAN 2 MATCH k* COUNT 31) "6"2) 1) "k7" 2) "k3" 3) "k10" 4) "k4"127.0.0.1:6379> SCAN 6 MATCH k* COUNT 31) "9"2) 1) "k9"127.0.0.1:6379> SCAN 9 MATCH k* COUNT 31) "15"2) 1) "k5" 2) "k2" 3) "k6"127.0.0.1:6379> SCAN 15 MATCH k* COUNT 31) "0"2) (empty array)Three things to read off. The cursors (2, 6, 9, 15) are opaque — do not do arithmetic on them, and never invent one. COUNT is a hint: the calls returned 2, 4, 1 and 3 keys. And the last call returned an empty list with cursor 0: an empty batch does not mean “done” (a non-zero cursor with an empty batch is normal, because MATCH filters after sampling); only cursor 0 means done. A loop that stops on “empty result” would silently skip keys.
127.0.0.1:6379> SCAN 0 MATCH k* COUNT 1001) "0"2) 1) "k8" 2) "k1" 3) "k7" 4) "k3" 5) "k10" 6) "k4" 7) "k9" 8) "k5" 9) "k2" 10) "k6"With a COUNT bigger than the keyspace, one call finds everything and returns cursor 0.
MSET k1 a k2 b k3 c k4 d k5 e k6 f k7 g k8 h k9 i k10 j
SCAN 0 MATCH k* COUNT 3
SCAN 0 MATCH k* COUNT 100SCAN options
Section titled “SCAN options”| Option | Description |
|---|---|
MATCH pattern | Filter results by glob pattern (filtering happens after sampling) |
COUNT hint | Suggest how many items to sample per call (default 10) |
TYPE type | Only return keys of a specific type (Redis 6.0+) |
127.0.0.1:6379> RPUSH mylist a(integer) 1127.0.0.1:6379> SCAN 0 COUNT 100 TYPE list1) "0"2) 1) "mylist"127.0.0.1:6379> SCAN 0 COUNT 100 TYPE string MATCH user:*1) "0"2) 1) "user:1" 2) "user:2"redis-cli --scan --pattern 'k*' runs the whole loop for you and prints one key per line (--scan --pattern 'b1:*' on a two-key test printed b1:b, b1:a). Keep calling SCAN with the returned cursor until the cursor comes back as 0, and collect results across all iterations.
SCAN 0 MATCH session:* COUNT 100What SCAN guarantees when the keyspace changes
Section titled “What SCAN guarantees when the keyspace changes”The official contract (SCAN docs): a full iteration returns every element that was present for the whole iteration; elements added or removed during it may or may not be returned; and the same element may be returned more than once. Measured with ioredis on 8.10.2: 20,000 stable keys, and 50 new keys written after every SCAN ... COUNT 100 call:
// @runimport { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);await redis.flushdb();
// 20,000 keys that exist for the whole scanconst seed = redis.pipeline();for (let i = 0; i < 20000; i++) seed.set(`b1:stable:${i}`, '1');await seed.exec();
const seen = new Map<string, number>();let cursor = '0', calls = 0, extra = 0;do { const [next, batch] = await redis.scan(cursor, 'MATCH', 'b1:*', 'COUNT', 100); for (const k of batch) seen.set(k, (seen.get(k) ?? 0) + 1); cursor = next; calls++; // meanwhile the keyspace keeps changing: 50 new keys per call, and the scan grows the table const w = redis.pipeline(); for (let j = 0; j < 50; j++) { w.set(`b1:new:${calls}:${j}`, '1'); extra++; } await w.exec();} while (cursor !== '0');
let missing = 0;for (let i = 0; i < 20000; i++) if (!seen.has(`b1:stable:${i}`)) missing++;const dupes = [...seen.values()].filter((n) => n > 1).length;console.log(`grow: ${calls} calls, stable keys missing: ${missing}, keys returned twice: ${dupes}, new keys seen: ${[...seen.keys()].filter((k) => k.startsWith('b1:new')).length} of ${extra}`);
// shrink: delete 95% of the keys halfway through, so Redis resizes the table downawait redis.flushdb();const seed2 = redis.pipeline();for (let i = 0; i < 100000; i++) seed2.set(`b1:s:${i}`, '1');await seed2.exec();const keep = new Set<string>();for (let i = 0; i < 100000; i += 20) keep.add(`b1:s:${i}`); // 5% surviveconst seen2 = new Map<string, number>();cursor = '0'; calls = 0; let shrunk = false;do { const [next, batch] = await redis.scan(cursor, 'COUNT', 1000); for (const k of batch) seen2.set(k, (seen2.get(k) ?? 0) + 1); cursor = next; calls++; if (calls === 20 && !shrunk) { shrunk = true; const d = redis.pipeline(); for (let i = 0; i < 100000; i++) if (!keep.has(`b1:s:${i}`)) d.del(`b1:s:${i}`); await d.exec(); }} while (cursor !== '0');let missing2 = 0;for (const k of keep) if (!seen2.has(k)) missing2++;console.log(`shrink: ${calls} calls, surviving keys missing: ${missing2}, keys returned twice: ${[...seen2.values()].filter((n) => n > 1).length}`);
await redis.flushdb();await redis.quit();Real output: grow: 258 calls, stable keys missing: 0, keys returned twice: 0, new keys seen: 5900 of 12900 and shrink: 24 calls, surviving keys missing: 0, keys returned twice: 0. The guarantee held both times: no stable key was missed, and only 5,900 of the 12,900 keys created mid-scan showed up (new keys are optional). I did not manage to provoke a duplicate on 8.10.2 — but the contract allows it, so code that must not process a key twice has to dedupe (a Set) or be idempotent. Do not read “0 duplicates here” as a guarantee.
Reading AI-generated code
Section titled “Reading AI-generated code”An assistant is asked for “a function that clears all cache entries” and writes:
🐛 Spot the bug
async function purge(pattern: string) {// find all matching keys and delete themconst keys = await redis.keys(pattern);return redis.del(...keys);}
await purge('cache:*');Show the answer
Two bugs, both real. First, when nothing matches, keys is [] and del() is sent with no key: we ran it and got ERR wrong number of arguments for 'del' command — the cleanup job crashes on exactly the day the cache was already empty. Second, KEYS freezes Redis for the whole scan (156 ms per million keys) and returns every name over the network, and DEL on big values blocks again. Spreading a million-element array into arguments can also exceed limits. The fix: SCAN in batches, dedupe, skip empty batches, and UNLINK:
// @runimport { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
// the AI-written version: KEYS + DELasync function purgeBuggy(pattern: string) { const keys = await redis.keys(pattern); return redis.del(...keys);}console.log('nothing to purge ->', await purgeBuggy('b1:cache:*').catch((e: Error) => e.message));
// seed 5000 keys, plus 5 keys that must surviveconst seed = redis.pipeline();for (let i = 0; i < 5000; i++) seed.set(`b1:cache:${i}`, '1');for (let i = 0; i < 5; i++) seed.set(`b1:keep:${i}`, '1');await seed.exec();
// the fix: SCAN in batches, UNLINK each batch (UNLINK frees memory off the main thread)async function purge(pattern: string) { let removed = 0; for await (const batch of redis.scanStream({ match: pattern, count: 500 })) { const keys = [...new Set(batch as string[])]; // SCAN may return a key more than once if (keys.length === 0) continue; // empty batches are normal removed += await redis.unlink(...keys); } return removed;}console.log('purged:', await purge('b1:cache:*'), 'left:', await redis.dbsize()); // 5000, 5
await redis.flushdb();await redis.quit();Real output: nothing to purge -> ERR wrong number of arguments for 'del' command, then purged: 5000 left: 5.
Mental Model
Section titled “Mental Model”KEYS is reading the entire phone book aloud while everyone waits; SCAN is flipping a few pages at a time and leaving a bookmark (the cursor). The bookmark is a promise, not a snapshot: anyone who was in the book the whole time will be read out at least once; people who move in or out meanwhile may or may not be; and after a reorganisation you may hear a name twice. The bookmark is opaque — you can only hand it back. And deleting is the same story again: DEL tears out a huge page yourself while everyone waits, UNLINK hands it to a helper.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
SCAN (cursor-based iteration) | Never blocks the server, spreads work into small batches, safe for production | No exact snapshot — keys added/removed mid-scan may be missed, and a key may appear twice; total wall time is longer than KEYS (1.3 s vs 0.6 s at 1M keys) |
KEYS * (full blocking scan) | Simple, one call gets the complete result, fine for a small dev dataset | Blocks the command thread until every key is scanned (156 ms at 1M keys); never use on production with many keys |
Set a high COUNT in SCAN to reduce round trips | Fewer round trips, faster overall | Each round does more work, which can affect the latency of other commands running concurrently |
UNLINK instead of DEL | Frees memory in the background; 11 µs vs 134 ms on a 2M-member set | The memory is not released instantly; you need Redis 4.0+ |
Common Misconceptions
Section titled “Common Misconceptions”- “
COUNT 10returns 10 keys.” It is a hint; we got 2, 4, 1 and 3 withCOUNT 3. - “An empty
SCANreply means the scan is over.” Only cursor0does. - “
SCANgives a consistent snapshot.” It gives a coverage guarantee for stable keys, nothing more. - “
DELis always cheap.” It is O(1) for a string but O(N) in the elements of a collection; 134 ms for 2M members.
Common mistakes
Section titled “Common mistakes”- Using
KEYS *to see all keys in production — this blocks the server until every key is scanned. On a large dataset it can stall other clients for seconds. Always useSCANinstead. - Assuming
COUNTis a hard limit on how many keys come back —COUNTis only a hint. Redis may return more or fewer keys per round. - Stopping the
SCANloop before the cursor returns to0— this produces an incomplete result. The iteration is only complete once the returned cursor is0. - Calling
DELwith an empty spread —redis.del(...[])is an error, not a no-op. Guard withif (keys.length).