Go Generics, One Year In: Which Promises Held, Which Didn't

Go 1.18 shipped generics in March 2022. Four years later, the honest picture: they're useful for a narrower set of problems than the community hoped, they fit naturally in a few places, and overuse them and your code gets worse. A production retrospective.

March 18, 2026
Harrison Guo
8 min read
System Design Backend Engineering

Go 1.18 shipped generics in March 2022. The two years before that were dominated by hopeful blog posts (“finally, a real type system!”) and the two years after by the predictable backlash (“why did we even bother, Go was simpler”). I’ve written production Go before and after. The honest answer is somewhere in the middle and closer to “useful for a narrower set of problems than we expected.”

This is a look back from someone who has shipped generic code in anger and reviewed a lot more of it. What held up. What didn’t. What habits to adopt and which to avoid.

tl;dr — Go generics are genuinely valuable for parametric operations on container-shaped types — slices, maps, channels, any-key lookup tables, min/max/sum utilities. Less valuable for “clever abstractions” that dress up control flow as type magic. The clearest gains are in the standard library itself (slices, maps) and in domain-specific utility packages. Most application code didn’t need generics before and doesn’t need them after. The mistake is not using generics; it’s using them for things interfaces already handled fine.


What Generics Actually Are

Go generics are type parameters on functions and types. A function like slices.Contains can be written once, work for any slice element type, and still be type-checked at compile time:

func Contains[S ~[]E, E comparable](s S, v E) bool {
    for _, x := range s {
        if x == v {
            return true
        }
    }
    return false
}

Three features you should know:

  • Type parameters: the [E any] or [E comparable] in brackets.
  • Constraints: tell the compiler what operations the type parameter supports. any, comparable, or custom interfaces like constraints.Ordered.
  • Approximate constraints: ~[]E means “any type whose underlying type is []E” — lets you be flexible about named slice types.

What they aren’t: Java-style wildcards, C++ SFINAE, or anything that mimics variance. The design is deliberately narrower than most prior languages. It’s more like Rust’s generics, minus the trait system’s complexity.

Where Generics Clearly Win

Standard-library style container and utility functions

The slices and maps packages in the standard library are the canonical example:

slices.Contains(users, "alice")
slices.Sort(numbers)
maps.Keys(config)
maps.Values(settings)

Before generics, these were either hand-written per-type (tedious, error-prone), done via interface{} (type-unsafe, slow), or done via reflect (slow and error-prone). Generics are strictly better for these.

The same pattern shows up in third-party libraries: samber/lo (JS-style utilities), thoas/go-funk (functional helpers), and many domain-specific ones. If you reach for lodash-style helpers in JavaScript, you’ll want similar in Go, and generics made that workable.

Concurrency helpers

Generic worker pools, futures, result types — these all benefit from generics:

type Future[T any] struct {
    done chan struct{}
    val  T
    err  error
}

func (f *Future[T]) Get() (T, error) {
    <-f.done
    return f.val, f.err
}

Before generics, you’d have had an interface{} return and a type assertion at the call site. Now you can express “this future produces a T” in the type. Cleaner at the boundary, safer at the call site.

Typed collections

If your system has a genuinely typed container use case — say, an ordered map keyed by a domain ID — generics let you write it once:

type OrderedMap[K comparable, V any] struct {
    order []K
    data  map[K]V
}

This is a rare case where “custom generic container” is the right tool. The majority of code doesn’t need this. But when you do need it, the generics version is much better than the interface{} alternative.

Numerical / algorithmic code

constraints.Ordered (or its post-1.21 replacement cmp.Ordered) is the key constraint for “works for any numeric or ordered type”:

func Max[T cmp.Ordered](a, b T) T {
    if a > b { return a }
    return b
}

Math helpers, min/max, sum, average — all cleanly generic. Readable, type-safe, performant.

Where Generics Don’t Help, Or Hurt

“Generic services” and similar framework-y code

I’ve seen codebases where someone wrote a generic “repository” type:

type Repository[T any] struct { /* ... */ }
func (r *Repository[T]) FindByID(id string) (T, error) { /* ... */ }
func (r *Repository[T]) Save(t T) error { /* ... */ }

The instinct — “all repositories do the same thing” — is mostly wrong. Real repositories differ in query shape, error cases, caching rules, transaction boundaries. Forcing them behind a generic interface either (a) produces a lowest-common-denominator API that doesn’t fit any actual use, or (b) gets so many type parameters that readability collapses.

The Go idiom is usually better: one non-generic UserRepository, one OrderRepository, etc. Each concrete, each tuned to its domain.

Over-constrained helpers

If your “generic” function has five type parameters with custom constraints each, readability dies:

func Complicated[
    T comparable,
    K Hashable,
    V any,
    F func(T, K) (V, error),
    M map[K]V,
](items []T, f F, cache M) error { /* ... */ }

This is technically legal. Reading it, you realize it’s a glorified map-with-cache-and-error. Interfaces or function types would have been clearer. Generics don’t make complex APIs simple; they just let you make them complex in a type-checked way.

Behavioral polymorphism

Interfaces are still the right tool when different types have different behavior. A generic Process[T any](x T) error doesn’t help if you actually want different logic per type. You want an interface with a method.

// Good use of interface
type Processor interface {
    Process(ctx context.Context) error
}

// Bad use of generics
func ProcessGeneric[T any](x T) error {
    // can't actually differentiate behavior
}

The separation: generics for parametric operations (same logic, any type), interfaces for polymorphic behavior (different logic per type).

Performance: Usually a Wash

The performance story is more nuanced than either “generics are slow” or “generics are free.”

Go’s current generic implementation uses GCShape stenciling — one compiled version per “GC shape” (roughly, per memory layout). This is between full monomorphization (one version per type, like Rust) and type-erased dispatch (one version total, like Java’s reified-erased hybrid).

Practical implications:

  • Small primitive types (int, int64) often get specialized versions. Competitive with hand-written.
  • Pointer-sized types (most structs, interfaces) share code. Slightly slower than hand-written but usually faster than interface-based dispatch.
  • Call overhead is similar to function calls, not interface dispatch. No devirtualization issue.
  • Compile times increase, especially for libraries with many instantiations. This is the real cost.

Benchmarks I’ve seen: generic versions are within 5% of hand-written equivalents, and consistently faster than interface{}-based alternatives. Performance is almost never the deciding factor — readability and design fit matter more.

Idioms That Emerged

Over the years since 1.18, a few conventions have stuck:

Prefer any to interface{}

any is a type alias for interface{} added in 1.18. Shorter, clearer. Use it everywhere.

Single-letter type parameters for simple cases, descriptive for complex

T, K, V for the obvious cases. More descriptive when the role is specific:

func Reduce[In any, Out any](items []In, f func(Out, In) Out, initial Out) Out

Put constraints in a dedicated package

If you have several custom constraints, group them:

package constraints

type Ordered interface { ~int | ~int64 | ~string | ~float64 }
type Numeric interface { ~int | ~int64 | ~float64 }

The standard golang.org/x/exp/constraints (and later cmp.Ordered in 1.21) set the pattern.

Use ~T approximations for flexibility

~[]E includes named slice types. ~int includes type MyInt int. Almost always the right choice for generic parametric code; refuses arbitrary extension.

Never overload generic helpers to do too much

Each generic function should do one parametric thing. Generic helpers that try to be many things at once collapse under type-parameter weight.

The Standard Library Won

The clearest vindication of Go generics is what happened to the standard library. slices, maps, cmp.Ordered — these additions are uncontroversially better than the pre-1.18 alternatives. A lot of code that used to be hand-rolled or based on sort.Interface has cleaner replacements.

The user-land picture is more mixed. Libraries that benefit from generics genuinely use them well (samber/lo, kelindar/column, many others). Libraries that don’t need them mostly haven’t been retrofitted with them.

What I Do Now

A few simple rules I apply:

  1. Prefer standard library generic helpers over hand-rolled. slices.Contains, slices.Sort, maps.Keys — use them.
  2. Write a generic helper only when I have at least two concrete use cases for it. One use case is a pattern waiting to be born, not necessarily a generic.
  3. Prefer functions to methods on generic types when possible. Generic methods have more friction (can’t overload by type, can’t add methods outside the defining package).
  4. Keep constraints simple. any, comparable, cmp.Ordered, and domain-specific single-type-union constraints cover 95% of cases. More complex constraints usually mean the abstraction is wrong.
  5. Never turn interfaces into generics just because you can. If the types have genuinely different behavior, an interface is right.

Where Generics Actually Sit Now

Generics were oversold before they landed (“Go finally becomes a real language!”) and oversampled in the aftermath (“generics everywhere!”). The truth is narrower and more boring: they’re a useful addition for a specific class of problems, mostly centered on parametric operations over containers and numerics. They improved the standard library. They haven’t changed the shape of most Go code.

If you’ve been writing Go and wondering whether you’re missing out by not using generics, the answer is almost certainly no. Code without them is still idiomatic. Code with them, when the use case fits, is cleaner. Neither is dominant. Both are fine.

The one concrete thing I’d say: learn the generic parts of the standard library. slices, maps, cmp.Ordered. Use them reflexively. Stop hand-rolling indexOf and contains. Everything else can wait until you have a real problem that generics solve.


🎧 More Ways to Consume This Content

Comments

This space is waiting for your voice.

Comments will be supported shortly. Stay connected for updates!

Preview of future curated comments

This section will display user comments from various platforms like X, Reddit, YouTube, and more. Comments will be curated for quality and relevance.