Redis SCAN vs KEYS: Safe Key Iteration in Production

KEYS * is the most dangerous command you can run in production Redis. It blocks the server while scanning the entire keyspace. This guide shows you the safe alternative: the SCAN family of commands.

The Problem

You need to find all keys matching a pattern. Maybe you're cleaning up expired cache keys, migrating data, or debugging. The obvious approach:

KEYS cache:user:*

This works fine with 100 keys. With 10 million keys, it blocks Redis for seconds — no other commands can execute. Your entire application freezes.

Why KEYS Is Dangerous

KEYS has O(N) time complexity where N is the total number of keys in the database. It runs synchronously, blocking the single-threaded Redis event loop.

SET key:1 "a"
SET key:2 "b"
SET key:3 "c"
KEYS key:*

This looks harmless. But in production with millions of keys, KEYS * can:

  • Block Redis for 1–10+ seconds.
  • Cause all connected clients to timeout.
  • Trigger cascading failures in your application.
  • Trip circuit breakers and health checks.

Redis documentation itself says: "Don't use KEYS in production."

SCAN: The Safe Alternative

SCAN iterates the keyspace incrementally using a cursor. It returns a small batch of keys per call and never blocks for long.

Basic Usage

SET user:1001 "Alice"
SET user:1002 "Bob"
SET user:1003 "Charlie"
SET product:2001 "Widget"
SET product:2002 "Gadget"
SET cache:temp:1 "data1"
SET cache:temp:2 "data2"

SCAN 0 COUNT 3

SCAN returns two things:

  1. A cursor (use it in the next SCAN call).
  2. A batch of keys.

When the cursor returns 0, the iteration is complete.

Full Iteration

SCAN 0 COUNT 3

Take the cursor from the result and use it in the next call:

SCAN <cursor_from_previous> COUNT 3

Repeat until the cursor returns 0.

Pattern Matching

SCAN 0 MATCH user:* COUNT 10

Returns only keys matching the user:* pattern. But note: SCAN applies the pattern filter AFTER fetching the batch, so some calls may return zero results even when matching keys exist.

TYPE Filtering (Redis 6.0+)

SCAN 0 TYPE string COUNT 10
SCAN 0 TYPE hash COUNT 10
SCAN 0 TYPE zset COUNT 10

Filter by data type. Useful when you only want to find, say, all Hashes.

HSCAN: Iterate Hash Fields

For large Hashes, HGETALL is the equivalent of KEYS * — it blocks. Use HSCAN instead:

HSET user:profile name "Alice" age "30" email "alice@example.com" role "admin" city "Springfield" country "US"
HSCAN user:profile 0 COUNT 2

With Pattern Matching

HSCAN user:profile 0 MATCH *a* COUNT 10

Returns only fields matching the pattern.

SSCAN: Iterate Set Members

SADD tags "redis" "database" "cache" "nosql" "performance" "tutorial" "guide"
SSCAN tags 0 COUNT 3
SSCAN tags 0 MATCH *re* COUNT 10

ZSCAN: Iterate Sorted Set Members

ZADD leaderboard 100 "alice" 200 "bob" 150 "charlie" 300 "diana" 250 "eve"
ZSCAN leaderboard 0 COUNT 2
ZSCAN leaderboard 0 MATCH *a* COUNT 10

Returns members with their scores.

The COUNT Parameter

COUNT is a hint, not a guarantee. It tells Redis approximately how many elements to scan per iteration.

SET a "1"
SET b "2"
SET c "3"
SET d "4"
SET e "5"
SCAN 0 COUNT 2

Redis may return more or fewer than COUNT elements. The default is 10.

Choosing COUNT

  • Small databases (< 10K keys): COUNT 100 is fine.
  • Medium databases (10K–1M keys): COUNT 1000 balances speed and responsiveness.
  • Large databases (> 1M keys): COUNT 5000–10000 for faster iteration, but monitor latency.

Practical Patterns

Delete Keys by Pattern

The safe way to delete all keys matching a pattern:

SET cache:temp:1 "data1"
SET cache:temp:2 "data2"
SET cache:temp:3 "data3"
SET cache:temp:4 "data4"

SCAN 0 MATCH cache:temp:* COUNT 100

In your application, loop:

  1. SCAN cursor MATCH pattern COUNT 100
  2. DEL (or UNLINK) the returned keys.
  3. Repeat until cursor is 0.
DEL cache:temp:1 cache:temp:2
SCAN 0 MATCH cache:temp:* COUNT 100
DEL cache:temp:3 cache:temp:4

Count Keys by Pattern

SCAN 0 MATCH user:* COUNT 100

Accumulate the count across all iterations. There's no built-in "count matching keys" command.

Find Keys Without TTL

Iterate and check each key:

SET permanent:key "no ttl"
SET temp:key "has ttl" EX 300

TTL permanent:key
TTL temp:key

In your script: SCAN, then check TTL for each key. If TTL returns -1, the key has no expiration.

Migrate Keys Between Databases

SCAN 0 MATCH migrate:* COUNT 100

For each key, DUMP + RESTORE to the target database or instance.

Guarantees and Limitations

What SCAN Guarantees

  • Every key that exists from start to finish of the iteration will be returned.
  • The iteration will eventually complete (cursor returns 0).

What SCAN Does NOT Guarantee

  • No duplicates: A key may be returned more than once. Your application must handle deduplication.
  • No consistency: Keys added or deleted during iteration may or may not appear.
  • Exact COUNT: The number of returned elements per call varies.

Handling Duplicates

SET key:1 "a"
SET key:2 "b"
SET key:3 "c"
SCAN 0 COUNT 2

Use a Set in your application to track seen keys:

# Pseudocode
seen = set()
cursor = 0
while True:
    cursor, keys = redis.scan(cursor, count=100)
    for key in keys:
        if key not in seen:
            seen.add(key)
            process(key)
    if cursor == 0:
        break

Performance Comparison

CommandTime ComplexityBlockingProduction Safe
KEYS *O(N)Yes (full scan)No
SCANO(1) per callNo (incremental)Yes
HGETALLO(N)Yes (full hash)Risky for large hashes
HSCANO(1) per callNo (incremental)Yes

Pitfalls

  1. Forgetting to handle cursor 0: The iteration is only complete when the cursor returns 0. Don't stop after a fixed number of calls.
  2. Ignoring duplicates: SCAN can return the same key twice. Always deduplicate in your application.
  3. Using KEYS in scripts: Even in Lua scripts, KEYS blocks. Use SCAN in your application code instead.
  4. Too small COUNT: COUNT 1 makes iteration extremely slow on large databases. Use at least COUNT 100.
  5. Pattern matching overhead: Complex glob patterns (e.g., *foo*bar*) are slower. Keep patterns simple.
  6. MATCH returns empty batches: SCAN may return empty arrays even when matching keys exist. This is normal — keep iterating until cursor is 0.
SET user:1 "a"
SET product:1 "b"
SET product:2 "c"
SET product:3 "d"
SCAN 0 MATCH user:* COUNT 2

You might get an empty result if the sampled batch contained only product:* keys. The next SCAN call may find user:1.

When KEYS Is Actually OK

  • Development/debugging on a local instance.
  • Redis instances with < 1000 keys.
  • One-time scripts on a replica (not the primary).
  • Inside redis-cli for quick inspection of small databases.
SET test:1 "a"
SET test:2 "b"
SET test:3 "c"
KEYS test:*
DBSIZE

Try It in the Editor

Head to the Redis Online Editor and practice safe iteration:

SET user:1001 "Alice"
SET user:1002 "Bob"
SET user:1003 "Charlie"
SET product:2001 "Widget"
SET product:2002 "Gadget"
SET cache:temp:1 "data"

SCAN 0 MATCH user:* COUNT 10
SCAN 0 MATCH product:* COUNT 10
SCAN 0 COUNT 3

HSET profile name "Alice" age "30" email "alice@example.com"
HSCAN profile 0 COUNT 2

ZADD scores 100 "alice" 200 "bob" 150 "charlie"
ZSCAN scores 0 COUNT 2

Compare KEYS * with SCAN 0 COUNT 10 — both return all keys, but SCAN does it safely in batches. That's the difference between a smooth production system and a 3 AM incident.