When should a widely imported Go package export an iter.Seq instead of returning a slice or taking a callback?
answer
- result size, and how often callers stop early
- a slice cannot become lazy later
- only a closure can hold something open
- exporting it raises your go directive
- importers' loop shape is permanent
basics
~10 sExport a sequence when results are large or streamed, callers stop early, or the loop must hold a resource open. Return a slice when the result is small and already in memory.
solid answer
~50 sDecide from the caller's workload, not from novelty. A slice is the better public contract when the result is small, bounded and already materialised: it is re-usable, sortable, countable, and it costs importers nothing. Export `iter.Seq` when the result is large or streamed, when callers commonly stop at the first match, or when producing a value costs something you would rather not pay for elements nobody reads — and especially when the sequence must own a resource for the duration of the loop, since a slice cannot express that. The asymmetry matters: a caller handed a sequence can build a slice in one line, while a caller handed a slice can never recover laziness. Against that, exporting a sequence raises your module's minimum Go version and binds every importer's loop shape forever. Decide the error convention in the same breath — errors in `Seq2` or a terminal `Err()` — and write both promises into the doc comment before the first release.
code
go · 7 linestype Walker interface {
// Lazy: stops when the caller stops, and owns what it holds open.
Entries(dir string) iter.Seq2[string, error]
// Eager: one result, re-usable and sortable, no early exit.
EntryList(dir string) ([]string, error)
}go deeper
Know the tradeoff in one line: a slice is simple, re-usable and sortable, while a sequence is lazy and lets the caller stop early.
Explain what laziness buys — no full materialisation, early exit, a resource held only for the loop — and what it costs a caller who just wants the whole list.
Argue from the workload: result size, how often callers stop early, and whether the sequence must hold something open. State plainly which errors ride in the sequence and which end it.
Own the commitment. Importing teams' loops bind to the shape forever, the module's minimum Go version rises for all of them, and the error convention is one you must live with across the whole package.
## What you are actually deciding Once a package other teams import exports `func (c *Catalog) Entries(dir string) iter.Seq2[string, error]`, three things become permanent: the loop shape every importer writes, the error convention they check inside it, and the minimum Go version your module imposes. You can add functions later; you cannot change what an existing one means without breaking builds you do not own. That is why this is an API review question and not a style question. ## The case for a slice A `[]T` is the default and should be beaten, not assumed to lose. It is a value: callers can hold it, sort it, index it, take its length, pass it around, range it repeatedly, and hand it to any code written in any Go version. It has no protocol to get wrong, and it needs no doc comment explaining what stopping early does. If your function reads a config file, lists ten fields, or returns the results of one query that is already fully in memory, a slice is the right export and a sequence is ceremony. ## The case for a sequence Four conditions, any of which is a real argument: **Size.** The result is large or unbounded — a walk over a file tree, a scan of a multi-gigabyte log. Materialising it costs memory the caller may not have. **Early exit.** Callers typically want the first match, or the first ten. A slice makes them pay for the whole result to look at one element. This is the strongest argument, and the one you should check against real call sites rather than imagining. **Cost per element.** Producing an element does I/O, decoding or allocation. Laziness means the caller pays for what they consume. **Resource lifetime.** The sequence needs a file, a handle or a cursor open for the duration of the loop and closed when the loop ends. A closure can own that with a `defer` in its own frame; a slice-returning function cannot express it at all, and a callback API expresses it awkwardly. And the asymmetry that settles many arguments: a caller given a sequence can collect it into a slice in one line, while a caller given a slice can never get laziness back. When both are defensible, the sequence is the more recoverable choice — but only if the other costs below are acceptable. ## The case for a callback The pre-existing Go answer, still used by `filepath.WalkDir`, is to take a function: `Walk(root string, fn func(path string, err error) error)`. It composes badly, it inverts control awkwardly in the caller's code, and `break` becomes a sentinel error. But it works on every Go version, it makes per-element error policy explicit, and there is a lot of it in existing codebases. If your package must support older toolchains, this is what you have. ## The costs importers pay **A version floor.** Using `iter` and ranging over a function requires a Go 1.23 or newer toolchain, and importers' own modules need a `go` directive of 1.23 or later for the range statement to compile. On a large estate with slow-moving services, that is a real coordination cost and a legitimate reason to add the sequence in a later minor version while keeping the existing API. **A protocol to learn.** A slice has no failure mode. A sequence has several, and the ones your callers will hit are early exit, error handling and whatever the loop holds open while it runs. **Two APIs for one thing.** Exporting both a slice function and a sequence function for the same data doubles your surface and leaves callers guessing. Pick one as the primary and, if you must, add the other as a thin, obviously-named convenience. ## Where the errors go Two conventions, and the choice is as permanent as the first: - **`iter.Seq2[V, error]`.** Every caller writes the error check inside the loop body. It is explicit, it is impossible to forget silently, and it lets a caller decide per element whether to continue or break. It also makes the common no-error loop noisier. - **A terminal `Err()` method** on an iterator value, the way a scanner works. The loop body stays clean, but the check is off to one side after the loop, and a caller who forgets it treats a truncated sequence as a complete one. The rule of thumb: per-element, recoverable failures belong in `Seq2`, because the caller genuinely has a decision to make each time. A single failure that ends the whole sequence fits `Err()` better. Anything you choose must be stated in the doc comment along with whether the value half is ever meaningful next to a non-nil error. ## How to run the decision Bring three things to the review: the actual call sites you have or expect, with a count of how many stop early; the version floor and who it blocks; and a written doc comment for the exported symbol, because if the contract cannot be stated in three sentences it is not ready to export. If early exit is rare and the results are small, ship the slice and keep the option open. Exporting nothing is always cheaper than un-exporting something.
- Where should errors go — iter.Seq2[V, error] or a terminal Err() method?Seq2 when failures are per element and the caller has a real decision each time: the check sits in the loop body and cannot be silently skipped. A terminal `Err()` when a single failure ends the whole sequence and the loop body should stay clean — at the cost that a caller who forgets to call it reads a truncated result as a complete one. Either way, document it.
- Your module's go directive is old and several importers are on older toolchains. How does that change the call?Exporting a sequence raises the floor to Go 1.23 for you and for anyone ranging over it. Treat it as a coordination cost: add the sequence in a new minor version, keep the existing slice or callback API working, and let importers move when they can. Do not retrofit the shape of an existing function.
- Is exporting both a slice function and a sequence function for the same data a good compromise?Usually not. It doubles the surface, doubles the documentation and leaves callers guessing which one is intended, and both must be maintained forever. Pick the primary shape from the call sites; if you add the second, make it a thin, obviously-named convenience over the first rather than an independent implementation.
- What must the doc comment of an exported sequence say?What the loop holds open while it runs and when that is released, and whether an error ends the sequence or the walk continues past the failed item. Callers write their loop body once against those promises. If the contract cannot be stated in about three sentences, the API is not ready to export.
saying these in an interview costs you the question
- Exports a sequence for a three-element in-memory result because it is newer
- Does not know that exporting it raises the module's minimum Go version
- Ships both a slice API and a sequence API for the same data with no guidance
- Adds a terminal Err method and never documents when it is valid to call
- Plans to change an exported function's shape later once callers adapt
- Argues purely from performance with no measurement of how often callers stop early