catena

Go ≥ 1.27 · v1.5.0 · Apache-2.0 · zero dependencies

Say what you want from the data. Skip the loop.

Go finally has the pieces for real pipelines — iter.Seq and generic methods. catena is the library on top: chained, fully typed, lazy, and honest about what every step costs. The loops you keep rewriting are already in it, tested harder than you would test your own.

// The ten largest overdue invoices. One pass; ten rows in memory, not the table.
overdue := catena.FromSlice(invoices).
    FilterNot(Invoice.IsPaid).
    Filter(func(i Invoice) bool { return i.Due.Before(today) }).
    TopNBy(10, Invoice.Amount)
go get github.com/NerdMeNot/catena

Three things you stop writing

The grouping loop

A map, a slice of keys so the output has an order, an if _, ok, and a sort at the end. You have written it a hundred times.

One pass, memory bounded by the number of countries rather than the number of orders, keys in first-seen order, and the chain keeps going.

// Revenue per country, largest first.
ranked := catena.FromSlice(orders).
    FoldedBy(Order.Country,
        func(string) int { return 0 },
        func(sum int, o Order) int { return sum + o.Total }).
    MapTo(func(country string, total int) Row { return Row{country, total} }).
    SortedByDesc(func(r Row) int { return r.Total })

The worker pool

A semaphore, a WaitGroup, a results slice indexed so things come back in order, an error channel, cancelling the rest when one fails — and hoping nothing is still running when you return.

loadCustomer is an ordinary function. Every goroutine has exited by the time Collect returns — on success, on error, on cancel, and on panic.

// func loadCustomer(ctx context.Context, id ID) (Customer, error)

customers, err := catena.FromSlice(ids).
    Then(parallel.Map(ctx, 8, loadCustomer)). // 8 in flight, results in order
    Collect()                                 // first error cancels the rest

The error plumbing

Half the loop is if err != nil, and whether a bad row aborts the import or gets skipped is decided deep inside it, where the caller cannot change it.

The pipeline carries the errors; the consumer picks the policy. The file is opened when the pipeline runs and closed on every way out of it.

rows := source.Lines(open).MapErr(parseRow) // a failure is an element

good, err  := rows.Collect()          // stop at the first bad row
good, errs := rows.CollectAll()       // import what parses, report the rest
good       := rows.Ignore().Collect() // skip bad rows

Nothing is hidden

No reflect, no any in value position, no buffering you were not told about. An operator that buffers says so, with its bound; a terminal that would hang on an infinite sequence is marked; a bad argument panics at the line that passed it. The core package starts no goroutines — concurrency is a separate import.

Contracts tested, not promised

Every operator is registered in a conformance harness that consumes it twice, runs it over infinite sources, breaks early and checks the producer’s cleanup ran, and compares it against a hand-written loop. CI fails for an operator nothing registers. Statement coverage is 100%, enforced.

Slow only where Go is slow

A terminal over a slice compiles down to the loop you would have written: Sum runs at exactly its speed. A chain of stages costs about 2.4ns an element, which is the iterator protocol, not the library — against the same pipeline built from raw iter.Seq closures catena is faster. Every number is published, including the unflattering ones.

The operators worth switching for

Not syntax sugar — these change the memory class of the computation.

UseInstead ofAnd it costs
FoldByGroupBy + fold per bucket2.97 MB → 1.2 KB
TopNBy(10, key)SortedDesc().Take(10)1.7 MB → 1 KB, 32× faster
Sample(10, rng)collect everything, shuffle803 KB → 456 B, 4.4× faster
DedupeByDistinctBy on sorted inputunbounded → O(1)

The full benchmarks, including what a pipeline costs against a hand-written loop and why.

What is in the box