Go Generics under the Hood: Performance Costs and Monomorphization
When Go 1.18 shipped generics, the design had to thread a needle: be fast like C++ templates without their binary bloat, and be ergonomic without Java’s boxing tax. The answer was a hybrid that everyone gets slightly wrong when they describe it. Go does not monomorphize per type the way C++ and Rust do, and it does not erase types the way Java does. It uses GC-shape stenciling with runtime dictionaries. Understanding that design tells you exactly when generics are free and when they cost you.
Shape stenciling and dictionaries
The compiler compiles a generic function once per GC shape – defined by size, alignment, and whether the type contains pointers – not once per concrete type. int, int64, and float64 are all the same shape on a 64-bit platform: one compiled instance, three type arguments. Two different shapes, like int64 and string, get two instances. That keeps binaries small: a function instantiated over a hundred types compiles to a handful of copies.
What the compiled instance cannot know is type-specific behavior: a String() method, a comparison, an arithmetic identity element. Those live in a runtime dictionary passed to the function, and calls through the dictionary are indirect – they cost a lookup and break inlining at the boundary. This is the real price of Go generics, and it is why the advice holds: generics are for algorithms that are type-agnostic, not for replacing interfaces in hot dispatch loops.
For pointer-containing types there is an extra wrinkle: the compiler uses the same shape for all pointer types, and the dictionary carries the specific operations. That is why a generic map[string]*T over many Ts is a single instance, and why generic code over value types is often allocation-free where interface-based code stays boxing-heavy.
What the benchmarks say
On Apple Silicon with Go 1.24, summing a 100k-element []int64:
| Approach | ns/el | allocs |
|---|---|---|
hand-rolled int64 loop |
~1.3 | 0 |
generic Sum[T Number] |
~1.4 | 0 |
interface-based ([]any) |
~18 | 1 per element |
The generic version lands within noise of the hand-rolled loop: the arithmetic compiles to the same instructions, with no boxing anywhere. The interface version boxes every element into an any and runs 10x+ slower. That is the whole argument for generics in one table: abstraction without turning your data into any.
Where the dictionary bites: calling a method on a type parameter in a tight loop. T.Method() compiles to an indirect call through the dictionary on every iteration. If that method call dominates, generics can be slower than a concrete type with a direct call, and the fix is usually to lift the call out of the loop or accept the concrete type.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package main
import "sort"
type Number interface {
~int | ~int32 | ~int64 | ~float64
}
// Sum compiles once per GC shape (all 64-bit numbers share one instance);
// the loop is identical to a hand-rolled one.
func Sum[T Number](xs []T) T {
var total T
for _, x := range xs {
total += x
}
return total
}
type ordered interface {
~int | ~int64 | ~string
}
func Sort[T ordered](xs []T) {
sort.Slice(xs, func(i, j int) bool { return xs[i] < xs[j] })
}
func main() {
_ = Sum([]int64{1, 2, 3, 4, 5})
nums := []int64{5, 2, 8, 1}
Sort(nums)
}
Where I use them and where I do not
I reach for generics for containers and algorithms: a Set[T comparable], a retry helper parameterized over a call’s result type, a Cache[T] built on atomic.Pointer. These are cases where the alternative is either any-based code with boxing allocations or copy-pasted variants per type. I avoid generics for dependency injection and for interface-heavy plugin designs: a generic with a method-heavy constraint is a dictionary call on every dispatch, and the constraint syntax actively harms readability when what you actually want is an interface.
One genuine pitfall: a Number constraint like the one above (a union of ~int | ~int64 | ~float64) looks free, and for arithmetic it is. But if your algorithm calls methods, make sure they are really per-shape constants and not something you can hoist. And remember the golden rule: a type parameter is erased at the source, so a generic over comparable that still hashes values through an interface will box. Prefer concrete shapes for pointer-free hot paths.
Production lessons
- Generics give you abstraction at hand-rolled performance for value types; they are the right tool for containers and algorithms.
- The dictionary is the cost center: indirect calls through it break inlining and add latency in method-heavy loops.
any-based alternatives box every value; on hot paths that is 5-10x, not 5-10%.- Constraint unions are fine for arithmetic; method-heavy generic code should make you stop and reconsider an interface.
- Benchmark generic versus interface versus concrete on the actual type before committing; the answer depends on the shape and the call mix.