skip to content

An SDK's `iter.Seq[Item]` yields 200 items on the first range and nothing on a second. Why?

level: seniorimportance: nice to knowfreq 32%

answer

  1. ranging twice calls the function twice
  2. what did the closure capture?
  3. a slice can rewind; a socket cannot
  4. state built inside versus captured outside
  5. an empty second pass raises no error

basics

~20 s

Nothing in iter.Seq's contract makes a sequence restartable. This one closes over a drained response body or a spent page cursor, so the second range calls it again, it yields nothing and returns normally: an empty result, not an error.

solid answer

~50 s

Ranging a sequence twice calls the same function twice; whether that produces the same elements depends entirely on what the body touches. A sequence over an in-memory slice restarts for free, because each call re-reads from index 0. This one started the pagination first and closed over the live state — a response body already read to EOF, a cursor sitting on the last page — so the second call finds nothing left, returns without yielding, and the loop body never runs. An exhausted sequence and an empty one are indistinguishable, so the caller sees zero items and no error, which is why a retry loop around the same value reports "no results" forever. Fix it by creating all iteration state inside the sequence function; if the source cannot be replayed, document it as single-use.

code

go · 13 lines
go
func assertRestartable(t *testing.T, seq iter.Seq[Item]) {
	t.Helper()
	first, second := 0, 0
	for range seq {
		first++
	}
	for range seq {
		second++
	}
	if first != second {
		t.Fatalf("second pass yielded %d items, first yielded %d", second, first)
	}
}

go deeper

for a junior

Take away one fact: ranging an iter.Seq twice calls the function twice, and there is no guarantee the second call produces anything. Do not assume a sequence you were handed can be reused.

for a middle

Explain the difference between a sequence whose state is created inside the function on each call and one that captured a live decoder or cursor, and why only the first restarts.

for a senior

Diagnose it from symptoms — zero items, no error, a retry loop that confirms the wrong answer — and prove it with a two-pass test before proposing the fix.

for a principal

This is a contract call others build on: decide whether your published sequences are restartable, make that explicit in the doc comment, and accept the round-trip cost or the loud second-pass failure that goes with your choice.

## The failure as the integrator sees it A developer picking up the SDK writes what looks like defensive code: ```go seq := client.Items(ctx) for attempt := range 3 { n := 0 for range seq { n++ } if n > 0 { break } log.Printf("attempt %d: no items", attempt) } ``` The first pass yields 200 items. The second and third yield zero, silently. Nothing errors, nothing panics, and the log says the endpoint is empty when it is not. ## The mechanism An `iter.Seq[Item]` is just `func(yield func(Item) bool)`. Ranging it is calling it. There is no cached element list, no rewind hook, no runtime bookkeeping between passes — the second `for range` simply invokes the same function value a second time, and what happens next is decided entirely by that function's body. That body comes in two flavours, and the type does not distinguish them. **Restartable.** Everything the traversal needs is created *inside* the function. Reading from an in-memory slice, walking a tree the closure has a pointer to, or issuing the first HTTP request from within the body: each call starts over, because each call builds its own cursor. **Single-use.** Some live, non-rewindable state was created *before* the function value and captured by it. The classic version of this bug is an SDK method that does the eager thing: ```go func (c *Client) Items(ctx context.Context) iter.Seq[Item] { resp, _ := c.get(ctx, "/items") // request already sent dec := json.NewDecoder(resp.Body) // decoder already positioned return func(yield func(Item) bool) { for dec.More() { … } } } ``` After the first traversal the body is at EOF and the page cursor is on the last page. Call the function again and its loop condition is false immediately: it yields nothing and returns normally. There is no error to return, because from inside the function nothing went wrong. ## Why it is silent, and why that is the dangerous part An exhausted sequence and a genuinely empty one produce byte-identical observable behaviour: the loop body runs zero times, the range statement completes, execution continues. Go gives the consumer no way to ask a `Seq` "are you spent?" — and no error surfaces, because failing to have anything left is not a failure. So the retry loop above does not merely fail to help, it converts a subtle bug into a confident wrong answer. Every retry re-ranges the same exhausted value, so each attempt reinforces "there are no items". Retries have to call the *method* again to build a fresh sequence; re-ranging a stale one is a no-op. ## Finding it before a customer does The cheapest test is a two-pass smoke test: range the same value twice against a stub or recorded server and assert the counts match. ```go func assertRestartable(t *testing.T, seq iter.Seq[Item]) { t.Helper() first, second := 0, 0 for range seq { first++ } for range seq { second++ } if first != second { t.Fatalf("second pass yielded %d items, first yielded %d", second, first) } } ``` This catches the whole class in one assertion, and it is worth running over every sequence a public API returns. Note what it does *not* catch: a sequence that is restartable but re-fetches, which is correct behaviour with a cost the caller should know about. ## The two defensible designs **Make it restartable.** Move every piece of mutable state inside the returned function, so the request is issued when the traversal begins rather than when the method is called. Each range is then a fresh, complete traversal. The consequence to document is that ranging twice costs two round trips — restartable does not mean free. **Keep it single-use, and say so.** Sometimes the source truly cannot be replayed: a live event stream, an already-open file handed in by the caller, a server-side cursor with a token that expires. Then say it in the doc comment, in the first line, in the form a reader will actually see: "The returned sequence may be ranged over only once." Name the method so it reads as consuming. If a second pass is a programming error, it is far better for it to panic than to yield nothing — a loud failure beats a plausible empty result. What is not defensible is leaving it ambiguous. `iter.Seq` deliberately promises nothing about repeatability, so whatever your sequence does is part of *your* API's contract, not the language's. ## What to say in an interview "Ranging twice just calls the function twice. This one captured a response body and a decoder that were already drained by the first pass, so the second call yields nothing and returns cleanly — indistinguishable from an empty result. I would prove it with a two-pass test that compares counts, then either move the request inside the sequence function so every range starts fresh, or document it as single-use and make the second pass fail loudly rather than silently."

  • The integrator wrapped the range in a retry loop. Why does that make the diagnosis worse?
    Every retry re-ranges the same exhausted value, so each attempt yields zero and returns cleanly. The loop turns one silent anomaly into three consistent confirmations that the endpoint is empty. A retry has to call the SDK method again to build a fresh sequence; re-ranging a stale one can never recover anything.
  • What change makes the sequence restartable?
    Move every piece of live state inside the returned function: issue the first request, create the decoder and initialise the cursor when the traversal starts, not when the method is called. Each range then performs a complete fresh traversal. Document the cost — two ranges now mean two round trips.
  • When is a single-use sequence still the right design?
    When the source genuinely cannot be replayed — a live event stream, a caller-supplied open file, a server cursor whose token expires. Then commit to it: say "may be ranged over only once" in the first line of the doc comment, name the method so it reads as consuming, and prefer a panic on a second pass over silently yielding nothing.
  • Can a consumer tell whether an `iter.Seq` is exhausted before ranging it?
    No. The type is a bare function value with no state to inspect and no method to ask, and an exhausted sequence behaves exactly like an empty one: the loop body runs zero times and the range completes normally. Repeatability is something the producing package must document, since the language will not tell you.

saying these in an interview costs you the question

  • Assumes every iter.Seq can be ranged repeatedly
  • Says a Seq caches its elements after the first pass
  • Blames the range statement instead of the captured state
  • Adds a retry loop around the same exhausted sequence
  • Expects an exhausted sequence to return an error
  • Ships a public sequence without documenting repeatability