Sijin T V
Sijin T V A passionate Software Engineer who contributes to the wonders happenning on the internet

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.

comments powered by Disqus