Go 1.23 New Features: range-over-func Finally Here
Go 1.23 shipped in August 2024, bringing the long-awaited range-over-func feature. Looking back six months later, which features actually changed day-to-day development? This is a retrospective from real use, not a release announcement.
range-over-func: Changing How We Iterate
What Problem It Solves
Go has had a long-standing friction point: custom collection types couldn’t participate in for range loops. If you wrote a linked list, tree, or lazy sequence, users had to write for i := 0; i < coll.Len(); i++ or implement their own iterator methods — no idiomatic unified approach.
Before Go 1.23, lazy iteration usually required a channel:
// Old approach: lazy iteration via channel — goroutine leak risk
func integers(start, end int) <-chan int {
ch := make(chan int)
go func() {
for i := start; i < end; i++ {
ch <- i
}
close(ch)
}()
return ch
}
// If caller breaks, the goroutine blocks forever
for v := range integers(0, 1000000) {
if v > 10 {
break // goroutine leak!
}
}How range-over-func Works
Go 1.23 introduced range-over-func: functions with signature func(yield func(V) bool) can now be used directly in for range. yield is a callback — returning false signals an early exit (break) from the caller.
// A lazy integer sequence
func Integers(start, end int) func(yield func(int) bool) {
return func(yield func(int) bool) {
for i := start; i < end; i++ {
if !yield(i) {
return // caller broke out — clean exit, no goroutine leak
}
}
}
}
func main() {
for v := range Integers(0, 1000000) {
fmt.Println(v)
if v > 10 {
break // completely safe
}
}
}Real Example: Custom Tree Iterator
type TreeNode struct {
Val int
Left *TreeNode
Right *TreeNode
}
// In-order traversal as an iterator
func (n *TreeNode) InOrder() func(yield func(int) bool) {
return func(yield func(int) bool) {
var traverse func(*TreeNode) bool
traverse = func(node *TreeNode) bool {
if node == nil {
return true
}
if !traverse(node.Left) {
return false
}
if !yield(node.Val) {
return false
}
return traverse(node.Right)
}
traverse(n)
}
}
// Caller code is clean
root := buildTree()
for val := range root.InOrder() {
fmt.Println(val)
if val > 100 {
break // tree traversal stops cleanly
}
}Custom collection iteration now has a unified idiomatic approach — cleaner call sites, no goroutine leak risk for implementers. See the Go 1.23 Range Spec for full details.
iter Package: Standard Iterator Primitives
Shipped alongside range-over-func, the iter package defines two core types:
// iter.Seq: single-value iterator
type Seq[V any] func(yield func(V) bool)
// iter.Seq2: key-value iterator (like map iteration)
type Seq2[K, V any] func(yield func(K, V) bool)The standard library updated in sync. slices and maps now provide iterator-based functions:
import (
"iter"
"maps"
"slices"
)
// slices.All returns iter.Seq2[int, T]
for i, v := range slices.All([]string{"a", "b", "c"}) {
fmt.Println(i, v) // 0 a, 1 b, 2 c
}
// slices.Values returns iter.Seq[T]
for v := range slices.Values([]int{1, 2, 3}) {
fmt.Println(v)
}
// maps.Keys and maps.All
for k := range maps.Keys(myMap) {
fmt.Println(k)
}Chaining Iterators
The real power is composition — filter, map, and other operations as iterators that chain lazily:
func Filter[V any](seq iter.Seq[V], f func(V) bool) iter.Seq[V] {
return func(yield func(V) bool) {
for v := range seq {
if f(v) {
if !yield(v) {
return
}
}
}
}
}
func Map[V, W any](seq iter.Seq[V], f func(V) W) iter.Seq[W] {
return func(yield func(W) bool) {
for v := range seq {
if !yield(f(v)) {
return
}
}
}
}
// Process a large file: filter and transform line by line, no full load into memory
lines := readLines("huge-file.txt") // returns iter.Seq[string]
results := Map(
Filter(lines, func(l string) bool { return strings.Contains(l, "ERROR") }),
strings.TrimSpace,
)
for line := range results {
process(line)
}Lazy evaluation — processes one element at a time, constant memory usage regardless of file size.
Timer Fix: No More Goroutine Leaks
This is the most overlooked improvement in Go 1.23, but it has significant real-world impact.
The Old Problem: time.After Memory Leaks
// Problematic old code
func processWithTimeout(items []Item) {
for _, item := range items {
select {
case result := <-process(item):
handle(result)
case <-time.After(5 * time.Second):
// time.After creates a timer that isn't GC'd until it fires
// In a tight loop, you accumulate many unreclaimed timers
log.Println("timeout")
}
}
}The “correct” old approach required manual timer management — verbose and error-prone:
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
select {
case result := <-process(item):
if !timer.Stop() {
<-timer.C
}
handle(result)
case <-timer.C:
log.Println("timeout")
}Go 1.23’s Fix
Go 1.23 fixed this: timers created by time.After, time.NewTimer, and time.NewTicker can now be garbage collected even before they fire, as long as no references remain.
// After Go 1.23, this code no longer has a memory leak problem
func processWithTimeout(items []Item) {
for _, item := range items {
select {
case result := <-process(item):
handle(result)
case <-time.After(5 * time.Second):
log.Println("timeout")
// timer is reclaimed at next GC — no manual Stop needed
}
}
}For code making heavy use of time.After, upgrading to 1.23 typically produces observable memory improvements. See time.Timer changes.
Linker Improvements
Go 1.23 also improved the linker:
- Smaller binaries: More aggressive dead code elimination; typical Go programs shrink by ~1-2%
- Faster builds: Link phase speed improvements, more noticeable on large projects
- Expanded PGO: Profile Guided Optimization now covers more optimization passes — upgrade your Go version to benefit, no code changes needed
Looking Forward: Go 1.24 (February 2025)
Go 1.24, released in February 2025, continues where 1.23 left off:
- Weak Pointers: The
weakpackage introduces weak references — hold a reference to an object without preventing GC, ideal for caches - Map Performance: Swiss Map implementation (inspired by Abseil) with significantly better performance under high load
- Toolchain Management:
go getcan now manage the Go toolchain version directly, similar to how npm manages Node versions - Test Improvements:
testing/synctestpackage provides better support for testing concurrent code
The map performance improvement in Go 1.24 is especially worth noting — for map-heavy applications (routing tables, state machines, high-frequency lookups), the upgrade can deliver measurable throughput gains.
Go’s evolution pace is steady and deliberate. range-over-func was discussed for years before landing — and when it did, the design is clean. That’s the Go team’s consistent style: move slow, don’t introduce confusion.
Reference: Go 1.23 Release Notes · Go 1.24 Release Notes · iter package docs · Go blog