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. 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)
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)
}
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
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.
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
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) },
)
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 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)