How to List and Find Keys in Valkey and Redis: KEYS, SCAN, and Pattern Matching in Java

Last updated
August 7, 2026

To list every key in a Valkey or Redis database, use KEYS *. To do it without risking an outage, use SCAN.

127.0.0.1:6379> KEYS *
1) "user:1042"
2) "session:a7f3"
3) "cart:1042"

127.0.0.1:6379> SCAN 0 MATCH user:* COUNT 100
1) "17"
2) 1) "user:1042"
   2) "user:1043"

Both commands answer the same question. Only one of them is safe to run against production. This guide covers when each applies, how glob patterns work, how the SCAN cursor stays correct while the table underneath it is being resized, when to reach for HSCAN, SSCAN and ZSCAN instead, what changes in a cluster, and how to do all of it from Java.

The KEYS Command

KEYS takes a single glob-style pattern and returns every matching key name.

KEYS *              # every key in the current database
KEYS user:*         # every key beginning with "user:"
KEYS session:??     # "session:" followed by exactly two characters
KEYS product:[0-9]* # "product:" followed by a digit, then anything

The supported pattern syntax is small:

PatternMatches
*Any sequence of characters, including none
?Exactly one character
[ae]One character from the set — a or e
[^a]One character not in the set
[a-c]One character in the range
\*A literal asterisk (backslash escapes)

An empty result is not an error — you get an empty array back, which is what "no keys matched" looks like.

The pattern is matched against key names only. There is no way to filter by value, and no way to sort or paginate the output. KEYS gives you everything at once or nothing.

Why KEYS Is Dangerous in Production

Valkey and Redis execute commands on a single thread. KEYS is O(N) over the entire keyspace — it walks every key, compares it against the pattern, and builds the reply. While that happens, every other client waits.

On a keyspace of a few thousand keys this is imperceptible. On a few million, it can block the server for hundreds of milliseconds. Every request from every application instance stalls for the duration, connection pools back up, timeouts start firing, and health checks fail. The KEYS call itself succeeds, which is what makes the failure confusing to diagnose afterwards.

There is a second problem. The reply is built in memory as one array. KEYS * against a large keyspace can produce a response of hundreds of megabytes, which strains both the server's output buffer and your client.

The official Redis documentation is direct about this: KEYS is intended for debugging and special operations, not for regular application code.

That leaves a few legitimate uses:

  • Local development and debugging
  • A keyspace you know is small and will stay small
  • Ad-hoc inspection against a replica, where blocking affects nobody

For everything else, use SCAN.

SCAN: The Safe Alternative

SCAN returns keys in small batches using a cursor. Each call does a bounded amount of work and returns immediately, so the server stays responsive between iterations.

127.0.0.1:6379> SCAN 0
1) "17"                    # next cursor
2) 1) "user:1042"          # this batch
   2) "session:a7f3"

127.0.0.1:6379> SCAN 17
1) "0"                     # cursor 0 means iteration complete
2) 1) "cart:1042"

Start at cursor 0, pass the returned cursor to the next call, and stop when the server returns 0 again.

Three options shape the iteration:

  • MATCH pattern — filters using the same glob syntax as KEYS. Filtering happens after keys are fetched, so a restrictive pattern doesn't make the scan cheaper; it just returns fewer results per batch. Batches can come back empty while the iteration is still running.
  • COUNT n — a hint for how much work to do per call, defaulting to 10. It is not a limit on results. Raising it to a few hundred reduces round trips at the cost of slightly longer individual calls.
  • TYPE type — returns only keys of a given type, such as list or hash. Added in Redis 6.0. Like MATCH, it filters after the fact: the server still walks the same number of buckets, so TYPE narrows the output rather than the work. It is specific to SCANHSCAN, SSCAN and ZSCAN do not accept it.

Note that COUNT and MATCH interact in a way that surprises people. Because filtering happens after the batch is fetched, SCAN 0 MATCH user:* COUNT 1000 against a keyspace where one key in ten thousand matches will return empty batch after empty batch — with a non-zero cursor each time. An empty reply means "nothing matched in this batch", never "iteration finished". Only a returned cursor of 0 means finished.

How the Cursor Survives a Resize

The cursor is not a position in a list, and it is not a count of keys already seen. It is a bucket index into the hash table behind the keyspace — a table that is being resized while you iterate it. That one fact explains every guarantee below, including the awkward one.

Valkey and Redis size their hash tables as powers of two, and a key's bucket is chosen by the low-order bits of its hash: hash & (size - 1). Double the table and one more bit joins the mask, so everything in bucket 1100 lands in either 01100 or 11100 — same low bits, one new high bit. Halve the table and two buckets merge back into one.

A cursor that simply counted 0, 1, 2, 3 would break the instant that happened. So the cursor is incremented from the high-order end instead: reverse the bits, add one, reverse back.

# Reverse-binary increment across a 16-bucket table
0000 → 1000 → 0100 → 1100 → 0010 → 1010 → 0110 → 1110 → 0001 → ...
   0      8      4     12      2     10      6     14      1

That ordering is the whole trick. Redis's own dict.c explains why it works: if the table grows mid-iteration, the new buckets that an already-visited bucket splits into "can be targeted only by keys we already visited when scanning the bucket 1100 in the smaller hash table". Advancing through the high bits keeps a bucket and all of its future expansions on the same side of the cursor, so a table that doubles underneath you cannot cause a key to be skipped.

The price is paid in the other direction. When the table shrinks, two buckets you visited separately merge back into one, and that merged bucket hands back keys you have already been given. (While a resize is in flight both tables exist and a single call reads from both. That is not a second source of duplicates — it is the mechanism that stops keys being missed as they move between tables, which dict.c lists as the reason a scan must return every key chained in a bucket "and all the expansions".) Redis puts the consequence first among the design's trade-offs — "it is possible we return elements more than once" — and leaves the fix to the caller.

So the deduplication advice below is not a matter of taste. Duplicates are the structural cost of an iterator that stays correct while the table it is walking changes shape. No client library can remove them, because the information needed to do so — every key already returned — is exactly what the cursor design refuses to keep on the server.

The Guarantees, and the One That Catches People Out

SCAN gives you a weaker consistency model than KEYS, and it is worth knowing exactly what you are promised:

  • A key present in the database for the full duration of the iteration will be returned.
  • A key absent for the full duration will not be returned.
  • A key added or removed partway through may or may not be returned.
  • A key may be returned more than once.

That last point is the one that causes bugs, and the section above explains where it comes from. Deduplication is the caller's responsibility. If you are counting keys, or building a list to act on, collect results into a Set rather than a List.

Scanning Inside a Collection: HSCAN, SSCAN and ZSCAN

SCAN walks the keyspace. Three sibling commands walk the contents of a single key, using the identical cursor protocol and the same MATCH and COUNT options:

CommandIteratesReturnsBlocking alternative it replaces
HSCANA hashField-value pairsHGETALL
SSCANA setMembersSMEMBERS
ZSCANA sorted setMember-score pairsZRANGE key 0 -1

The reason to reach for them is the reason to prefer SCAN over KEYS, one scope down. A hash with four hundred thousand fields is a single key, so HGETALL on it is one O(N) command occupying the single thread for its full duration — the same outage, triggered by a key rather than a keyspace. We cover that case in detail in HGETALL vs HSCAN.

HSCAN has an extra option worth knowing: NOVALUES, added in Redis 7.4. It returns field names without their values, which is what you want when you are auditing structure rather than reading data — and it can cut the reply size by an order of magnitude on hashes holding large values.

127.0.0.1:6379> HSCAN user:1042 0 COUNT 100
1) "0"
2) 1) "email"
   2) "ada@example.com"
   3) "plan"
   4) "pro"

127.0.0.1:6379> HSCAN user:1042 0 COUNT 100 NOVALUES
1) "0"
2) 1) "email"
   2) "plan"

One behaviour catches almost everyone. Small sets of integers are stored as intsets, and small hashes and sorted sets as listpacks — flat arrays rather than hash tables. There is no cursor to speak of in a flat array, so for these encodings the server ignores COUNT and returns every element in the first call, with cursor 0.

The practical consequence is a testing gap rather than a performance one. Your cursor loop never actually loops in development, because the test fixture has forty fields and stays a listpack. It starts looping the first time a key in production crosses hash-max-listpack-entries and converts to a hash table — which is also the first time a missing dedupe or a mishandled empty batch can bite. If you are writing the loop by hand, test it against a collection large enough to have been converted.

Scanning From redis-cli

For command-line work, redis-cli wraps the whole cursor loop for you:

redis-cli --scan                              # every key, via SCAN
redis-cli --scan --pattern 'user:*'           # glob-filtered
redis-cli --scan --pattern 'user:*' --count 500   # larger COUNT hint
redis-cli --scan --pattern 'user:*' | wc -l   # count matches
redis-cli --scan -i 0.01                      # 10ms pause between calls

Three flags are worth committing to memory. --count maps straight to the COUNT hint and defaults to 10, so raising it is the single easiest way to speed up a large scan. -i <seconds> inserts a delay between calls, which deliberately slows the scan to keep load off a busy production node. And --quoted-pattern accepts a quoted string for patterns that aren't binary-safe.

The related --bigkeys and --memkeys flags use the same non-blocking SCAN machinery to find the largest keys — --bigkeys by element count for aggregate types and by length in bytes for strings, --memkeys by actual memory consumed. Either one is the fastest way to find the hash that is going to cause the incident described above.

KEYS vs SCAN

KEYSSCAN
BlockingBlocks the server for the whole operationBounded work per call
ComplexityO(N) in one shotO(1) per call, O(N) total
ResultsComplete snapshot, one replyBatched across many replies
DuplicatesNonePossible on resize — dedupe required
ConsistencyPoint-in-timeKeys changed mid-scan may be missed
MemoryEntire result set at onceOne batch at a time
Production-safeNoYes

The rule of thumb: KEYS when you're at a prompt on a small dataset, SCAN in anything that runs unattended.

Counting Keys

If you want a count rather than the key names themselves, don't scan at all:

127.0.0.1:6379> DBSIZE
(integer) 3

DBSIZE is O(1) — the server tracks the number of keys, so it answers instantly regardless of keyspace size. Use it whenever you need the total.

Counting keys matching a pattern is different: there is no built-in command, so you have to scan and count, deduplicating as you go.

Listing Keys in a Cluster

Both KEYS and SCAN operate on one node. In a clustered deployment, the keyspace is split across multiple masters by hash slot, so running KEYS * against a single node returns only that node's share.

Getting a complete picture means connecting to every master and iterating each one separately. From the command line:

redis-cli --cluster call 127.0.0.1:7000 KEYS '*'

This works, but it inherits every KEYS problem and multiplies it — you are now blocking every node in the cluster simultaneously. It is a debugging tool, not an operational one.

Doing this properly means discovering the current set of masters, opening a connection to each, running a cursor loop per node, merging the results, and re-discovering the topology if a failover happens mid-iteration. That is a meaningful amount of code to get right, and it is the part most guides skip. (Duplicates remain a per-node concern rather than a merge one — masters own disjoint hash slots, so the same key never arrives from two nodes.)

Listing Keys From Java

A low-level client gives you the commands and leaves the orchestration to you. With Jedis or Lettuce, iterating a keyspace means writing the cursor loop by hand: initialise the cursor, call scan, accumulate results into a set, check whether the returned cursor is "0", and repeat. Add cluster support and you are also managing per-node connections and topology changes.

The loop is not difficult, but it is easy to write subtly wrong — forgetting to deduplicate, treating COUNT as a hard limit, or assuming an empty batch means the iteration has finished. And because KEYS is a single call while SCAN is a loop, the dangerous option is always the more convenient one to reach for.

Listing Keys With Redisson

Redisson exposes keyspace operations through the RKeys interface, obtained from the client:

RedissonClient redisson = Redisson.create(config);
RKeys keys = redisson.getKeys();

The important design decision: getKeys() iterates using SCAN, not KEYS. No iteration method on RKeys issues a raw KEYS command, so the safe path is what you get by default rather than something you have to remember to choose. (One exception, covered below: the pattern-delete methods do fall back to KEYS inside a Lua script when called within a batch.)

// Lazily iterates the whole keyspace via SCAN
for (String key : keys.getKeys()) {
    System.out.println(key);
}

The iteration is lazy — keys are fetched in batches as you consume them, so the full keyspace is never materialised in memory.

One caveat carries over: Redisson does not deduplicate. getKeys() chains the underlying SCAN batches straight through, so the duplicate-results guarantee discussed above applies exactly as it does at the command line. Redisson removes the cursor bookkeeping and the per-node fan-out; it does not change SCAN's semantics. If you are counting or acting on each key, collect into a Set:

Set<String> unique = new HashSet<>();
keys.getKeys(KeysScanOptions.defaults().pattern("user:*"))
    .forEach(unique::add);

Narrowing the Scan With KeysScanOptions

KeysScanOptions controls pattern, batch size, result limit, and key type:

Iterable<String> found = keys.getKeys(
    KeysScanOptions.defaults()
        .pattern("user:*")     // glob pattern, same syntax as MATCH
        .chunkSize(200)        // keys loaded per request to the server
        .limit(1000)           // total keys returned, then iteration stops
        .type(RType.MAP));     // only hash-typed keys

Two of these are worth dwelling on.

chunkSize maps to COUNT, and is unset by default. Redisson only sends COUNT when you give it a positive value, so defaults() leaves the server to apply its own default of 10 keys per call. That is conservative — a scan over a large keyspace at 10 keys per round trip spends most of its time waiting on network latency. Raising it to a few hundred is usually a straightforward win.

limit is a genuine cap, unlike the COUNT hint. If you only need a sample of matching keys, limit(100) stops the iteration after 100 rather than walking the entire keyspace. In a cluster the cap is global rather than per-node — the count is tracked across the merged iteration, so limit(100) returns 100 keys in total, not 100 from each master.

The type filter uses Redisson's RType enum, which names types by their Java-side concept rather than the Redis wire name — RType.MAP filters for hashes and RType.OBJECT filters for strings:

RTypeUnderlying type
OBJECTstring
MAPhash
LISTlist
SETset
ZSETzset
STREAMstream
JSONReJSON-RL

Streams

For pipeline-style processing, getKeysStream() returns a Java Stream over the same lazy SCAN iteration:

// Audit: session keys created without a TTL, which will never expire
Set<String> neverExpires = keys.getKeysStream(
        KeysScanOptions.defaults().pattern("session:*"))
    .filter(k -> keys.remainTimeToLive(k) == -1)
    .collect(Collectors.toSet());

getKeysStream() wraps the same iterator as getKeys(), so collect to a Set here for the same reason — a List would preserve any duplicates the scan returned.

Note also that the filter issues one round trip per key. That is fine for a maintenance audit over a narrow pattern, but it is not something to put in a request path; batch the TTL lookups if the matched set is large.

remainTimeToLive returns the remaining life in milliseconds, or -1 if the key exists with no expiry set, or -2 if it doesn't exist. Test for -1 specifically rather than any negative value — lumping the two together conflates "this key will never expire" with "this key is already gone", which are different problems.

Counting and Inspecting

long total = keys.count();                           // DBSIZE — O(1)
long present = keys.countExists("user:1", "user:2"); // how many exist
String sample = keys.randomKey();                    // one key at random
RType type = keys.getType("user:1042");              // type of a given key
int slot = keys.getSlot("user:1042");                // cluster hash slot

count() is worth a closer look in a clustered deployment. It issues DBSIZE to every master in parallel and sums the results, so you get the cluster-wide total rather than one shard's share — still O(1) per node, and still far cheaper than scanning.

Cluster Behaviour

RKeys operates across the whole keyspace rather than a single node. Iterating with getKeys() against a cluster walks every master and merges the results, with topology changes handled by the client — no per-node connection management in your code.

HSCAN, SSCAN and ZSCAN From Java

The collection scans are not a separate API in Redisson. They are what the ordinary Java collection views already do — the pattern and batch-size overloads on RMap, RSet and RScoredSortedSet issue HSCAN, SSCAN and ZSCAN underneath:

// HSCAN — RMap views take (pattern, count)
RMap<String, String> sessions = redisson.getMap("user:sessions");
Set<String> ids = sessions.keySet("sess:*", 500);              // HSCAN, field names
sessions.entrySet("sess:*", 500).forEach(this::process);        // fields and values
Collection<String> vals = sessions.values("sess:*", 500);

// SSCAN
RSet<String> tags = redisson.getSet("article:42:tags");
Iterator<String> it = tags.iterator("lang:*", 500);

// ZSCAN
RScoredSortedSet<String> board = redisson.getScoredSortedSet("leaderboard");
Iterator<String> players = board.iterator("player:*", 500);
Iterator<ScoredEntry<String>> withScores =
        board.entryIterator("player:*", 500);   // members and their scores

The plain no-argument forms scan too — keySet() and entrySet() on RMap, iterator() on RSet and RScoredSortedSet — but every one of them resolves to a COUNT of 10, which Redisson sends explicitly rather than leaving to the server. That is the default worth overriding: on a collection of any size, a batch of 10 means most of the elapsed time is round-trip latency rather than work. The count overload exists for exactly this, and a few hundred is a reasonable starting point.

One implementation detail on keySet(...) is worth knowing, because it is better than it first appears. Redisson never sends NOVALUES — the option appears nowhere in the codebase. Instead the key view wraps HSCAN in a short Lua script that strips the values before the reply leaves the server, so you get the bandwidth saving NOVALUES would have given you, on every server version rather than only 7.4 and later. entrySet(...), which needs the values, issues a plain HSCAN. The practical rule is the obvious one: ask for keySet(...) when you only need field names, rather than pulling entries and discarding half of each.

Note the asymmetry with readAll*(). readAllKeySet(), readAllValues() and readAllEntrySet() map to HKEYS, HVALS and HGETALL — single blocking commands, not scans. They are the right choice for a collection you know is small, and the wrong one for a collection that grows. The naming is the tell: readAll reads everything at once, the collection views iterate.

One capability has no command-line equivalent. distributedIterator() shares a single cursor across JVMs, so several application instances can consume one iteration cooperatively rather than each walking the whole collection:

// Each instance pulls a distinct slice of the same iteration
Iterator<String> shared = tags.distributedIterator("worker-pool", "lang:*", 500);

In cluster mode the iterator's own name must hash to the same slot as the collection it iterates — use a hash tag if you are naming it explicitly.

Deleting Keys by Pattern

Once you can find keys, removing a subset of them is the natural next step. Two commands do the work:

  • DEL removes keys synchronously. For large collections it blocks proportionally to the number of elements.
  • UNLINK removes the key from the keyspace immediately and reclaims memory on a background thread. For anything large, prefer it.

Redisson exposes both, including pattern-based variants that handle the scan-and-delete loop for you:

long removed  = keys.delete("user:1042", "cart:1042");
long unlinked = keys.unlink("session:a7f3");

long byPattern    = keys.deleteByPattern("session:*");
long byPatternFast = keys.unlinkByPattern("temp:*");

Two things to know about the pattern variants.

They are not atomic in cluster mode. The implementation iterates each master separately with SCAN, buffering matches and deleting them in batches of 500, so there is no single instant at which every match disappears. Treat them as best-effort bulk operations; if you need atomicity, scope the pattern to one hash slot using hash tags.

They also behave differently inside an RBatch. Batched, the pattern variants switch to a server-side Lua script that calls KEYS internally — reintroducing exactly the blocking behaviour this article has been steering you away from. In cluster mode a batched call doesn't run at all; it throws IllegalStateException telling you to execute it as a non-batch method. Call deleteByPattern and unlinkByPattern outside a batch and you get the safe SCAN-based path.

To clear an entire database rather than a matching subset, see our guide to FLUSHALL and FLUSHDB.

Non-Blocking Variants

The same iteration is available through Redisson's Reactive and RxJava APIs, returning a stream you can compose with backpressure:

// Project Reactor
Flux<String> keyFlux = redisson.reactive().getKeys()
    .getKeys(KeysScanOptions.defaults().pattern("user:*"));

// RxJava 3
Flowable<String> keyFlow = redisson.rxJava().getKeys()
    .getKeys(KeysScanOptions.defaults().pattern("user:*"));

The async API handles iteration differently from the rest of RKeysAsync. Operations that return a single result — countAsync(), deleteAsync(), deleteByPatternAsync() — hand back an RFuture. Iteration can't, because there is no single result to complete with, so getKeysAsync() returns an AsyncIterator instead:

AsyncIterator<String> it = redisson.getKeys()
    .getKeysAsync(KeysScanOptions.defaults().pattern("user:*"));

CompletionStage<Void> drain(AsyncIterator<String> it) {
    return it.hasNext().thenCompose(hasNext -> {
        if (!hasNext) {
            return CompletableFuture.completedFuture(null);
        }
        return it.next()
                 .thenAccept(System.out::println)
                 .thenCompose(ignored -> drain(it));
    });
}

hasNext() returns CompletionStage<Boolean> and next() returns CompletionStage<String>, so the cursor advances without ever blocking a thread. Two things to watch: each invocation returns a fresh CompletionStage, so don't cache and reuse them; and because the drain is recursive, a very large keyspace can build a long completion chain — batch the results or bound the iteration with limit() if that's a concern.

See our guide to non-blocking Valkey and Redis in Java for how these fit into a wider reactive stack.

Production Checklist

  1. Never run KEYS against production. Use SCAN, or run it on a replica if you must.
  2. Always deduplicate SCAN results. Collect into a Set, not a List.
  3. Don't treat COUNT as a limit. It's a work hint; use a real limit if you need one.
  4. Tune your batch size. Redisson leaves COUNT unset by default, so the server's conservative default of 10 applies — raise chunkSize for large keyspaces.
  5. Prefer UNLINK over DEL when removing large keys or many at once.
  6. Pattern deletes aren't atomic in a cluster — and inside an RBatch they fall back to KEYS via Lua. Call them outside a batch.
  7. Raise COUNT on collection views too. keySet(), iterator() and entrySet() resolve to 10 elements per call. Use the count overload on anything non-trivial.
  8. Test cursor loops against a converted collection. A small hash or set is a listpack or intset and returns everything in one call, so a hand-written loop never iterates in development.
  9. Design key names for scannability. A consistent type:id convention makes patterns precise and cheap to filter.
  10. If you scan on every request, you need an index. Scanning is for maintenance and debugging. For query workloads, use a secondary index — a set or sorted set of IDs — or Redis Search.

That last point is the one worth internalising. Reaching for SCAN in a hot path is usually a sign that the data model is missing an access pattern. Storing an explicit index alongside your data turns an O(N) keyspace walk into an O(1) lookup.

FAQ

How Do I Get All Keys in Redis?

Run KEYS * for a complete list, or iterate with SCAN 0 for a production-safe equivalent. In Java with Redisson, redisson.getKeys().getKeys() iterates the keyspace using SCAN.

Is the KEYS Command Safe in Production?

No. It blocks the single-threaded server for the duration of a full keyspace walk, stalling every other client. Use SCAN instead.

What Is the Difference Between KEYS and SCAN?

KEYS returns everything in one blocking call. SCAN returns small batches via a cursor, keeping the server responsive — at the cost of possible duplicate results and weaker consistency for keys changed mid-iteration.

How Do I Count the Keys in Redis?

DBSIZE returns the total in O(1). For a pattern-matched count you must scan and count, deduplicating results. In Redisson, keys.count() maps to DBSIZE.

How Do I List Keys Across a Redis Cluster?

KEYS and SCAN only cover the node you're connected to, so you must iterate every master and merge results. Redisson's RKeys.getKeys() handles this automatically.

How Do I Find and Delete Keys Matching a Pattern?

Scan for matches and delete them in batches. In Redisson, deleteByPattern() and unlinkByPattern() do this in one call — though not atomically in cluster mode.

Why Does SCAN Return the Same Key Twice?

Because the cursor is a bucket index in a hash table that resizes while you iterate. The cursor advances through its high-order bits — reverse the bits, increment, reverse back — which guarantees nothing is missed when the table grows. The trade-off is that when the table shrinks, or while an incremental rehash is in progress, buckets you already visited can be read again. Duplicates are inherent to the design, not a bug, and deduplication is the caller's job: collect into a Set.

What Is the Difference Between SCAN and HSCAN?

SCAN iterates the keyspace and returns key names. HSCAN iterates the fields inside one hash and returns field-value pairs — or field names only, with the NOVALUES option added in Redis 7.4. SSCAN and ZSCAN do the same for sets and sorted sets. All four share the same cursor protocol and the same MATCH and COUNT options.

Does SCAN Block Redis?

Not meaningfully. Each call is O(1) and does a bounded amount of work, so other clients are served between iterations. A full iteration is still O(N) in total, but that cost is spread across many short calls rather than concentrated in one long one — which is the entire difference between SCAN and KEYS.

Why Does SCAN Return an Empty Result but a Non-Zero Cursor?

Because MATCH filters after the batch is fetched. If few keys match your pattern, most batches contain nothing — that is normal and does not mean the iteration is over. Only a returned cursor of 0 means the iteration is complete.