Contents

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 weak package 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 get can now manage the Go toolchain version directly, similar to how npm manages Node versions
  • Test Improvements: testing/synctest package 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