Go 1.23 stabilised range over function iterators, a feature that had been in the rangefunc experiment since 1.22. It’s the most significant addition to the range statement since channels were added.
The reaction has been mixed: people who needed it find it elegant; people who didn’t need it find it confusing. Both reactions are reasonable. Here’s what it actually does and where it belongs.
The Problem It Solves
Before 1.23, if you wanted to iterate over a custom data structure, you had two options:
Option 1: Return a slice. Collect all elements into a []T and return it. Simple. Works well. Has two costs: allocates memory proportional to the full collection, and requires collecting everything before iteration starts.
| |
Option 2: Accept a callback. Pass a function to the data structure and let it call back for each element. Zero allocation, streaming. Awkward to use: can’t break out of the loop without returning a special value from the callback.
| |
Range over functions is option 2, but with the caller-side range syntax instead of the callback syntax.
The Iterator Function Signature
An iterator function for range has one of these signatures:
| |
The yield function is called for each element. If yield returns false, iteration stops (the caller did break). The iterator function must stop calling yield when it returns false.
A tree iterator:
| |
The iter.Seq[V] and iter.Seq2[K, V] types from the iter package are just type aliases for the iterator function signatures. They’re documentation, not a new mechanism.
The Standard Library Additions
Go 1.23 added iterator functions to several standard library types:
| |
These are convenient but not compelling — you could already range over slices and maps. The value is in the composability with iterator combinators:
| |
The iter Package Combinators
The iter package provides Pull for consuming push-based iterators in a pull-based way, and external packages are emerging with combinators (map, filter, take, zip). The pattern:
| |
This is where the feature gets interesting for data pipeline-style code. Iterator composition without intermediate allocations, with natural break semantics.
Where It Belongs and Where It Doesn’t
Use range over functions for:
- Custom data structures (trees, graphs, ordered sets) where traversal needs to be lazy
- Wrapping external iteration sources (database cursors, file lines, API pagination) where you don’t want to buffer everything in memory
- Stateful generators that produce values on demand
- Library code where composability matters
Don’t use range over functions for:
- Wrapping a
[]Tthat you already have in memory — just range over the slice - Simple transformations on collections — a loop or a helper function is clearer
- Making code look “more functional” — iterator chains with
Map/Filter/Reduceare often less readable than explicit loops in Go’s style
The rule of thumb: if you’d previously have written a Walk(func(v V) bool) method on your type, replace it with an iterator function and let callers use range. If you’d previously have returned a []T, the slice is probably still right.
The Pitfall: Forgetting to Honour false
The contract of an iterator function: if yield returns false, you must stop calling yield. Violating this causes a panic.
| |
This is a new failure mode that didn’t exist before. Linters will catch it eventually; for now, code review is the check.
Range over functions is a clean solution to a real problem: lazy iteration over custom data structures with natural break semantics. It fills the gap between “return a slice” (eager, allocates) and “accept a callback” (lazy, awkward). Whether it belongs in your code depends on whether you have that problem. Most service-layer code doesn’t — it ranges over slices and maps. Infrastructure and library code often does.