Engineering Atlas › Low-Level Design

Iterator pattern

12 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

The problem

A billing team exports invoices to a data warehouse. The first version calls repo.all_invoices(), which returns a list. At 200,000 invoices it took 400 MB and 20 seconds before the first row was written. At 30 million it ran out of memory. A second consumer, a fraud check, needs only the first 1,000 invoices that match a rule, and still waits for all 30 million.

The iterator answer: repo.invoices() returns an iterator that fetches 5,000 rows at a time from a server-side cursor. The export writes as it reads in constant memory, and the fraud check stops after 1,000 matches, so no more batches are fetched.

External and internal iterators

An external (pull) iterator is an object the caller advances: next(it), it.next(), rows.Next(). The caller controls the loop, so it can stop, interleave two iterators (merging two sorted streams) or hand the iterator to another function. An internal (push) iterator is a function that takes a callback and calls it per element: list.forEach(fn), Go's func(yield func(T) bool). The producer controls the loop, which makes producers easy to write, because their state lives in ordinary local variables and the call stack, but makes merging two of them awkward.

Generators bridge the two. A Python or JavaScript generator is written like a push iterator (a loop with yield) and consumed like a pull iterator, because the runtime suspends the function at each yield and resumes it on the next next(). Go 1.23's iter.Pull converts a push iterator into a pull one by running it as a coroutine.

Protocols in four languages

Python calls __iter__(), then __next__() until it raises StopIteration. JavaScript calls x[Symbol.iterator](), then next() until {done: true}; for...of calls the optional return() on early exit so the iterator can release resources. Java has hasNext() and next(). Go's standard library uses Next() bool with accessors and an Err() checked after the loop (bufio.Scanner, sql.Rows), and Go 1.23 added iter.Seq[V], a function type for range loops over directly.

Mutation during iteration

What should happen if the list changes while someone iterates it? There are three common answers. Fail-fast: Java's ArrayList iterator throws ConcurrentModificationException, and Python raises RuntimeError: dictionary changed size during iteration. Snapshot: Java's CopyOnWriteArrayList iterates the array as it was when the iterator was created; RocksDB iterators read a consistent snapshot even while compactions rewrite files (see LSM Trees). Weakly consistent: Java's ConcurrentHashMap iterators never throw and may or may not see concurrent changes. Pick one deliberately and document it; "undefined" is the answer that produces skipped or duplicated elements in production.

Iterators that outlive a process

A pagination token, a cursor name and a Kafka consumer offset are iterator state serialised so iteration can resume in another request or after a crash. The token must capture position, not page number: offset pages skip or repeat rows when earlier rows change, while "after id 81723" stays correct. Clients must also guard against a buggy server returning the same token forever.

Reviewing agent-written code

Agents reach for list(...) and .all() because they make the next line simple; ask for peak memory at production volume. Pagination loops need an exit on an empty token, a repeated-token guard and a page cap. Iterators over files, sql.Rows or HTTP streams must close on early exit: defer rows.Close(), with or try/finally, or return() in JavaScript.

The same shape elsewhere

A pull iterator solves the overload problem shape by letting the consumer set the rate: nothing is produced until it is asked for. TCP flow control does the same between machines, where the receiver's advertised window limits how much the sender may transmit, and Reactive Streams makes it explicit with request(n), where a subscriber asks for the next n elements.

Down the stack

Layer 1The contract

An iterator yields each element of its source at most once, in a documented order, and signals exhaustion in a defined way that it repeats if asked again. It documents its behaviour under concurrent modification (fail-fast, snapshot or weakly consistent), whether it can be restarted (an iterable can produce a fresh iterator; an iterator usually cannot), and who releases underlying resources on early exit. For remote iterators, it documents whether a failed step can be retried without skipping or repeating elements.

Layer 2The mechanism

Java's ArrayList.Itr holds three ints: cursor (next index), lastRet (index last returned, for remove) and expectedModCount. Every structural change to the list increments its modCount, and each next() compares the two and throws on mismatch. That is the whole fail-fast mechanism: one integer comparison per step, best-effort and not a concurrency guarantee.

A CPython generator object holds its suspended frame: local variables, the evaluation stack and the instruction offset. YIELD_VALUE stores the value and returns control to the caller; next() resumes the frame at the saved instruction. Since PEP 479 (Python 3.7) a StopIteration raised inside a generator body becomes a RuntimeError, so a stray next() on an exhausted inner iterator no longer ends the outer generator silently. Calling close() raises GeneratorExit at the suspension point, which runs finally blocks.

In Go 1.23, for x := range seq over a function compiles the loop body into a closure passed as yield; break makes yield return false, and the iterator must stop calling it. iter.Pull runs the push function on a runtime coroutine and switches to it on each call to next.

Database cursors work in batches. PostgreSQL's JDBC driver fetches the entire result set by default; setting a fetch size with autocommit off makes it use a portal and fetch that many rows per round trip. Python's psycopg2 behaves the same way unless you use a named (server-side) cursor.

Layer 3The machine

Per-element cost decides where iterators hurt. An index loop over a Go slice is a few nanoseconds per element and vectorisable. A range-over-func loop is usually inlined to similar cost. A pull iterator through an interface is an indirect call per element. A CPython next() on a generator costs tens of nanoseconds, and iter.Pull pays a coroutine switch per element, far above an inlined loop.

Memory is where iterators win. A list of 30 million invoice rows at around 120 bytes each as Python objects is over 3.6 GB; an iterator fetching 5,000 rows at a time holds about 600 KB. Batching also amortises round trips: at 0.5 ms per round trip, fetching one row at a time costs 15,000 seconds for 30 million rows, while batches of 5,000 need 6,000 round trips, or 3 seconds of latency.

Concurrency: an iterator is single-consumer state. Two goroutines calling Next on the same sql.Rows race on its cursor. Fan out by having one consumer read and send items to workers over a channel, or partition the source (key ranges) and give each worker its own iterator.

Why it's built this way

  1. Collections can exceed memory or be infinite, so consumers process one element at a time.
  2. Consumers should not depend on storage, so traversal sits behind a small interface.
  3. Hand-written traversal state is error-prone, so languages added generators that keep it on a suspended stack.
  4. Round trips dominate remote sources, so iterators fetch in batches and yield one element at a time.
  5. Iteration spans requests and crashes, so position is serialised as tokens and offsets.

Diagram

sequenceDiagram
  participant C as Consumer
  participant P as Paginator
  participant A as "List API"
  C->>P: next()
  P->>A: fetch(token "")
  A-->>P: [a, b], next "t1"
  P-->>C: a
  C->>P: next()
  P-->>C: b (buffered)
  C->>P: next()
  P->>A: fetch("t1")
  A-->>P: [c], next ""
  P-->>C: c
  C->>P: next()
  P-->>C: done

Worked example

The invoice export reads 30 million rows from PostgreSQL and writes Parquet files. The first version used the ORM's .all(): 3.6 GB of Python objects, a 14-minute delay before the first write, and an out-of-memory kill on the 8 GB worker when another job shared the node.

The rewrite used a named server-side cursor with itersize = 5000, wrapped in a generator that yields rows and closes the cursor in finally. Peak memory dropped to 180 MB, mostly the Parquet writer's row group buffer, and the first file appeared in 2 seconds. Total time fell from 31 to 22 minutes, because writing now overlapped reading.

Two bugs surfaced. The fraud check broke out of the loop after 1,000 matches, but the generator was never closed, so the cursor and its transaction stayed open until the connection pool recycled the connection, holding back vacuum on the invoices table. Wrapping the iteration in with closing(...) fixed it. The second came from a partner API used for enrichment: during an incident it returned the same next_page_token repeatedly, and the paginator fetched the same page 400,000 times in an hour. A guard that remembers seen tokens and fails on a repeat, plus a page cap, stopped it.

Trade-offs

OptionStrengthsWeaknessesChoose when
Materialised listSimple; random access; reusableMemory grows with size; first result waits for allSmall, bounded results
External (pull) iteratorCaller controls pace; can merge and stop earlyHand-written state machine; cleanup on early exitMerging streams, resumable reads
Internal (push) iterator or callbackEasy to write; fast in Go and compiled languagesHard to merge two or pauseSimple full traversals
GeneratorPull interface with push-style codePer-step overhead; hidden suspended statePython and JavaScript pipelines
Server cursor or pagination tokenConstant memory; resumable across requestsServer state or token design; consistency under writesLarge or remote result sets

Pitfalls in production

Leadership lens

Check yourself

  1. Why can a pull iterator merge two sorted streams easily while a push iterator cannot?

    Show answer

    With pull, the consumer advances whichever stream has the smaller head; with push, each producer drives its own loop, so interleaving needs threads, coroutines or buffering.

  2. What does Java's fail-fast iterator actually guarantee?

    Show answer

    Nothing under concurrency; it compares a modification counter on each step and throws on a detected mismatch, which catches most single-threaded mistakes but is explicitly best-effort.

  3. Why do keyset pagination tokens survive inserts while offset pages do not?

    Show answer

    A keyset token says "after this sort key", which still identifies the same position after rows are inserted earlier, while an offset counts rows and shifts when earlier rows appear or disappear.

  4. What leaks when a consumer breaks out of a loop over a database-backed generator, and how is it prevented?

    Show answer

    The cursor and its transaction stay open until the generator is collected or closed; close it explicitly with a context manager, finally, or defer rows.Close().

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →