Skip to content

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.

These commands operate on any key regardless of its type.

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).

cli/keys-strings-scan-keys-1.redis
127.0.0.1:6379> SET user:1 "Ada"
OK
127.0.0.1:6379> EXISTS user:1
(integer) 1
127.0.0.1:6379> EXISTS user:99
(integer) 0
127.0.0.1:6379> EXISTS user:1 user:99 user:1
(integer) 2

DEL deletes one or more keys and returns the count of keys that were actually deleted (keys that did not exist are not counted).

cli/keys-strings-scan-keys-2.redis
127.0.0.1:6379> SET a "1"
OK
127.0.0.1:6379> SET b "2"
OK
127.0.0.1:6379> DEL a b nonexistent
(integer) 2
127.0.0.1:6379> EXISTS a
(integer) 0
127.0.0.1:6379> SET junk "x"
OK
127.0.0.1:6379> UNLINK junk nonexistent
(integer) 1

UNLINK 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 setTime the command thread was blocked
DEL b1:big1133,902 µs (134 ms)
UNLINK b1:big211 µ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.

cli/keys-strings-scan-keys-3.redis
127.0.0.1:6379> SET mystr "hello"
OK
127.0.0.1:6379> TYPE mystr
string
127.0.0.1:6379> TYPE nonexistent:key
none
127.0.0.1:6379> DEL mystr
(integer) 1

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:

cli/keys-strings-scan-keys-4.redis
127.0.0.1:6379> SET old:key "value" EX 100
OK
127.0.0.1:6379> RENAME old:key new:key
OK
127.0.0.1:6379> EXISTS old:key
(integer) 0
127.0.0.1:6379> GET new:key
"value"
127.0.0.1:6379> TTL new:key
(integer) 100
127.0.0.1:6379> RENAME nokey other
(error) ERR no such key
127.0.0.1:6379> SET dest "keepme"
OK
127.0.0.1:6379> SET src "fromsrc"
OK
127.0.0.1:6379> RENAMENX src dest
(integer) 0
127.0.0.1:6379> GET dest
"keepme"
127.0.0.1:6379> RENAME src dest
OK
127.0.0.1:6379> GET dest
"fromsrc"
127.0.0.1:6379> DEL new:key dest
(integer) 2

RENAME 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:key

KEYS — 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.

cli/keys-strings-scan-keys-5.redis
127.0.0.1:6379> MSET user:2 "Bob" session:abc "tok" session:xyz "tok2"
OK
127.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 is a cursor-based iterator. Each call processes a small batch of keys and returns:

  1. A new cursor (to pass to the next call)
  2. 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):

cli/keys-strings-scan-keys-6.redis
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 j
OK
127.0.0.1:6379> SCAN 0 MATCH k* COUNT 3
1) "2"
2) 1) "k8"
2) "k1"
127.0.0.1:6379> SCAN 2 MATCH k* COUNT 3
1) "6"
2) 1) "k7"
2) "k3"
3) "k10"
4) "k4"
127.0.0.1:6379> SCAN 6 MATCH k* COUNT 3
1) "9"
2) 1) "k9"
127.0.0.1:6379> SCAN 9 MATCH k* COUNT 3
1) "15"
2) 1) "k5"
2) "k2"
3) "k6"
127.0.0.1:6379> SCAN 15 MATCH k* COUNT 3
1) "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.

cli/keys-strings-scan-keys-7.redis
127.0.0.1:6379> SCAN 0 MATCH k* COUNT 100
1) "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 100
OptionDescription
MATCH patternFilter results by glob pattern (filtering happens after sampling)
COUNT hintSuggest how many items to sample per call (default 10)
TYPE typeOnly return keys of a specific type (Redis 6.0+)
cli/keys-strings-scan-keys-8.redis
127.0.0.1:6379> RPUSH mylist a
(integer) 1
127.0.0.1:6379> SCAN 0 COUNT 100 TYPE list
1) "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 100

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

src/keys-strings-scan-keys-mutation.ts
// @run
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
await redis.flushdb();
// 20,000 keys that exist for the whole scan
const 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 down
await 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% survive
const 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.

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 them
const 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:

src/keys-strings-scan-keys-purge.ts
// @run
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
// the AI-written version: KEYS + DEL
async 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 survive
const 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.

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.

ChoiceBenefitCost
SCAN (cursor-based iteration)Never blocks the server, spreads work into small batches, safe for productionNo 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 datasetBlocks 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 tripsFewer round trips, faster overallEach round does more work, which can affect the latency of other commands running concurrently
UNLINK instead of DELFrees memory in the background; 11 µs vs 134 ms on a 2M-member setThe memory is not released instantly; you need Redis 4.0+
  • “COUNT 10 returns 10 keys.” It is a hint; we got 2, 4, 1 and 3 with COUNT 3.
  • “An empty SCAN reply means the scan is over.” Only cursor 0 does.
  • “SCAN gives a consistent snapshot.” It gives a coverage guarantee for stable keys, nothing more.
  • “DEL is always cheap.” It is O(1) for a string but O(N) in the elements of a collection; 134 ms for 2M members.
  • 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 use SCAN instead.
  • Assuming COUNT is a hard limit on how many keys come back — COUNT is only a hint. Redis may return more or fewer keys per round.
  • Stopping the SCAN loop before the cursor returns to 0 — this produces an incomplete result. The iteration is only complete once the returned cursor is 0.
  • Calling DEL with an empty spread — redis.del(...[]) is an error, not a no-op. Guard with if (keys.length).
What does SCAN return when the full iteration is complete?
Why is KEYS dangerous on a production Redis server?
What does EXISTS user:1 user:1 user:2 return if user:1 exists and user:2 does not?
What does TYPE return for a key that does not exist?
During a full SCAN, some keys are added and others exist the whole time. Which is guaranteed?
You must delete a 2-million-member set on a live server. Which is best?