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();
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. So this is the comparison that post hinted I ought to write at some point. I ran every snippet here on 1.27 before writing it up, and where .NET is better, or where I think Go is still missing something, I will point that out.
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. Unrolled, a foreach is roughly this:
IEnumerable<int> Naturals()
{
for (var i = 0; ; i++)
yield return i; // pauses here, resumes on the next MoveNext()
}
// what foreach (var n in Naturals()) expands to, more or less
using var e = Naturals().GetEnumerator();
while (e.MoveNext()) // the consumer asks for the next value
{
var n = e.Current; // the producer has left it here
if (n > 3) break; // consumer stops asking; Dispose() runs
Console.WriteLine(n);
}
The consumer is in charge. It pulls a value, does something, and decides whether to pull again. The producer only runs when asked.
Go went the other way. An iterator is a function that takes a callback:
type Seq[V any] func(yield func(V) bool)
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)
}
If you are coming from C# the odd part is that yield returns a bool and nobody in the loop appears to return one. The compiler does it. It lifts the body of your for loop into a function and hands that to the iterator as yield. Running off the end of the body, or hitting continue, is a return true: keep going. A break, a return from the enclosing function, a goto out of the loop or a panic is a return false: stop, I am done. You never write true or false yourself, and if you ever hand-roll a consumer instead of using for range, remembering to return false on your exit path is the thing you will forget.
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
Mostly sources and sinks, and no adapters at all.
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
So the first thing LINQ people reach for already works:
keys := slices.Sorted(maps.Keys(m))
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.
Note: the over-abstraction worry is not snobbery, and anyone who has reviewed a lot of LINQ will recognise it. Once
WhereandSelectare in the box, this happens:n := Count(Where(Values(orders), func(o Order) bool { return o.Status == Shipped }))Three function calls, two closures and a package import to express
n := 0 for _, o := range orders { if o.Status == Shipped { n++ } }which any Go programmer reads at a glance and the debugger steps through line by line. The C# equivalent,
orders.Count(o => o.Status == Shipped), is actually shorter than the loop, so LINQ wins there. The Go version is not, and that is the argument against shipping the adapters: once they exist, people reach for them on three-line loops where they add nothing, because that is the habit LINQ built.For what it is worth, I am on the other side of that argument. I have seen the bad LINQ habits, and I have also seen the tooling that grew up around them: the IDE refactors that turn a loop into a query and back, and senior developers who take an over-abstracted chain and reshape it into something clearer in a minute. The abstraction is fixable by people and by tools. What is not fixable is the cognitive load of reading "throw away the index, call the order o, if its status is shipped add one to n" when what the code means is "count the shipped orders".
orders.Count(o => o.Status == Shipped)is declarative. It says what, and leaves the how to the library. Any developer can read the loop, but the one-liner is read, not parsed, and I would rather have that in the box and tolerate the occasional silly chain than not.
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
}
}
}
}
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
That gives you laziness, composition and early termination, which are the three things 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) },
)
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:
- You cannot define methods on a type from another package, so nothing can hang off
iter.Seqdirectly. You need your own named type. - Methods could not have their own type parameters, so even on your own
Query[T]type, aSelectthat returnsQuery[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)) }
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()
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 I am rather glad about that.
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.
LINQ, method by method
| 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.
A small detour through 2014
Around the time I was arguing with LINQ in anger, everyone else was arguing with Hadoop. Map, shuffle, reduce. You wrote a Mapper class and a Reducer class, a framework shipped them to a cluster, and a few hours later a CSV of counts fell out. Looking at iter.Seq now, the shape is familiar enough to be worth a side by side, so here is the whole of MapReduce in Go, minus the cluster, the JVM, the XML and the wait:
type Mapper[In any, K comparable, V any] interface {
Map(in In, emit func(K, V))
}
type Reducer[K comparable, V any, Out any] interface {
Reduce(key K, values []V) Out
}
func RunJob[In any, K comparable, V any, Out any](
input iter.Seq[In], m Mapper[In, K, V], r Reducer[K, V, Out],
) map[K]Out {
shuffle := map[K][]V{}
for in := range input {
m.Map(in, func(k K, v V) { shuffle[k] = append(shuffle[k], v) })
}
out := make(map[K]Out, len(shuffle))
for k, vs := range shuffle {
out[k] = r.Reduce(k, vs)
}
return out
}
A job is two types with one method each. Mean response time of the pricing page by country, say:
type SlowPagesByCountry struct{}
func (SlowPagesByCountry) Map(c Click, emit func(string, uint16)) {
if c.Page == "/pricing" {
emit(c.Country, c.Ms)
}
}
type MeanMs struct{}
func (MeanMs) Reduce(country string, ms []uint16) float64 {
var sum uint64
for _, m := range ms {
sum += uint64(m)
}
return float64(sum) / float64(len(ms))
}
The iterator version of the same job needs the Where and Select from earlier plus one more helper, a fold by key that keeps a single accumulator per key rather than a slice of everything it has seen:
func GroupFold[T any, K comparable, A any](
seq iter.Seq[T], key func(T) K, fold func(A, T) A,
) map[K]A {
acc := map[K]A{}
for v := range seq {
k := key(v)
acc[k] = fold(acc[k], v)
}
return acc
}
type stat struct{ sum, n uint64 }
pricing := Where(Clicks(n), func(c Click) bool { return c.Page == "/pricing" })
byCountry := GroupFold(
Select(pricing, func(c Click) KV[string, uint16] { return KV[string, uint16]{c.Country, c.Ms} }),
func(kv KV[string, uint16]) string { return kv.Key },
func(s stat, kv KV[string, uint16]) stat { return stat{s.sum + uint64(kv.Val), s.n + 1} },
)
Now for the big data. I do not have a terabyte of click logs, and the disk this is being written on would not thank me for one (ha), so the input is an iterator that invents each record as it is asked for and forgets it the moment the consumer moves on:
func Clicks(n int) iter.Seq[Click] {
return func(yield func(Click) bool) {
var x uint64 = 0x9E3779B97F4A7C15 // xorshift, same stream every run
for i := 0; i < n; i++ {
x ^= x << 13; x ^= x >> 7; x ^= x << 17
c := Click{
UserID: uint32(x % 1_000_000),
Country: countries[(x>>20)%uint64(len(countries))],
Page: pages[(x>>28)%uint64(len(pages))],
Ms: uint16(x>>36) % 2000,
}
if !yield(c) {
return
}
}
}
}
A billion of those is 48 GB if you held them as structs (two string headers and padding do that to a four-field record), and the point is that nobody does. Both jobs, one after the other, single-threaded on an M4 Max with Go 1.27.3:
| Wall time | Peak memory | |
|---|---|---|
RunJob (map, shuffle, reduce) |
13.0 s | 910 MB |
GroupFold(Select(Where(Clicks(n)))) |
6.9 s | a map with ten entries |
Same ten means to two decimal places, as they should be. The 910 MB is the shuffle: RunJob has to hold every emitted value for a key until the reducer runs, which is the thing a cluster existed to spread across machines. The iterator pipeline never holds more than one record and ten accumulators, so a billion records costs the same memory as a thousand. The time difference is mostly the append into the shuffle and the second pass over it.
None of this is a dig at Hadoop. In 2014 the data did not fit on one machine, and the shuffle was the price of using forty. On the laptop you have now, a pull-nothing, push-one-at-a-time iterator over a lazy source does the same job in a function, and break still works if you only wanted the first country over a second. The code is at github.com/AndyCross/go-mapreduce-iter if you want to run it.
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 it is less missing than I said, though not gone. Write the eight lines, keep the loop for the simple cases, and do not import a query language.
Top comments (0)