skip to content

Redis also offers HSCAN, SSCAN and ZSCAN for iterating inside a single collection. What do they return, and why does such a call sometimes hand back the entire collection at once with a cursor of 0?

level: seniorimportance: should knowfreq 32%

answer

  1. HSCAN pairs, ZSCAN member+score, SSCAN members
  2. HSCAN NOVALUES (7.4)
  3. listpack/intset → whole thing, cursor 0, COUNT ignored
  4. OBJECT ENCODING to check; conversion is one-way
  5. ZSCAN is hash order, not score order

basics

~20 s

They iterate one hash, set or sorted set instead of the keyspace — HSCAN returns field/value pairs, SSCAN members, ZSCAN member/score pairs — with the same cursor rules. When the collection is small it is stored as a flat compact array, not a hash table, so Redis returns all of it in one call with cursor 0 and ignores COUNT.

solid answer

~60 s

`HSCAN key cursor`, `SSCAN key cursor` and `ZSCAN key cursor` iterate the contents of one collection instead of the keyspace, with the same cursor contract: at-least-once, duplicates possible, stop at cursor `0`, and `MATCH`/`COUNT` behave the same way. Replies are flattened — HSCAN alternates field, value; ZSCAN alternates member, score; SSCAN returns bare members. Since Redis 7.4, `HSCAN ... NOVALUES` returns just the field names. The one-shot behaviour comes from encodings. A small hash, set or sorted set is stored as a **listpack** (or an **intset** for all-integer sets) — a compact flat array with no bucket structure to hold a cursor position in. There is nothing to iterate incrementally, so Redis returns the whole thing in the first call with cursor `0` and ignores `COUNT`. That is cheap while the collection is small, but it means these commands only bound reply size once the collection is large enough to be promoted to a real hash table or skiplist. The practical use is to avoid `HGETALL`/`SMEMBERS` on collections with millions of elements.

code

text · 19 lines
text
> RPUSH ignored x   # (setup elsewhere)
> HSET profile:1 name ada city london role eng
> OBJECT ENCODING profile:1
"listpack"
> HSCAN profile:1 0 COUNT 1
1) "0"                     # already finished
2) 1) "name"
   2) "ada"
   3) "city"
   4) "london"
   5) "role"
   6) "eng"

# Above hash-max-listpack-entries the key is promoted:
> OBJECT ENCODING big_hash
"hashtable"
> HSCAN big_hash 0 COUNT 100 NOVALUES     # 7.4+: field names only
1) "4096"
2) 1) "field:1" ...

go deeper

for a junior

Know that the three commands iterate inside a hash, set or sorted set instead of the keyspace, and that the reply for HSCAN/ZSCAN is flattened pairs.

for a middle

Add that they follow the same cursor contract as SCAN and that they are the safe replacement for HGETALL/SMEMBERS on collections of unbounded size.

for a senior

Explain the encoding-driven one-shot behaviour (listpack/intset, COUNT ignored, one-way conversion, OBJECT ENCODING), the testing trap it creates with small fixtures, and when ZRANGEBYSCORE beats ZSCAN.

for a principal

Set the policy: unbounded collections are a design smell; either cap them, shard them across keys, or mandate scan-based access, and tune the listpack thresholds knowingly as a memory-versus-CPU decision rather than leaving them incidental.

## What the type-scan commands are for `SCAN` walks the keyspace — the dictionary mapping key names to values. `HSCAN`, `SSCAN` and `ZSCAN` walk *inside* one value: the fields of a hash, the members of a set, the member/score pairs of a sorted set. They exist for the same reason `SCAN` does: the single-shot alternatives (`HGETALL`, `SMEMBERS`, `ZRANGE key 0 -1`) are O(N) commands that build the whole collection into one reply, and on a hash with ten million fields that is both a long occupancy of the server and a very large allocation. ## Reply shapes Each returns the usual two-element reply — next cursor, then the batch — but the batch is flattened: - `SSCAN key 0` → `member1, member2, ...` - `HSCAN key 0` → `field1, value1, field2, value2, ...` - `ZSCAN key 0` → `member1, score1, member2, score2, ...` Clients that naively treat the batch as a list of items will mangle HSCAN and ZSCAN results — a common bug when hand-rolling a driver. Since Redis 7.4, `HSCAN key cursor NOVALUES` returns only field names, which halves the bandwidth when you are enumerating fields rather than reading them (and is much cheaper than `HKEYS` on a huge hash). ## The cursor contract is identical Same guarantees as keyspace SCAN: an element present for the whole iteration is returned at least once; elements added or removed mid-iteration are undefined; duplicates are possible; the cursor is stateless; `MATCH` filters after retrieval; `COUNT` is a work hint; iteration ends only at cursor `0`. If the key is deleted mid-iteration, subsequent calls simply behave as if scanning an empty collection (cursor `0`, empty batch) — they do not error. ## Why a scan can return everything at once Redis stores each collection type in one of two representations, chosen automatically by size: - **Compact/flat**: a **listpack** for small hashes, small sorted sets and small non-integer sets, or an **intset** for a small set whose members are all integers. These are contiguous byte arrays scanned linearly. (Before Redis 7.0 the compact encoding was called a ziplist; listpack replaced it.) - **Full**: a hash table for large hashes and sets, and a hash table plus a skiplist for large sorted sets. The thresholds are configurable — `hash-max-listpack-entries` / `hash-max-listpack-value`, `set-max-intset-entries`, `set-max-listpack-entries`, `zset-max-listpack-entries` / `zset-max-listpack-value` — and conversion is one-way: once a collection outgrows the threshold it is converted and never converts back, even if elements are removed. A flat encoding has no bucket array, so there is no position for a cursor to encode. Redis therefore does the only sane thing: it returns **all** elements in the first call with cursor `0`, ignoring `COUNT`. Since the collection is by definition small (typically ≤128 entries by default), this is a trivially cheap O(N) command. The important consequence is a *false sense of boundedness*: `HSCAN h 0 COUNT 10` on a listpack-encoded hash returns everything, so testing your batching logic against small fixtures will never exercise the multi-call path. Write tests with a collection above the threshold, or lower the threshold in the test config. You can see which representation a key uses with `OBJECT ENCODING`. ## Where this matters operationally The practical rule is: **never call `HGETALL`, `SMEMBERS` or a full-range `ZRANGE` on a collection whose size you do not control.** A user-generated set that grows without bound will, one day, be enumerated by code that was written when it had twelve members. Type scans are the safe form. They are also how you delete an enormous collection field-by-field if you want to avoid a single large free, though `UNLINK` on the whole key is usually the better answer. For sorted sets there is a second reason to prefer other commands: `ZSCAN` iterates in hash-table order, not score order, so if you want ordered slices use `ZRANGEBYSCORE`/`ZRANGE ... LIMIT` with paging on the score, which is O(log N + M) and gives you a meaningful order. `ZSCAN` is for "visit everything", not "visit in order". ## What an interviewer wants to hear That these commands share SCAN's semantics, that reply shapes are flattened pairs, and — the discriminating detail — that small collections come back in one call because their compact encoding has no incremental iteration position, so `COUNT` is ignored and boundedness only kicks in above the configured thresholds.

  • Why is HSCAN safer than HGETALL for a hash you did not size yourself?
    HGETALL is a single O(N) command that materialises every field and value into one reply, so a hash that has quietly grown to millions of fields occupies the server for the whole traversal and allocates a very large output buffer. HSCAN performs the same total work but in bounded slices, keeping per-command latency and reply size small, at the cost of at-least-once semantics.
  • Can you rely on ZSCAN to walk a sorted set in score order?
    No. ZSCAN iterates the underlying hash table, so members come back in an arbitrary order; the score is returned alongside each member but does not drive the traversal. For ordered access use ZRANGE/ZRANGEBYSCORE with LIMIT and page by score or rank, which is both ordered and efficient.

saying these in an interview costs you the question

  • "HSCAN with COUNT 10 always caps the reply at ~10 entries" — for listpack-encoded collections COUNT is ignored and everything comes back.
  • "ZSCAN returns members in score order" — it iterates hash order.
  • "HSCAN returns a list of fields" — it returns flattened field/value pairs unless NOVALUES is used.
  • "A collection converts back to the compact encoding when it shrinks" — encoding conversion is one-way.
  • "SSCAN on a missing key raises an error" — it returns cursor 0 and an empty batch.

context