Generics in Go are useful when the same algorithm or container has to work over many types without losing type safety. They're also easy to overuse. This lesson covers when to use type parameters, when to avoid them, and how to avoid the patterns that make a generic API harder to read than the non-generic version it replaced.
Generics are well-suited to three concrete situations: the same algorithm running over many element types, container types that hold any element, and type-safe wrappers around code that would otherwise hand back any. If your code matches one of these shapes, type parameters are usually the cleanest answer.
The first shape is an algorithm whose body doesn't depend on the element type. A function that finds the maximum value in a slice doesn't care whether the slice holds prices, ratings, or stock counts; it only needs values that support comparison. Before generics, you'd write MaxFloat64, MaxInt, MaxString, or you'd hand back any and force the caller to type-assert. With generics, one function covers them all:
The cmp.Ordered constraint (added in Go 1.21) covers every type that supports <, >, <=, and >=. The function body is one short loop, the caller doesn't have to assert anything, and the compiler rejects nonsense like Max([]struct{}{}) at compile time.
The second shape is a container that holds any element type. A queue, a stack, a tree, a typed cache, a thread-safe map. Pre-generics, you either wrote one for int, one for string, one for each user-defined type, or you wrote one over any and lost type safety on every read.
Stack[string] and Stack[int] reuse the same code, both are fully type-safe, and the caller never has to remember which assertion to use.
The third shape is a type-safe wrapper around an existing any-typed API. A common one is parsing JSON into a known type, or pulling a typed value out of a generic key-value store. The wrapper hides the assertion in one place, and every caller benefits.
The diagram is the decision in miniature. The three left-side boxes are good candidates; the bottom two are where generics either replace the wrong tool or add weight without benefit.
Generics aren't free. They add a type parameter to the signature, an extra layer to think about during reviews, and (depending on the underlying type) a small runtime cost from indirection. Four cases come up often where the non-generic version is clearer.
Case 1: only one concrete type. A function that processes carts will only ever process carts. Making it func Process[T Cart](items []T) doesn't open up new uses, it just adds a type parameter that future readers have to parse. Use generics when you can name at least two real callers that need different Ts.
Case 2: the function is one short line. A wrapper that just returns len(s) == 0 doesn't need to be generic. len already works on every type it applies to, and the call site is shorter than the wrapper.
Case 3: the constraint becomes an interface with one method. This is the most common mistake. If you write a constraint that requires one method, you've reinvented a regular interface, just with extra ceremony.
The function body never uses T for anything except calling a method that's already part of the interface. The straightforward, idiomatic Go version is to accept the interface directly:
The interface version is shorter, the call site is identical, and there's no T for a reader to mentally track. The generic version only helps if the function does something with T that an interface can't express, like returning a T (not a Refundable) or storing it in a typed slice. If the body only calls methods, use an interface.
Case 4: the type already satisfies an interface you can use. io.Reader, io.Writer, fmt.Stringer, error, sort.Interface. When the standard library or your own codebase has an interface for the behavior you need, generics typically don't add value. The implementations are already pluggable through the interface; a type parameter is a second layer of abstraction over the first.
Cost: A type parameter on a function call adds at least one line of cognitive overhead for every reader, and sometimes more if the constraint is non-trivial. Treat it as a real cost when the function would be readable without it.
Interfaces describe what a value can do. Generics parameterize over what a value is. When the API of a function is "give me anything that can read bytes", interfaces fit better. io.Reader is a one-method interface satisfied by files, network sockets, byte buffers, gzip streams, and dozens of other types. None of them need to be the same concrete type, and no caller needs to mention a type parameter.
A common temptation is to write something like this:
The constraint is just io.Reader written inline. Every call site has to satisfy the same shape an interface would have satisfied. The function body can only call r.Read, which is exactly what an interface argument would let you do. The generic version adds a type parameter that buys nothing.
The plain version is shorter, more idiomatic, and works with every existing implementation of io.Reader:
The function signature is one line. Every existing io.Reader works. The body is identical to the generic version. There's no R to track.
If every line in your generic function body could be expressed by calling methods on an interface, use an interface. Use generics when the function needs to return the input's concrete type, store the input in a typed container, or operate over values the language's operators (+, <, ==) understand directly. Use interfaces when the function only needs the value's behavior.
The diagram captures the practical split. Interfaces cover behavior; generics cover identity and operators. Most functions touch only one of these, which makes the choice easy. The functions where both apply are rare and usually benefit from being split into two.
A constraint is a contract. The widest contract that still lets the function compile is the right one. Asking for more than you need narrows the set of callable types without adding capability.
A function that only needs to print values should constrain on fmt.Stringer, not on cmp.Ordered. A function that only needs comparison for equality should use comparable, not the full cmp.Ordered. A function that just stores values can use any. Each step up the constraint ladder excludes types that would otherwise work.
Both functions are correct, but only the second one expresses the actual contract. If somebody later adds a struct type that's comparable but not Ordered, the wide-constraint version rejects it for no reason.
A useful checklist for sizing constraints:
| You need to... | Use constraint |
|---|---|
Compare with ==, != | comparable |
Use <, >, <=, >= | cmp.Ordered |
| Add, multiply (numbers) | A custom union: ~int | ~float64 | ... |
| Call a method | An interface with that method |
| Just store the value | any |
The custom-union case is where the constraints package or your own definitions fit best. Don't use cmp.Ordered when comparable is enough, and don't use comparable when any is enough.
Cost: Over-constraining a function doesn't change its runtime cost, but it shrinks the API surface. The first time a legitimate caller can't pass a value because of an over-specified constraint, you have to widen the constraint, which is a breaking change for anybody depending on the old signature.
When a generic function uses an operator on its type parameter, the operator may compile differently for different instantiations, and the cost can move with the type. The two cases that come up most are append (which allocates when the backing array fills) and string-like conversions.
A function that builds a slice by appending is fine, but the cost story is the same as the non-generic version: appending may copy the underlying array. Many readers assume generics somehow avoid this; they don't. The function below is correct, but you should size the result slice when you can.
The make([]U, 0, len(items)) avoids the doubling growth pattern that append falls back to when capacity is exhausted. The same advice applies to non-generic code, but it's easy to forget inside a generic function because the type isn't visible at the body.
A trickier case is generic conversions. string([]byte) allocates. []byte(string) allocates. A generic function that takes T and converts it back and forth pays the allocation each time. If you find yourself writing a generic helper that turns []byte into string and back, check that you actually need both forms; usually one of them was the original input and the other is a fresh copy.
Cost: A generic helper that appends to a result slice has the same growth cost as the non-generic version. Pre-allocate the result with make([]T, 0, n) when you know the final length.
A common question: are generic functions slower than non-generic ones? The short answer is "sometimes slightly, rarely noticeably, and the gap is shrinking". The longer answer needs a quick look at how the Go compiler implements generics.
Two strategies exist in the wider language design world. Monomorphization generates a separate copy of the function body for every distinct type used. It's the strategy C++ templates use and what Rust does for <T>. The generated code is as fast as hand-written code per type, at the cost of binary size that grows with the number of instantiations.
Go's compiler uses GC shape stenciling instead. Two type parameter instantiations share generated code if they have the same "GC shape", a notion based on whether the value is a pointer, an int-sized scalar, a struct with a particular pointer layout, and so on. So Map[int, int] and Map[uint64, uint64] typically share code. Map[string, string] and Map[*Order, *Order] share code because both are pointer-shaped. But Map[int, int] and Map[*Order, *Order] don't share, because their shapes differ.
In the shared-code case, the compiler passes a small dictionary alongside the call, telling the generated code things like "for this call, the type is 8 bytes wide and has no pointers". The dictionary adds an indirection that the non-generic version doesn't have. In practice the overhead is small, often a few percent, and the Go team has been steadily closing the gap with each release.
any would have been, but it's nearly always faster than any plus type assertions.interface{} map with a generic map will make it faster" is sometimes true and sometimes flat.A practical example: slices.Index in the standard library is generic over comparable. It compiles to fairly tight code for most types, but the standard library's bytes.Index (specialized for []byte) is still faster on byte slices because it uses SIMD-friendly tricks the generic version can't. The lesson is that "generic" doesn't automatically mean "as fast as specialized".
anyNumbers ground the discussion. The benchmark below can be dropped into a test file alongside an implementation file and run with go test -bench=.. It compares three versions of "sum the values in a slice": a hand-specialized int version, a generic version, and an any-based version that needs a type assertion.
Save these as two files in the same package:
Run with go test -bench=. -benchmem. Numbers will vary by machine and Go version. A representative run on a recent Go version looks like:
The pattern is what you'd expect from the GC shape stenciling story. The generic version is within a few percent of the hand-specialized version; the any version is roughly 5x slower because each iteration does a runtime type assertion. The takeaway: replacing any with generics typically helps performance; replacing a hand-specialized non-generic function with a generic one usually doesn't, but it usually doesn't hurt much either.
Notes on running the benchmark:
-benchmem to see allocation counts. A generic function that secretly boxes its argument shows up as bytes per op.go test -bench=. -benchtime=3s runs each benchmark for longer and gives more stable numbers.Cost: A generic function whose type parameter is an interface (like [T fmt.Stringer]) often pays the same boxing cost as the any version because the dictionary holds a pointer to an interface. Operator-driven constraints over scalars are the cheap path.
A few generic patterns look attractive but pay for themselves badly.
Anti-pattern 1: imitating `Result` or `Option` from other languages. A generic Result[T, E] or Option[T] looks clean until you remember that idiomatic Go already has multiple return values for both. The native form is shorter at every call site and integrates with errors.Is, errors.As, and the rest of the standard error handling toolkit.
The idiomatic Go version is a regular function returning (value, ok) or (value, error):
The call site is the same length, the result is destructurable in one line, and the function doesn't drag a Result[T, E] type into every signature that touches it.
Anti-pattern 2: deeply nested generic types. A type like Cache[K, V comparable, F func(K) V, M Map[K, V]] requires the reader to keep four type parameters in mind to follow a single line of code. If you find yourself adding a third or fourth type parameter to a type, step back. Often the right answer is to commit to one of the parameters as a concrete type, or to break the type into two smaller types that each parameterize over fewer dimensions.
Anti-pattern 3: type-parameter explosion in helpers. Generic helpers can multiply. A MapKeys[K comparable, V any], MapValues[K comparable, V any], MapFilter[K comparable, V any], MapMerge[K comparable, V any] chain ends up with the same shape repeated everywhere. Go 1.21 added the maps package precisely so most projects don't need their own. Before writing the fifth generic map helper, check whether maps or slices already has it.
Anti-pattern 4: wrapping `any` in a generic just to avoid the keyword. A type like Box[T any] that's only ever used as Box[any] is any in a fancy wrapper. The generic version is harder to read and serializes badly. If your type only ever has one instantiation and that instantiation is any, delete the parameter.
Compare to the version that admits what it is:
Same capability, less ceremony. If a real second instantiation appears later (Box[Order], Box[Customer]), bring the parameter back then.
Type parameter names are part of the API. They show up in error messages, godoc, and IDE hovers. A few small conventions keep them readable.
For short functions where the type parameter is generic-purpose (a value, an element, a key, a result), single letters are fine and follow the conventions the standard library uses:
T for a generic element or value.K for a map key, V for a map value.E for an element of a collection, when you also need a separate T for something else.R for a result type when distinct from input.S for a slice, when you want the parameter to be the slice itself rather than its element.For long-lived APIs where the type parameter has a domain meaning, a descriptive name reads better. A generic order book type might be OrderBook[Asset] rather than OrderBook[T]. The choice depends on how much the type is going to appear in user-facing code.
What to avoid:
T1, T2, T3. Numbered parameters with no semantic meaning are hard to follow. If two parameters genuinely have no semantic difference, swap one for U or V.Conn than T.T-style conventions.One more piece of API hygiene: don't add a type parameter that isn't used in the function body or return type. Go will reject most cases at compile time (the parameter must appear somewhere), but you can game it by referencing the parameter in a comment or a never-used local. Don't. A type parameter that doesn't pull weight in the function signature is dead weight.
Cost: A type parameter name in a widely used API is hard to change later. Picking a clear name once is cheaper than renaming T across a hundred call sites.
Before writing [T any], run through this short list. If none of the first three apply, the function probably wants an interface, a regular parameter, or no parameter at all.
Refundable view of them?+, <, ==, or another operator that an interface can't express?slices, maps, or cmp already shipped what you're about to write?If you reach the bottom of the list and the answers don't justify the generic, write the non-generic version. You can always add type parameters later, but you can't take them out of a public API without breaking callers.
The e-commerce example below pulls the rules together. We need a cache that maps product IDs to product details. The cache should be type-safe (no any-typed reads), bounded in what it constrains, and shouldn't reinvent something the standard library already does.
The cache parameterizes only over V, not over K. Every cache in this hypothetical codebase uses int keys, so adding a K parameter would be type-parameter explosion. The constraint on V is the widest possible (any) because we never compare, order, or call methods on it; the cache just stores and returns. The mutex protects the map directly, and the lock is released before any I/O can happen because the fetch function is called outside the locked region. The generic part of the API is the type-safe wrapper around what would otherwise be a map[int]any.
If a future requirement is "support multiple key types", we'd add a K comparable parameter then, not preemptively now. If the requirement is "values must be invalidated by timestamp", we'd add a method, not a type parameter. The shape of the type follows the shape of the actual problem, not the shape of what generics could in principle express.
10 quizzes