DEV Community

stepinto.dev
stepinto.dev

Posted on Originally published at stepinto.dev

Range-Over-Func Iterators for People Who Miss LINQ

The most common complaint in the comments on this series, and the first item in what I miss from C#, is LINQ. That post shows this:

var activeUsers = users
    .Where(u => u.IsActive)
    .OrderBy(u => u.Name)
    .Select(u => new { u.Id, u.Name })
    .ToList();
Enter fullscreen mode Exit fullscreen mode

and then the for loop and the sort.Slice call you wrote instead. Go has had range-over-func iterators since 1.23, the standard library has been growing functions that return them in every release since, and Go 1.27 removed the one language restriction that made a fluent chain impossible. This post is the honest side by side. Every snippet runs.

What an iterator is in Go

In C#, IEnumerable<T> is a pull model. The consumer holds an enumerator and calls MoveNext() until it says no. A yield return method is compiled into a state machine that implements that interface for you.

Go went the other way. An iterator is a function that takes a callback:

type Seq[V any] func(yield func(V) bool)
Enter fullscreen mode Exit fullscreen mode

The iterator pushes values into yield, and yield returns false when the consumer wants to stop. The for range statement knows how to drive one:

func Naturals() iter.Seq[int] {
    return func(yield func(int) bool) {
        for i := 0; ; i++ {
            if !yield(i) {
                return
            }
        }
    }
}

for n := range Naturals() {
    if n > 3 {
        break   // yield returns false, the iterator returns, nothing leaks
    }
    fmt.Println(n)
}
Enter fullscreen mode Exit fullscreen mode

That is the whole mechanism. The body of your for loop becomes the yield function. break becomes yield returning false. There is no state machine and no interface, which is why the compiler can inline simple cases and why the design went through so many rounds before it shipped.

There is also iter.Seq2[K, V] for pairs, which is what maps.All and slices.All return, and iter.Pull for the rare case where you really do need MoveNext() semantics, at the cost of a goroutine-like coroutine behind the scenes. You will almost never want it.

What the standard library gives you

Sources and sinks, mostly. No adapters.

slices.Values(s)        // iter.Seq[E]
slices.All(s)           // iter.Seq2[int, E]
slices.Backward(s)
slices.Chunk(s, n)
maps.Keys(m)
maps.Values(m)
maps.All(m)
strings.Lines(s)        // Go 1.24
strings.SplitSeq(s, sep)
strings.FieldsSeq(s)

slices.Collect(seq)     // []E
slices.Sorted(seq)      // []E, sorted
slices.SortedFunc(seq, cmp)
maps.Collect(seq2)      // map[K]V
Enter fullscreen mode Exit fullscreen mode

So the first thing LINQ people reach for already works:

keys := slices.Sorted(maps.Keys(m))
Enter fullscreen mode Exit fullscreen mode

That line replaced a nine line function in half the Go codebases I have worked in.

What is missing is Where, Select, Take, First and friends. The proposal to add them as x/exp/xiter was withdrawn after a long argument about whether adapters encourage over-abstraction. There is a decent chance they come back once the community has settled on idioms. For now you write them, and they are short.

Writing Where and Select

func Where[T any](seq iter.Seq[T], keep func(T) bool) iter.Seq[T] {
    return func(yield func(T) bool) {
        for v := range seq {
            if keep(v) && !yield(v) {
                return
            }
        }
    }
}

func Select[T, U any](seq iter.Seq[T], f func(T) U) iter.Seq[U] {
    return func(yield func(U) bool) {
        for v := range seq {
            if !yield(f(v)) {
                return
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Each one is a function that returns a function. The !yield(v) check is the only line that matters: it is what lets a break three layers up stop an infinite source at the bottom.

func First[T any](seq iter.Seq[T]) (T, bool) {
    for v := range seq {
        return v, true
    }
    var zero T
    return zero, false
}

first, _ := First(Where(Naturals(), func(n int) bool { return n > 100 }))
// 101, and Naturals stopped at 101
Enter fullscreen mode Exit fullscreen mode

Lazy, composable, early-terminating. All three properties the "what I miss" post said Go did not have.

The example, rewritten

With those two helpers and slices.SortedFunc, the LINQ query from the top becomes:

active := slices.SortedFunc(
    Select(
        Where(slices.Values(users), func(u User) bool { return u.IsActive }),
        func(u User) UserSummary { return UserSummary{u.ID, u.Name} },
    ),
    func(a, b UserSummary) int { return cmp.Compare(a.Name, b.Name) },
)
Enter fullscreen mode Exit fullscreen mode

It does the same thing. It is lazy until the sort. And it reads inside-out, which is the complaint everyone has about it within five minutes. In C# the pipeline reads top to bottom because Where and Select are extension methods on the sequence. In Go, up to 1.26, there were two reasons you could not do that:

  1. You cannot define methods on a type from another package, so nothing can hang off iter.Seq directly. You need your own named type.
  2. Methods could not have their own type parameters, so even on your own Query[T] type, a Select that returns Query[U] was impossible to declare. That is the one that killed every attempt at a fluent LINQ in Go.

The first restriction is permanent and the workaround is one line. The second one is gone.

Go 1.27 and generic methods

As of August 2026 a method can declare type parameters of its own. So this is now legal:

type Query[T any] iter.Seq[T]

func From[T any](s []T) Query[T] { return Query[T](slices.Values(s)) }

func (q Query[T]) Where(keep func(T) bool) Query[T] {
    return func(yield func(T) bool) {
        for v := range q {
            if keep(v) && !yield(v) {
                return
            }
        }
    }
}

func (q Query[T]) Select[U any](f func(T) U) Query[U] {
    return func(yield func(U) bool) {
        for v := range q {
            if !yield(f(v)) {
                return
            }
        }
    }
}

func (q Query[T]) OrderBy(cmp func(a, b T) int) Query[T] {
    return From(slices.SortedFunc(iter.Seq[T](q), cmp))
}

func (q Query[T]) ToSlice() []T { return slices.Collect(iter.Seq[T](q)) }
Enter fullscreen mode Exit fullscreen mode

And the query reads the way your C# brain wants it to:

active := From(users).
    Where(func(u User) bool { return u.IsActive }).
    OrderBy(func(a, b User) int { return cmp.Compare(a.Name, b.Name) }).
    Select(func(u User) UserSummary { return UserSummary{u.ID, u.Name} }).
    ToSlice()
Enter fullscreen mode Exit fullscreen mode

Type inference fills in U from the function you pass, so you never write Select[UserSummary]. The two caveats from the release notes: an interface still cannot declare a generic method, and a generic method cannot satisfy an interface. For a query type that is irrelevant. For anyone hoping to write IQueryable, it is the end of the road, and honestly good.

Whether you should build this is a separate question. The Go answer is still that a pipeline of three steps is a for loop with three lines in it, and the loop is easier to debug. Where Query earns its keep is the same place LINQ does: when the pipeline is the point, when it is reused, or when the source is lazy and you want First to stop it.

What it costs

Each stage of the pipeline is an indirect function call per element. The compiler inlines the simple cases but not a chain of closures. Filtering and projecting a hundred thousand structs on Go 1.25, three runs each:

ns/op allocs/op
for loop with append 500,000 23
Collect(Select(Where(Values(...)))) 920,000 33

Roughly double on a benchmark that does nothing but iterate. In a real handler the database round trip dwarfs it. It is the same trade LINQ makes against a foreach, and the same answer: write the pipeline, profile before you unroll it.

The honest table

LINQ Go now Notes
Where write it, 8 lines or Query.Where on 1.27
Select write it, 8 lines generic method needs 1.27
OrderBy slices.SortedFunc materialises, same as LINQ
First / FirstOrDefault write it, 6 lines returns (T, bool)
Take / Skip write it easy, counter in the closure
Any / All / Count loop or write it three lines each
ToList slices.Collect
ToDictionary maps.Collect from a Seq2
Distinct slices.Sorted + slices.Compact or a map, if order matters
GroupBy write it, returns map[K][]T no lazy grouping
Zip / Join iter.Pull on one side ugly, write the loop
Aggregate loop a fold is a loop
SelectMany nested range in a closure fine
IQueryable nothing and nothing coming

Everything in the "write it" column is under ten lines and lives in one file in your module. There are third-party packages that have already written them, and the lo library the old post mentioned now has an it subpackage built on iter.Seq, but a dependency for forty lines of code is a bad Go instinct and the standard library may yet absorb them.

What I would say now

The "what I miss" post said Go's approach "lacks the expressiveness" of LINQ. In 2024 that was fair. In 2026 the laziness and composition are there, the standard library sources and sinks are there, and generic methods mean the fluent form is a type definition away. What is still missing is the adapters in the box and the convention that everyone uses the same ones, which is the thing that makes LINQ readable across codebases rather than just within one.

So: less missing than I said. Not gone. Write the eight lines, keep the loop for the simple cases, and do not import a query language.

Top comments (0)