Go has always been opinionated about iteration: simple syntax, predictable control flow, and—when you need concurrency—explicit goroutines and channels. So when Go 1.23 expands what range can do, it doesn’t just add a small convenience. It changes the shape of what Go code can express, particularly around custom iterators and “generator-like” patterns.

The headline feature sounds small: you can now range over function types in Go 1.23. But the real story is what that unlocks. You can write lazy, composable iteration pipelines—filters, transformers, even reductions—without channels, without callback spaghetti, and without paying the mental cost of concurrency when you don’t need it.

What changed in Go 1.23 (and why it matters)

Before Go 1.23, you could range over arrays, slices, maps, strings, and channels. You could build custom iteration abstractions, but the moment you wanted to integrate with for range, you typically had to choose between:

  • Channels: convenient, but ties your design to concurrency and buffering semantics.
  • Callbacks: flexible, but often devolves into hard-to-read control flow and “who owns what?”
  • Manual loops: straightforward, but you lose the uniformity and readability that range gives you.

Go 1.23 extends range to work with range-over-function types. The exact mechanics are language-level, but the practical effect is simple: you can define an iterator as a function (or a function-like type) and then iterate it with for ... range in a way that feels native to Go.

This is the kind of change that doesn’t trumpet itself as a new abstraction—because it doesn’t introduce a new concept you have to learn. It upgrades a syntax tool you already know.

From “iterator objects” to lazy pipelines

The biggest win is composition without ceremony. Once your iteration source can be ranged over, you can build pipelines that are:

  • Lazy: items are produced on demand.
  • Deterministic: no goroutine scheduling, no races, no buffering surprises.
  • Ergonomic: for v := range it { ... } reads like iteration, not like framework code.

Consider a common need: filter a stream of records and then transform them. With channels, you’d typically spin up a goroutine pipeline. With the new capability, you can write something closer to iterator algebra.

Imagine a function that yields values matching a predicate:

  • It takes a collection (or another iterator).
  • It returns an iterator function type that range can consume.
  • It produces results lazily as the consumer requests them.

Even if you’re used to Go’s functional packages, the key shift is that you’re not building “map/filter” helpers that eagerly allocate slices. You’re building something you can chain and consume like native iteration.

Practical example: filter + map without channels

Suppose you have a slice of integers and you want odd numbers squared, but only until you’ve collected 10.

With a lazy iterator approach, you can stop early naturally:

  • for v := range IterFrom(xs).Filter(isOdd).Map(square) { ... }
  • Break after the 10th result.

The early break matters. Eager implementations would already compute the entire transformed slice before you ever know you can stop. Lazy iterators let you align computation with need.

That alignment is more than performance. It’s also correctness-by-construction: your “pipeline” doesn’t accidentally do extra work or depend on side effects that should have been deferred.

The case for custom iterators over channels

It’s tempting to treat channels as a universal iterator mechanism. In practice, that’s usually a tax.

Channels shine when you’re doing concurrency: independent work streams that must overlap. But when you’re doing ordinary data traversal, channels force you to answer questions your code shouldn’t have to care about:

  • Should the pipeline run in a goroutine, or not?
  • Who closes the channel, and how do you guarantee it?
  • What happens on early termination (e.g., break)?
  • What’s the buffering strategy, and why?

Custom iterators eliminate most of that. They let you keep the shape of Go’s range loops while staying fully in the single-threaded world unless you explicitly opt into concurrency.

This is especially valuable for library code. A library that exposes an iterator-like API shouldn’t implicitly decide that its user now has to think about goroutine lifetime. With function-based range iterators, the consumer owns the loop; cancellation becomes as simple as breaking out of the loop.

The standard library’s iter package becomes more than helpers

Go’s ecosystem has long had “iterator” utilities, but the real difference here is that the language now makes iterator composition feel first-class.

The standard library’s iter package (and its map/filter/reduce style operations) becomes dramatically more usable because you can integrate those operations directly into for range loops. That means:

  • Consumers can use familiar loop constructs.
  • Iterator transformations remain lazy and composable.
  • You can build “generator patterns” without inventing your own mini-framework.

A practical way to think about it: iter gives you a vocabulary, and Go 1.23 gives you grammar. Previously, you could write pipeline logic, but the moment you wanted it to feel like iteration, you had friction. Now you can make it feel native.

Example: reduce with readable control flow

Reduction is a good example of why this matters. A reduction often wants a single pass and no intermediate allocations.

With iterators, a reducer can consume values as they’re produced. That means you can express things like:

  • Sum only items matching a condition.
  • Find the first element meeting a criterion.
  • Build a fold that carries state.

The for range integration makes these patterns straightforward to read: the loop is where the control flow is, and the iterator is where the values come from.

A more Go-like alternative to generator frameworks

If you’ve used other languages with generator syntax, you know the appeal: define a “sequence producer” once, then consume it with plain loops. Go’s history has been less syntactic here, so teams often build internal generator patterns with channels or callbacks.

Go 1.23 lets you stop doing that.

Custom iterators are the sweet spot for “generator-like” behavior in Go:

  • You can define reusable iteration sources.
  • You can chain transforms cleanly.
  • You can avoid goroutines unless you truly want concurrency.
  • You can keep the consumer side boring and idiomatic.

This is also how you avoid accidental complexity in teams. Instead of every codebase rolling its own yield abstraction (with subtle differences, error handling quirks, and inconsistent naming), you can standardize on iterator functions that work with for range.

And yes, that also makes code review easier: reviewers can reason about for range semantics without tracing channel lifetimes or callback invocation order.

Practical guidance: what to build, what to avoid

To take advantage of this feature without turning your codebase into an iterator maze, keep these principles in mind:

  1. Use iterators for lazy pipelines, not for everything.
    If you already need all results, building slices directly may be clearer and faster than chaining iterator adapters.

  2. Prefer early termination.
    The biggest practical benefit of laziness shows up when consumers can stop early. Design iterators so break stops upstream work.

  3. Don’t hide side effects in iteration.
    Iteration should generally be about traversal and transformation, not orchestration. If you must do side effects, keep them explicit in the consumer loop.

  4. Choose clarity over cleverness.
    Iterator chains can become hard to debug if you go too abstract. Use small, well-named adapter functions—especially in exported APIs.

  5. Use channels when concurrency is the point.
    If you’re doing parallel work or coordinating asynchronous producers/consumers, channels are still the right tool. Iterators are for expressive, composable single-pass iteration.

If you adopt those rules, Go 1.23’s range-over-function-types don’t just add new capability—they help you keep your code honest.

Conclusion: Go is getting more expressive without losing itself

Go’s design promise has always been simple: keep the language understandable, and add power in ways that fit the existing mental model. Range-over-function types are a perfect example. They don’t replace Go’s core iteration constructs—they extend them so custom iterators can participate as first-class citizens.

The result is a practical shift: you can build lazy filtering pipelines, generator-style sequences, and reduce-style folds without channels, without goroutine choreography, and without callbacks pretending to be control flow. The iter package gets more useful, but the bigger win is architectural: Go becomes more composable for everyday data transformation tasks.

In other words, it’s not a “minor feature.” It’s a better way to write the code you already write—just with fewer compromises.