Generics in Go: eleven types, six code bodies, and a method still reached through a dictionary
The language first: a constraint defines a type set, a type set defines the operations, and no interface can preserve the relation between an input type and an output type. Then the compiler: Go emits a version not per type but per shape — eleven types gave six code bodies, three different pointers gave one. And only then the measurement: a generic over a constraint with methods gives no direct call and trails fourfold, but four fifths of that four is lost inlining rather than indirection, and here that is measured rather than supposed.
Full technical treatment
TL;DR
- Go monomorphises not per type but per GC shape: one body per shape. Eleven types gave six bodies; three pointers gave one.
- On go1.24.7 one question settled whether pointers collapse: is the
constraint a method set?
anyandStringerare, and pointers collapse.comparableis not, and each gets its own body (the same eleven types give eight). That is an observation about the compiler, not a rule of the spec. - A generic with methods in the constraint does not give you a direct call: 1,342 ns against 321 for a direct one. But "costs what an interface costs" cannot be said: here the interface came out at 2,185, while an earlier run of this article had it level with the generic.
- Four fifths of that fourfold gap is lost inlining. Forbid inlining on the direct path and it rises from 321 to 1,143, and the generic trails by 1.39 rather than 4.18.
- Where the constraint has no methods, the generic cost nothing in any of three benchmarks: same bytes, same allocations as hand-written code.
[]anyis where you pay: 1,001 allocations against one.
One body per shape, not per type
The compiler emits code not for each substituted type but for each shape — size, alignment, and where the pointers sit inside. Everything the body lacks in order to know the concrete type is passed as a hidden argument: a dictionary.
None of this has to be taken on trust: bench/gogenerics/shapes.go builds a
probe package and reads the symbol table through go tool nm. Both the bodies
(main.AnyBox[go.shape.*uint8]) and the dictionaries
(main..dict.AnyBox[*main.A]) are visible there.
Three rules, all three on the figure:
- pointers collapse if the constraint is a method set;
- a named type takes the shape of its underlying type (
Celsius,Metersandfloat64are one body); int64andfloat64do not merge, though both are eight bytes — and that is by design: their operations differ.
Why the method is still called indirectly
Since three pointers share one body, that body does not know whose method to call. The address cannot be placed into the code at build time — it comes from the dictionary.
| how the method is reached | ns | vs direct |
|---|---|---|
| directly | 321 | ×1.00 |
generic, [T Namer] | 1,342 | ×4.18 |
| interface | 2,185 | ×6.81 |
The generic does not deliver the speed of a direct call — that is the headline and it does not move. The generic-to-interface ratio does move: an earlier run of this article had them level, here the generic is markedly cheaper. It is no basis for a choice.
The fourfold gap, though, can be broken down. Put //go:noinline on the method
so that the direct path also makes a real call:
| how the method is reached | ns | vs direct |
|---|---|---|
directly, //go:noinline | 1,143 | ×1.00 |
generic, [T Namer] | 1,585 | ×1.39 |
| interface | 2,198 | ×1.92 |
Without inlining the direct call went from 321 to 1,143 — so inlining was worth about 822 ns of the 1,021 the generic trailed by. Roughly four fifths of the gap is lost inlining, not indirection.
But only if the constraint has methods
| what we do | by hand | generic |
|---|---|---|
read a thousand int64 | 307 ns | 308 ns |
| build a slice | 3,303 ns, 8,192 B, 1 alloc | 3,290 ns, 8,192 B, 1 alloc |
| one comparison | 0.4426 ns | 0.4523 ns |
Bytes and allocations match exactly, and the times sit inside the round-to-round
spread. For [T ~int64] or [T ordered] the body
is compiled for a concrete shape, and nothing of the generic survives into
machine code.
The rule worth taking away: not "generics are fast" and not "generics are slow", but look at the constraint.
The real price is not the generic — it is any
| what we build | ns | bytes | allocations |
|---|---|---|---|
[]int64 | 3,303 | 8,192 | 1 |
generic []T | 3,290 | 8,192 | 1 |
[]any | 16,114 | 24,384 | 1,001 |
A slice of "type descriptor, pointer to value" pairs — sixteen bytes per element — plus a box per value: 16,384 plus 8,000 makes 24,384. This is what a generic saves you from, in multiples.
Caveat: the conversion to any does not itself require the heap. Whether the
value moves there is decided by the escape analysis of the particular code
and the particular compiler version. Here the values are collected into a slice
and live on, so they escape; in the one-comparison benchmark the same conversion
allocates nothing.
And another: sixteen bytes per element is the layout of this build on
linux/amd64, not a contract of any.
What measured this
Every number above was taken on go1.24.7 linux/amd64, Intel Xeon 2.10GHz,
GOMAXPROCS = 2, over seven interleaved rounds; the whole run sits in
bench/gogenerics/runs/cost.txt. The count of code bodies and dictionaries comes
from bench/gogenerics/shapes.go.
The language has moved on in the meantime: Go 1.26 lifted the ban on a constraint referring to itself, and Go 1.27 added generic methods — both checked here by the compiler rather than taken from the release notes. The measurements were repeated on 1.27 too: the shape table matched byte for byte, and in a pair of runs on one machine the two toolchains' ranges overlapped in every row. On these workloads 1.27 changed nothing.
Do not expect your times to match these: expect the ratios within a single table to match — and not even all of them, as the generic-and-interface pair shows. The count of code bodies is not measured with a stopwatch; it comes from the symbol table, and for the same source, compiler version and target configuration it will be the same.
TL;DR
Three layers, and the article walks them in this order: language (what a generic expresses), compiler (how go1.24.7 implemented it), measurement (what it cost on one machine). Running them together is the central mistake in arguments about generics.
Language. A constraint defines a type set, a type set defines the operations the body is permitted. A generic is wanted where one algorithm serves a family of types, where a container does not care what is inside, and — above all — where the relation between the input type and the output type must be preserved: no interface can express that. Where each type behaves differently and the code calls one method, an interface is the honest choice. The state of the language today is Go 1.27 with generic methods — checked by the compiler, not read off the release notes. Every table below was taken on go1.24.7; what of it changes on 1.27 is checked too, and the answer is nothing.
Compiler. Go monomorphises not per type but per GC shape: one code body per shape, and a shape often holds several types. A dictionary, passed as a hidden argument, is what lets the shared body tell them apart.
- Eleven types, six bodies. Taken from the symbol table: three different
pointers give one body, and so do
Celsius,Metersandfloat64. int64andfloat64do not merge, though both are eight bytes — and that is by design. Pointers, on the other hand, the implementation document merges unconditionally while the compiler merges them only under a basic interface — a constraint defined by methods alone: there the implementation is narrower than the document.- On go1.24.7 one property of the constraint decided whether pointers
collapse: is it a basic interface, that is, is its type set defined by
methods only.
any,Stringerand an interface of two methods are basic, and pointers collapse.comparable, a union*A | *B | *Cand "Stringerpluscomparable" are not, and then each pointer gets its own body: the same eleven types undercomparablegive eight. Checked on six constraints rather than three: the last of them does contain a method, and still does not collapse. The specification defines neither shapes nor dictionaries nor a count of bodies — this is an observation about the compiler.
Measurement. go1.24.7 linux/amd64, seven interleaved rounds, the whole run
in bench/gogenerics/runs/cost.txt.
- With a method constraint the generic did not remove the indirect call. 1,342 ns against 321 for a direct one — ×4.18; an interface is 2,185, that is ×6.81. What follows is neither "a generic equals an interface" nor "a generic beats an interface", but "swapping an interface for a generic does not by itself give you a direct call".
- The fourfold gap is broken into its parts rather than left a guess. Forbid inlining on the direct path and it rises from 321 to 1,143: about four fifths of the gap is lost inlining, not indirection. Against an honest direct call the generic is only ×1.39 behind. The interface is ×1.92 behind, and a fifth of that is not dispatch at all but twice as much data: 8 KiB of pointers against 16 KiB of interface values. On another machine instance indirection's share came out at zero and inlining's at a hundred per cent: the order of the terms carries over, the shares do not.
- 1.27 changed nothing on these workloads. The symbol table matched byte for byte, the compiler refusals word for word, and in a pair of runs on one machine the two toolchains' ranges overlapped in every row of every block.
- Where the constraint has no methods, the generic cost nothing in any of three benchmarks — reading, building a slice, one comparison: the same bytes, the same allocations as hand-written code.
[]anyis where you actually pay: 1,001 allocations against one, and 24,384 bytes against 8,192.
Three layers that get stuck together
Almost the whole "are Go generics fast or slow" argument runs on three different questions being asked as one. They have to be pulled apart at the start, because the answers are not equally durable:
| layer | what belongs to it | how durable it is |
|---|---|---|
| the language | type parameter, constraint, type set, inference, generic method | a contract: written in the specification and binding on any implementation of Go |
| the compiler | gcshape, dictionary, how many code bodies get emitted | a decision of one implementation: the specification says nothing about it, and a new release does not break it — it changes it |
| a measurement | nanoseconds, bytes, allocations | an observation of one program on one machine. The most fragile of the three |
The article walks them in that order: first what a generic expresses, then what type set the constraint defines and what it permits the body, then whether a generic is wanted at all, then how go1.24.7 implemented it — and only at the end what it cost on our machine.
The order is not cosmetic. Read backwards — "we measured 4.18, so generics cost four times as much" — it is a claim about the language drawn from an observation of a machine, and it is wrong by exactly the distance between the layers. How large that distance is shows up at the end: the very same generic-to-interface ratio came out differently on two instances of one machine.
What a generic is made of
The place to start is not the compiler but what is actually written on the page. Five words that get used constantly below — and all five are visible in one line:
Two calls of one function — Max(10, 20) and Max(1.5, 2.5) — give int and
float64. No assertion, no any, the concrete type preserved on the way in and
on the way out. That is what type parameters were made for, and it is a claim
about the language: it holds on any implementation of Go.
Type parameters are not only for functions:
type Box[T any] struct {
Value T
}
b := Box[string]{Value: "s"}And here is where the question people usually start with arises — better second than first: how many machine versions of this code must the compiler emit? One per substituted type? One for all of them? The answer is not in the language but in the implementation, and it gets its own part of the article, below.
First, though, the other half of that line: cmp.Ordered.
A constraint defines a type set, and a type set defines the operations
The chain everything else follows from, mechanically:
- a constraint defines
- a type set — which
Tare allowed — and that defines - the operations the function body is permitted.
That last arrow is not a metaphor. The body may do with a value of type T
exactly what is permitted by every type in the set, and not one operation
more.
The four constraints that come up most are worth reading as language first, and as code shape second:
| constraint | which types are allowed | what the body gets |
|---|---|---|
any | any type argument | almost nothing: there are no operations common to every Go type |
comparable | types whose values can be compared with == and != | == and != |
interface{ Name() string } | types that have that method | a Name() call |
interface{ ~int | ~float64 } | only int, float64 and named types over them | arithmetic and comparison |
Note that comparable and the union are not "interfaces" in the familiar sense:
you cannot have a value of such a type. That distinction is worked out just
below, and it will later explain the most surprising observation in the article.
What a constraint permits the body, and what it forbids
These are two different tools, and confusing them is expensive. The rule by which the compiler decides what you are allowed to do:
The rule is that a generic function may use a value whose type is a type
parameter in any way that is permitted by every member of the type set of the
parameter's constraint.
The mechanical consequence shows up in two compile errors
(bench/gogenerics/constraints.go):
v.Name undefined (type T has no field or method Name)
invalid operation: operator + not defined on a (variable of type T constrained by Named)
A set constraint (~int | ~float64) gives you operators but no methods. A
behaviour constraint (interface { Name() string }) gives you the method but no
operators. And they cannot be combined by union — the specification forbids it:
A union (with more than one term) cannot contain the predeclared identifier
comparable or interfaces that specify methods, or embed comparable or
interfaces that specify methods.
They can be combined by intersection: interface { ~int; Name() string }
gives you both the operator and the method. Verified: it works.
A basic interface and a general one: why "interface" is two words here
This is also where the distinction that trips people up at the very start lives — the same one that will explain the pointer collapse below. The specification splits interfaces in two:
The compiler settles it immediately:
type Number interface{ ~int | ~float64 }
var x Number // does not compile
func Sum[T Number](a, b T) T // compilesThe compiler's message, verbatim (bench/gogenerics/constraints.go, case 4):
cannot use type Number outside a type constraint: interface contains type constraints
Two things follow at once. First: a constraint is not a value. When T is
constrained by Stringer, there is no interface variable inside the body; there
is a concrete type known to have a method. Second: it is membership of the basic
group that decides the shape, and the number of methods does not. But that is
already about the compiler, and it is checked below, by a table of six
constraints.
The tilde: why ~int64 rather than int64
~ has appeared above as part of the syntax, and it is time to say what it
means. A constraint made of one type denotes a set of one element:
The type set of a non-interface type term is the set consisting of just that
type.
The tilde widens it to every type with that underlying type:
The type set of a term of the form ~T is the set of all types whose underlying
type is T.
The practical difference is this one (bench/gogenerics/tilde.go):
type Exact interface { float64 }
type Under interface { ~float64 }
type Celsius float64
ScaleExact(Celsius(20)) // does not compile
ScaleUnder(Celsius(20)) // worksThe compiler even tells you what is missing:
Celsius does not satisfy Exact (possibly missing ~ for float64 in Exact)
Without the tilde, in other words, a constraint cuts off all of your domain
code: type UserID int64 fails to satisfy [T int64], type Celsius float64
fails [T float64], type Seconds int fails [T int]. You almost always want
the tilde.
The tilde itself has two limits, and the specification names both: in a term
~T, the underlying type of T must be T itself (so there is no ~MyInt for
type MyInt int), and it cannot be an interface. Both were checked, and both are
compile errors:
invalid use of ~ (underlying type of MyInt is int)
cannot satisfy Bad (empty type set)
invalid use of ~ (error is an interface)
The first case yields two lines: a constraint holding a forbidden term becomes an empty type set, and every attempt to satisfy it then fails as well.
And the same thing that held for inference above: the tilde widens the set but
does not erase the name. ScaleUnder(Celsius(20)) returns main.Celsius,
not float64.
What is inferred, and what you will have to write out
Inference was named in the article's first code block and left there. It is the reason generics read like ordinary functions. The specification describes it this way:
A use of a generic function may omit some or all type arguments if they can be
inferred from the context within which the function is used, including the
constraints of the function's type parameters.
The operative words are "from the context". The list of contexts in the specification is closed:
Type inference supports calls of generic functions and assignments of generic
functions to (explicitly function-typed) variables.
Assigning a result is not on that list — and that is the commonest case
where the compiler asks you to spell the parameter out. Verified on go1.24.7
(bench/gogenerics/inference.go); below are the compiler's refusals verbatim,
with only the file position ahead of each message stripped:
in call to Zero, cannot infer T (declared at ./main.go:3:11)
in call to Make, cannot infer T (declared at ./main.go:3:11)
cannot use generic type Box[T any] without instantiation
in call to Pair, mismatched types untyped int and untyped string (cannot infer T)
The rule behind all of it is simple: what gets inferred is what is visible in
the arguments. Zero[T]() T has no arguments, so there is nothing to infer
from. A generic type (as opposed to a function) has no inference at all:
Box[T] must be instantiated.
Untyped constants are their own case, and the specification sets the priority outright:
Type inference gives precedence to type information obtained from typed operands
before considering untyped constants.
The difference shows on two calls to one function,
Sum[T ~int | ~float64](a, b T) T. While both operands are untyped constants,
nothing stands in the way of widening: Sum(1, 2.5) yields float64. Let one
operand become typed (var i int, the call Sum(i, 2.5)) and T is bound to
int before the constant's turn comes:
cannot use 2.5 (untyped float constant) as int value in argument to Sum (truncated)
What fails here is not the inference but the conversion of the constant to the type already inferred.
And a pleasant detail visible only by running it: inference keeps the named
type. For type Point []int, the call Map(p, f) yields S = main.Point, not
[]int — the name is not lost along the way.
Is a generic wanted here at all
So far this has been about what a generic can do. The question people ask before any of that is whether to use one here. The language authors put their answer as a contrast with the interface:
Inversely, if the implementation is different for each type, then use an
interface type and write different method implementations, don't use a type
parameter.
Out of that, and out of the "constraint → type set → operations" chain, comes a short case analysis:
the same ALGORITHM for a family of types
(sorting, min/max, Map/Filter, summing)
→ generic
a CONTAINER that does not care what is inside
(stack, set, cache, queue)
→ generic
you must PRESERVE the relation between the input type and the output type
(Keys(map[K]V) []K; Map([]T, func(T) U) []U)
→ generic: the only one of the four that an interface
cannot express at all
each type BEHAVES differently and the code calls one method
(Read, String, Serve)
→ interface
The third line is the one most often skipped, and it is the real reason generics
exist. func Keys(m map[any]any) []any compiles and works; what it does not
have is a relation between what went in and what came out. That relation is
restored by an assertion at the call site — a run-time check in place of a
build-time one. A generic keeps it, and that is a property of the language,
independent of the compiler version and of the machine.
The fourth line is where a generic is not wanted, and the authors' advice is exactly about it. If the implementation differs per type, a constraint with methods gives nothing an interface does not: then an interface is the honest choice in meaning too.
In price, the conclusion has to be taken exactly as wide as it was measured and
no wider; the measurement itself is at the end of the article. What was measured
is this: on one workload — a thousand pointer-shaped objects, the Namer
constraint, a Name() call — the generic did not turn into a direct call. What
was not measured, and so is not claimed, is that a generic "costs what an
interface costs": in the run that sits in bench/gogenerics/runs/cost.txt it
came out markedly cheaper than the interface, while an earlier run of this same
article had them level. One thing follows from the benchmark: swapping an
interface for a generic gives you neither a direct call, nor inlining, nor
devirtualization, and no API choice should count on them.
A type set without methods is what generics were made for, and across three benchmarks it cost nothing.
What changed in the language itself
Here runs the boundary that articles about generics most often smudge, and it must not be smudged: the state of the language and the toolchain of the measurement are not the same thing.
- The language is Go 1.27. The quotations below are verbatim, but not one feature here is asserted on the document's word: each is checked by the compiler.
- Every table in the article is go1.24.7. The shape experiment and every measurement were taken on it. What of that changes on 1.27 is worked out at the end of this section; the short answer is nothing.
| version | what changed | checked here |
|---|---|---|
| 1.18 | type parameters, generic functions and types, gcshape + dictionaries | yes, the whole article |
| 1.21 | substantially extended type inference | partly: the inference section |
| 1.24 | generic type aliases | yes, by the compiler |
| 1.26 | a constraint may refer to the type being constrained | yes, by the compiler |
| 1.27 | generic methods; type inference in all assignment contexts | yes, by the compiler |
"Checked" in that column means literally checked: versions.go builds each case
as a temporary module with the same toolchain it is run by, and prints the
compiler's answer. Here is that one file run twice — once on go1.24.7, once on
go1.27.0 (the runs themselves are in runs/versions-go124.txt and
runs/versions-go127.txt):
| case | go in the probe's go.mod | go1.24.7 | go1.27.0 |
|---|---|---|---|
| generic type alias | 1.24 | builds | builds |
| a constraint referring to the type being constrained | 1.26 | refused | builds |
| a method with its own type parameters | 1.27 | refused | builds |
| an INTERFACE method with its own type parameters | 1.27 | refused | refused |
On go1.24.7 the refusal is the toolchain declining the module's language version outright:
go: go.mod requires go >= 1.26 (running go 1.24.7; GOTOOLCHAIN=local)
On go1.27.0 the last row is refused by the type checker itself:
./main.go:4:7: interface method must have no type parameters
Note case 4: it is built by the same toolchain as case 3 and is refused all the same. More on that boundary below.
An alias with parameters is something that did not exist before 1.24 at all:
type Pair[A, B any] = struct {
First A
Second B
}
p := Pair[int, string]{First: 1, Second: "s"} // builds on go1.24.7In 1.26 the restriction that kept a constraint from closing on itself was lifted:
The restriction that a generic type may not refer to itself in its type
parameter list has been lifted. It is now possible to specify type constraints
that refer to the generic type being constrained.
The example from the same notes — a constraint for "a type that can add itself to itself":
type Adder[A Adder[A]] interface {
Add(A) A
}
func algo[A Adder[A]](x, y A) A {
return x.Add(y)
}And 1.27 brought what Go generics had lacked from the start — a method's own type parameters:
Go 1.27 now supports generic methods: a method declaration may declare its own
type parameters.
The second half of the same paragraph matters just as much — the boundary 1.27 did not move:
Note that methods of interfaces may not declare type parameters nor can
interface methods be implemented by generic methods.
So 1.27's effect on the generic-or-interface choice that the second half of this article is about is not what it looks like at first glance: it widens what can be expressed on a concrete type and changes exactly nothing about interfaces. That is not a footnote but case 4 of the run above: an interface method with its own type parameter is refused by the 1.27 compiler, and the refusal reads
./main.go:4:7: interface method must have no type parameters
So the opposition the call-cost benchmark was made for is still the same one.
The same notes extend type inference, which has its own section above:
Function type inference has been generalized to apply in all contexts where a
generic function is assigned to a variable of (or converted to) a matching
function type.
The inviting conclusion here is that the inference section is out of date — and
it is wrong. What 1.27 widened is the assignment of a generic FUNCTION to a
variable of function type, a case that already worked on 1.24.7. None of the
five refusals in that section is about it: running inference.go on 1.27
reproduces every one of them verbatim, including the one where a type is not
inferred from the assignment target of a call's result.
What of the go1.24.7 findings changed on 1.27
Nothing. That is worth showing piece by piece, because "nothing" is a result too, and it is checked differently for each layer.
The shape experiment matched byte for byte. shapes.go on 1.27 produces the
same symbol table: eleven types under any give six bodies, comparable gives
eight, three pointers under Stringer give one, the union gives three. The two
run files sit side by side (runs/shapes.txt and runs/shapes-go127.txt) and
differ in one line, the version. So the shape grouping the specification does
not promise survived three releases in a row.
The compiler refusals in the constraints section matched byte for byte too.
constraints.go on 1.27 prints the same messages word for word.
The timings overlap in every row. Here a caveat matters more than the
numbers themselves: only two runs taken on the SAME machine may be compared. The
article's main tables come from one machine instance, and the "1.24.7 against
1.27" pair from another, so the pair's numbers are not comparable with the
article's tables at all. Within the pair (runs/toolchain-go124.txt and
runs/toolchain-go127.txt) everything is comparable, and the result is this: in
every row of every block the two toolchains' ranges overlap. On these workloads
1.27 changed neither time, nor bytes, nor allocations.
And here is what that pair said beyond the question asked. On that machine block 6 came out ×1.00: with inlining forbidden, the generic cost exactly what the direct call cost — on both toolchains. So there, reading the address out of the dictionary cost nothing measurable, and the generic's whole gap in block 1 turned out to be lost inlining, down to the last nanosecond. On the machine of the main tables, 1.39 remained. The two observations add up like this: inlining accounts for anywhere between four fifths and all of the difference, indirection for between a fifth and nothing — and which of the two cases is yours is decided by the machine, not by the version of Go.
Three ways to do it, and why the third was chosen
From here to the end of the article is the second layer: not the language but the decisions of one implementation. Nothing said below about shapes, dictionaries and the number of code bodies is promised by the specification, and in the next release of Go it could differ without the language changing at all.
The question left open at the start was this: how many machine versions of the
body must the compiler emit from func Min[T Ordered](a, b T) T? There are
three ways, and each has its trouble.
Specialise per type (templates, as in C++). The code comes out ideal:
Min[int] is two instructions. The trouble is volume: as many bodies as there
are distinct substitutions, and in a large program that shows up both in file
size and in build time.
Do not specialise at all — one body working through pointers and type descriptors. One body, but every operation goes through the runtime.
The middle option, which is what was chosen: specialise not per type but per shape. A shape is what a type looks like to the garbage collector:
The GC shape of a type means how that type appears to the allocator / garbage
collector. It is determined by its size, its required alignment, and which
parts of the type contain a pointer.
There are many types and few shapes. Everything the body lacks in order to work with a concrete type is passed separately:
The implementation of f will have an additional argument which is the pointer
to the dictionary structure.
What follows is what this produces in practice — and all of it can be counted.
How many bodies there actually are
These numbers are not derived from documentation; they are read off a finished
binary. The program bench/gogenerics/shapes.go builds a probe package and
reads its symbol table through go tool nm. The instantiation bodies are right
there:
main.AnyBox[go.shape.*uint8]
main.AnyBox[go.shape.float64]
main.AnyBox[go.shape.int]
main.AnyBox[go.shape.int64]
main.AnyBox[go.shape.string]
main.AnyBox[go.shape.[2]int]
Six lines. And eleven dictionaries beside them, one per concrete type:
main..dict.AnyBox[*main.A]
main..dict.AnyBox[*main.B]
main..dict.AnyBox[*main.C]
main..dict.AnyBox[float64]
main..dict.AnyBox[main.Celsius]
...
Three pointers in one body is neither a coincidence nor a just-in-case optimisation. In the compiler it is one function and one rule:
When a pointer type is used to instantiate a type parameter constrained by a
basic interface, we know the pointer's element type can't matter to the
generated code. In this case, we can use an arbitrary pointer type as the
shape type. (To match the non-unified frontend, we use *byte.)
Otherwise, we simply use the type's underlying type as its shape.
byte is an alias for uint8, which is why the symbol table calls the shape
*uint8. Everything on the figure follows from those three sentences — and
they already contain the word that matters: basic interface.
Pointers collapse if the constraint is a basic interface. "Basic" here is
not an adjective but a term from the specification: a basic interface is one
whose type set is defined by methods only. any is basic (zero methods),
Stringer is basic (one), an interface of two methods is basic too.
comparable is not: it constrains the type itself. So under comparable the
same eleven types give not six bodies but eight: the pointer group breaks apart
and nothing else changes.
Three constraints are three points, and it is easy to draw the wrong line through three points: "it is about having methods" explains them equally well. Only a constraint that does have methods while not being basic can tell the two formulations apart. Which is why the probe program carries six:
| constraint | basic? | types | code bodies |
|---|---|---|---|
any | yes, zero methods | 11 | 6 |
Stringer | yes, one method | 3 | 1 |
TwoMethods | yes, two methods | 3 | 1 |
comparable | no | 11 | 8 |
interface{ *A | *B | *C } | no: a union of types | 3 | 3 |
interface{ comparable; String() string } | no, though it has a method | 3 | 3 |
The last row is the answer: the constraint has a method and there is no
collapse. So the rule is not about methods being present but about the
constraint being defined by them alone — exactly what shapify says. The
numbers come from a run of bench/gogenerics/shapes.go, which does not merely
print the table: it fails if the rule and the symbol table disagree on even one
type.
A named type takes the shape of its underlying type. type Celsius float64
adds no body: its shape is float64. Which means in practice that you can
declare as many named types over one underlying type as you like without
affecting code size.
Scalars with different underlying types do not merge. int64 and
float64 are eight bytes each, identically aligned, with no pointers inside.
To the garbage collector that is one shape. To the compiler it is two.
It is easy to take this for an omission, and that would be a mistake: they are kept apart deliberately. The document describing what was actually implemented says so outright:
fundamentally different built-in types such as int and float64 are never in
the same gcshape
The reason is the dictionary: int and float64 have different operations, and
the dictionary would have to carry different implementations of addition. Even
int16 and int32 are separated — their shifts differ.
Where the implementation really does diverge from the document is pointers, and in the opposite direction from what you might guess. There the rule is stated with no conditions at all:
Two concrete types are in the same gcshape grouping if and only if they have
the same underlying type or they are both pointer types.
"Or they are both pointer types" — any two pointers, unconditionally. The
compiler, though, merges them only under a basic interface: under
comparable each has its own shape. That is exactly what the figure shows when
you switch the constraint — and the same thing stands as the first item in the
shapify authors' note about what they have not done yet:
collapsing all pointer-shaped types into a common type
So here the implementation is not coarser than the document but narrower.
What this means for a method call
Here intuition fails systematically. "I'll replace the interface with a generic and get a direct call" is a change made for speed that does not deliver speed.
The logic is simple and already on the previous figure. Since all pointers in
Named[T Stringer] share one body, that body does not know whose method to
call. So the address of (*user).Name cannot be placed into the code at build
time. It lives in the dictionary, and the call goes through it — indirectly.
Measured over a thousand calls, seven interleaved rounds; the column holds the range across rounds:
| how the method is reached | ns/op | B/op | allocs/op | vs direct |
|---|---|---|---|---|
directly, []*user | 321–328 | 0 | 0 | ×1.00 |
generic, [T Namer] | 1,342–1,462 | 0 | 0 | ×4.18 |
interface, []Namer | 2,185–2,243 | 0 | 0 | ×6.81 |
The generic does not deliver the speed of a direct call — that is the headline,
and it does not move from machine to machine. What comes next is where it is
easy to say more than you measured, and this article once did. An earlier
version published 1,805 against 1,844 here and concluded "a generic delivers
interface speed, the difference is 2 %". On the machine instance the run above
comes from, that two-percent proximity is not there at all: the generic came out
half again cheaper than the interface. Same source, same go1.24.7, same CPU
model in cpu: — and everything else in the article got 10–40 % faster while
the interface call alone got slower. Why, this benchmark does not establish.
What it does establish is worth carrying away: the cost of an indirect call is
the least portable number in this article, and the generic-to-interface ratio
is no basis for a decision in code.
The fourfold gap to the direct call, on the other hand, can be broken into parts — and now it is. A supposition used to stand here: "probably it is not only indirection — a direct call also gets inlined." Testing a supposition costs one directive.
How much of the fourfold gap is lost inlining
//go:noinline on the method takes exactly one advantage away from the direct
path: now it too makes a real call. Everything else — the type, the traversal,
the amount of work — is unchanged:
| how the method is reached | ns/op | B/op | allocs/op | vs direct |
|---|---|---|---|---|
directly, //go:noinline | 1,143–1,198 | 0 | 0 | ×1.00 |
generic, [T Namer] | 1,585–1,629 | 0 | 0 | ×1.39 |
interface, []Namer | 2,198–2,253 | 0 | 0 | ×1.92 |
Read it like this. Stripped of inlining, the direct call went from 321 to 1,143 — inlining was worth roughly 822 ns per thousand calls. The generic's whole gap to the direct call in block 1 is 1,342 − 321 ≈ 1,021 ns. So about four fifths of the gap is inlining and only the remaining fifth is indirection itself: once the direct call is really made, the generic trails it by 1.39, not by four.
The control for this block is the interface row. An interface call was never inlined, the ban takes nothing away from it, and its number has to match block
- It does — 2,185–2,243 against 2,198–2,253.
And how much of the interface's gap is not dispatch at all
Blocks 1 and 6 share one silent assumption: that everything except the calling
route is equal across the three rows. It is not. []*user is a thousand machine
words; []Namer is a thousand pairs of "type descriptor, pointer" — twice the
data for the same traversal. Separating that out means not calling the method at
all: both rows read the field directly, the interface side through a type
assertion.
| how the field is read | ns/op | B/op | allocs/op | vs the first row |
|---|---|---|---|---|
through []*user | 317–348 | 0 | 0 | ×1.00 |
through []Namer | 539–564 | 0 | 0 | ×1.70 |
The data layout alone costs about 222 ns. The interface trails an honest direct call by 2,198 − 1,143 ≈ 1,055 ns, and a fifth of that is not dispatch but the extra eight kilobytes the processor drags through cache.
The result of the three blocks is not one number but an order of terms: inlining, dispatch, data layout, in that order of size. And the design document names the first two prices in advance, among the drawbacks of the chosen approach: more conservative escape analysis, and lost inlining wherever a method call is not resolved at compile time.
Where a generic costs nothing
The previous section invites the conclusion "generics are slow". It is wrong, and here is why.
Switch the tabs. The first is the one above: a fourfold gap. And "reading a slice", "building a slice" and "one comparison" show no difference where none can be shown (ranges across seven rounds):
| what we do | by hand | generic |
|---|---|---|
read a thousand int64 | 307–312 ns | 308–319 ns |
| build a slice of a thousand | 3,303–3,598 ns, 8,192 B, 1 alloc | 3,290–3,557 ns, 8,192 B, 1 alloc |
| one comparison | 0.4426–0.4694 ns | 0.4523–0.4941 ns |
Bytes and allocations match to the unit, and the time ranges overlap — which means there is no difference. What separates these cases is not the size of the data or the complexity of the code. It is one thing: whether the constraint has methods.
[T ~int64],[T ordered]— no methods. The body is compiled for theint64shape, and nothing of the generic survives into machine code.[T Namer]— methods. One body for all pointers, the address from a dictionary.
That is the rule worth taking away. Not "generics are fast" and not "generics are slow", but: look at the constraint.
The price people actually pay
It is not in generics. It is in what generics replace:
| what we build | ns | bytes | allocations |
|---|---|---|---|
[]int64 | 3,303–3,598 | 8,192 | 1 |
generic []T | 3,290–3,557 | 8,192 | 1 |
[]any | 16,114–17,002 | 24,384 | 1,001 |
In this benchmark (go1.24.7, linux/amd64) an interface value takes two machine
words — a type descriptor and a pointer — and the stored int64s additionally
escape to the heap, one box per element. The arithmetic works out: 16,384 for
the slice itself plus 8,000 for a thousand boxes make 24,384 bytes and 1,001
allocations. This is what a generic actually saves you from, and it saves in
multiples, not percentages.
Both figures are an observation about this build and this workload, not a
contract of any. The width of an interface value depends on the architecture,
and whether a value moves to the heap is decided not by the boxing itself but by
the escape analysis of the particular code and the particular compiler
version. Here the values are stored in a slice and outlive the call — so they
escape. In the one-comparison block the same conversion to any produces no
allocation at all: the value never leaves the function, and the any variant
costs 0.9188–0.982 ns against 0.4426–0.4694 — twice as much, but without a
single allocation. So "any
always allocates" is untrue, and "it allocates when the value outlives the call"
is too short as well: outliving is the commonest reason to escape, not the only
one.
Sorting: a case where the argument is already over
| what sorts | ns | bytes | allocations |
|---|---|---|---|
slices.Sort | 12,804–13,565 | 0 | 0 |
sort.Ints | 12,814–13,259 | 0 | 0 |
sort.Sort(sort.IntSlice(x)) | 62,112–63,593 | 24 | 1 |
sort.Slice(x, func(i, j int) bool) | 50,244–55,476 | 56 | 2 |
The second row looks like a comparison of a generic against a specialised
function and is not one. Since Go 1.22 sort.Ints is literally one line:
// Note: as of Go 1.22, this function simply calls [slices.Sort].
func Ints(x []int) { slices.Sort(x) }The agreement between the first two rows checks the repeatability of the measurement, not the superiority of the generic. But the mere fact that the specialised version was replaced by a call to the generic one says more than any benchmark.
The real comparison is with the bottom two rows, and there slices.Sort beats
the old interface route by 4.8× on this workload, and the closure route by
3.9×. No "generics are N× faster than interfaces" coefficient comes out of those
numbers, and here is why: what is being compared is not two language constructs
but two whole APIs. slices.Sort has an arithmetic constraint (cmp.Ordered)
with no methods in it, and the element type is concrete — so the comparison
compiles to an instruction. The old route pays an indirect call per comparison,
and sort.Sort pays three (Len, Less, Swap). On top of that these are two
implementations of the algorithm written a decade apart. The 4.8× is the sum of
all of it at once, and this block does not try to break it down: what a real
breakdown looks like is above, in blocks 1, 6 and 7.
And a side result worth looking at across those bottom two rows: sort.Sort
with IntSlice came out slower than sort.Slice with a closure —
62,112–63,593 against 50,244–55,476, ranges not overlapping. The "idiomatic"
pre-generics way was also the slowest: three indirect calls per comparison
(Len, Less, Swap) instead of one closure.
What should reproduce, and what should not
The numbers were taken on go1.24.7 linux/amd64, Intel Xeon 2.10GHz,
GOMAXPROCS = 2. The run every table above comes from sits whole in
bench/gogenerics/runs/cost.txt; bench/gogenerics/cost.sh assembles it from
cost_test.go (blocks 1–5), noinline_test.go (block 6) and layout_test.go
(block 7). The symbol tables come from bench/gogenerics/shapes.go.
The sections on type inference, the tilde, constraints and language versions
contain no timing at all: their scripts compile code and print the compiler's
answer — bench/gogenerics/inference.go, bench/gogenerics/tilde.go,
bench/gogenerics/constraints.go, bench/gogenerics/versions.go. Those four
reproduce word for word on the same Go version; the file positions will be your
own, the text of the messages the same, because it is part of the compiler's
behaviour rather than a measurement.
The re-check on go1.27.0 sits alongside, in its own files:
runs/shapes-go127.txt, runs/versions-go127.txt and the pair
runs/toolchain-go124.txt — runs/toolchain-go127.txt. The pair was taken on a
different machine from the article's tables, and so is compared only against
itself: that is the very rule it was taken as a pair for.
Do not expect your numbers to match these — expect the ratios within a block to match. And not even all of the ratios: as the method-call section shows, the generic-to-interface ratio did not survive a change of machine instance, even though both sides stayed indirect. What to expect is that an indirect call costs more than a direct one, and that inlining weighs more in that difference than dispatch does.
The article's main claim, about the number of code bodies, is not measured with a stopwatch: it comes from the symbol table. But it is not a language guarantee either — it reproduces for the same source, the same compiler version and the same target configuration; on a different GOOS/GOARCH pair or a different build configuration it has to be checked again by the same run.
Across versions it need not be — and "need not" is no excuse here: between
1.24.7 and 1.27.0 it matched byte for byte, but that is checked, not promised.
Shape grouping is not promised by the specification, and the article itself shows where the current compiler departs
from its own design document; the first item in shapify's TODO is collapsing
all pointer-shaped types into one. Do that, and the number of bodies under
comparable changes without anything in the language changing. So the number of
code bodies is not a constant but the result of a measurement, and on a new
version it is re-checked with the same run of bench/gogenerics/shapes.go.
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- Three layers, and the article walks them in this order: language (what a generic expresses), compiler (how go1.24.7 implemented it), measurement (what it cost on one machine). Running them together is the central mistake in arguments about generics.
- Language. A constraint defines a type set, a type set defines the operations the body is permitted. A generic is wanted where one algorithm serves a family of types, where a container does not care what is inside, and — above all — where the relation between the input type and the output type must be preserved: no interface can express that. Where each type behaves differently and the code calls one method, an interface is the honest choice. The state of the language today is Go 1.27 with generic methods — checked by the compiler, not read off the release notes. Every table below was taken on go1.24.7; what of it changes on 1.27 is checked too, and the answer is nothing.
- Compiler. Go monomorphises not per type but per GC shape: one code body per shape, and a shape often holds several types. A dictionary, passed as a hidden argument, is what lets the shared body tell them apart.
- Eleven types, six bodies. Taken from the symbol table: three different pointers give one body, and so do
Celsius,Metersandfloat64. int64andfloat64do not merge, though both are eight bytes — and that is by design. Pointers, on the other hand, the implementation document merges unconditionally while the compiler merges them only under a basic interface — a constraint defined by methods alone: there the implementation is narrower than the document.- On go1.24.7 one property of the constraint decided whether pointers collapse: is it a basic interface, that is, is its type set defined by methods only.
any,Stringerand an interface of two methods are basic, and pointers collapse.comparable, a union*A | *B | *Cand "Stringerpluscomparable" are not, and then each pointer gets its own body: the same eleven types undercomparablegive eight. Checked on six constraints rather than three: the last of them does contain a method, and still does not collapse. The specification defines neither shapes nor dictionaries nor a count of bodies — this is an observation about the compiler. - Measurement. go1.24.7 linux/amd64, seven interleaved rounds, the whole run in
bench/gogenerics/runs/cost.txt. - With a method constraint the generic did not remove the indirect call. 1,342 ns against 321 for a direct one — ×4.18; an interface is 2,185, that is ×6.81. What follows is neither "a generic equals an interface" nor "a generic beats an interface", but "swapping an interface for a generic does not by itself give you a direct call".
- The fourfold gap is broken into its parts rather than left a guess. Forbid inlining on the direct path and it rises from 321 to 1,143: about four fifths of the gap is lost inlining, not indirection. Against an honest direct call the generic is only ×1.39 behind. The interface is ×1.92 behind, and a fifth of that is not dispatch at all but twice as much data: 8 KiB of pointers against 16 KiB of interface values. On another machine instance indirection's share came out at zero and inlining's at a hundred per cent: the order of the terms carries over, the shares do not.
- 1.27 changed nothing on these workloads. The symbol table matched byte for byte, the compiler refusals word for word, and in a pair of runs on one machine the two toolchains' ranges overlapped in every row of every block.
- Where the constraint has no methods, the generic cost nothing in any of three benchmarks — reading, building a slice, one comparison: the same bytes, the same allocations as hand-written code.
[]anyis where you actually pay: 1,001 allocations against one, and 24,384 bytes against 8,192.
In fact
- It does not. Measured from the symbol table of a finished binary: eleven different types substituted into one function gave six code bodies, and three different pointers gave one. The compiler specialises not per type but per GC shape: The GC shape of a type means how that type appears to the allocator / garbage collector. It is determined by its size, its required alignment, and which parts of the type contain a pointer. Full specialisation (“stenciling”) was one of the options considered; the middle one was chosen.
- The specification does not say a word about it. Neither "GC shapes", nor dictionaries, nor a count of emitted bodies is defined by the language at all — it is one implementation's decision, and the article shows where the current compiler departs even from its own design document: that document merges any two pointers unconditionally, the compiler only under a methods-only constraint. Something like that has to be checked by running it, not by reading: between go1.24.7 and go1.27.0 the symbol table matched byte for byte, but that is checked, not promised. The first item in
shapify's TODO is collapsing all pointer-shaped types into one; do that and the number of bodies undercomparablechanges without anything in the language changing. - Re-checked, and they are not. The shape experiment on 1.27 gave the same symbol table byte for byte; the compiler refusals in the constraints section, the same word for word; the five refusals in the inference section too — including the one that 1.27's widened inference might seem to overturn (it is about assigning a generic function to a variable of function type, which already worked on 1.24.7). The timings were repeated as a pair of runs on one machine: the two toolchains' ranges overlapped in every row of every block. And generic methods do not by themselves move the generic-or-interface opposition: methods of INTERFACES still have no type parameters in 1.27 — the compiler answers
interface method must have no type parameters. - Exactly the opposite, if the constraint has methods. Since all pointers share one body, it does not know whose method to call: the address comes from a dictionary passed as a hidden argument, and the call stays indirect. Measured over a thousand calls: direct 321 ns, generic 1,342, interface 2,185. The design document names this price in advance, among the drawbacks of the approach: more conservative escape analysis, and lost inlining wherever a method call is not resolved at compile time. And most of the generic's fourfold gap is that inlining rather than the indirection: forbid inlining on the direct path and it rises from 321 to 1,143, leaving the generic 1.39× behind instead of 4.18×.
- We do not, and this is the least portable result in the article. In the run currently in the repository the generic came out at 1,342 ns against 2,185 for the interface — half again cheaper. In an earlier run of the same article, the same source and the same go1.24.7, they were level: 1,805 against 1,844. The two routes differ in WHERE the method's address is read from: the generic reads it from the call's dictionary, the interface from the value's own method table; on top of that the interface slice is twice as wide — 16 KiB of pairs against 8 KiB of pointers — and that alone costs about 222 ns. One thing carries over: both calls stay indirect. The ratio between them does not.
- Not always, and the gap is enormous. If the constraint has no methods (
~int64,cmp.Ordered), the body is compiled for a concrete shape and nothing of the generic survives into machine code. Measured: reading a thousand int64 — 307 ns by hand against 308 with a generic; building a slice — 3,303 ns / 8,192 B / 1 allocation against 3,290 / 8,192 / 1; one comparison — 0.4426 against 0.4523 ns. Bytes and allocations match exactly. What separates the cases is not the size of the data or the complexity of the code but one thing: whether the constraint has methods. In fairness: three cases were measured, on one element type and one length — enough to refute “always”, not enough to assert “never”. - For the programmer, yes; and on go1.24.7 it also bore on how many code bodies the compiler emitted — in the cases checked, on one criterion: is the constraint a METHOD SET. The specification defines neither shapes nor dictionaries nor a count of bodies at all, so this is an observation about the compiler, not a rule of the language.
anyandStringerare, so pointers collapse into one shape.comparableis not, so every pointer gets its own body: the same eleven types give eight bodies instead of six. Beyond that,comparablechanges nothing — the whole difference is the pointer group breaking apart. - It does not: a named type's shape is its underlying type's shape.
type Celsius float64andtype Meters float64land in the same body asfloat64— measured, all three on one row of the symbol table. In this experiment named types with the same underlying type did not produce separate shape bodies for the function under study; carrying that over to an arbitrary function and an arbitrary compiler version without checking is not allowed. What does NOT merge isint64andfloat64, though both are eight bytes. And that is no omission: the implementation document keeps them apart deliberately — fundamentally different built-in types such as int and float64 are never in the same gcshape, because their operations differ and the dictionary would have to carry different implementations of addition. What does diverge from the document is something else: there, any two pointers are declared one shape unconditionally, while the compiler merges them only under a method-set constraint. - It is in what generics replace. Building a slice of a thousand values:
[]int64— 8,192 bytes and one allocation; generic[]T— exactly the same;[]any— 24,384 bytes and 1,001 allocations. A slice of “type descriptor, pointer to value” pairs, sixteen bytes per element, plus a separate heap box per value: 16,384 plus 8,000 makes 24,384. A generic saves you from that in multiples, not percentages. - The conversion to
anydoes not itself require the heap. Where the value lands is decided by the escape analysis of the particular code and the particular compiler version: in the one-comparison benchmark theanyvariant costs 0.9188 ns against 0.4426 for hand-written code — more, but with not a single allocation. The thousand allocations show up in a different benchmark, where the values are collected into a slice and live on. "Outlives the call" is the commonest reason to escape, but the rule is the analysis, not that phrase. - They give the same numbers because they are the same function. Since Go 1.22 the standard library holds
func Ints(x []int) { slices.Sort(x) }with the authors' note Note: as of Go 1.22, this function simply calls [slices.Sort]. The agreement between their numbers checks the repeatability of the measurement. The real comparison is with the old interface route:sort.Sort(sort.IntSlice(x))— 62,112 ns against 12,804, so 4.8× on this workload. That is not a "generics versus interfaces" coefficient: two whole APIs from different eras are being compared, and the difference includes dispatch, inlining, comparator representation and the algorithm itself.
What is covered
- Three layers that get stuck together
- What a generic is made of
- What a constraint permits the body, and what it forbids
- The tilde: why `~int64` rather than `int64`
- What is inferred, and what you will have to write out
- Is a generic wanted here at all
- What changed in the language itself
- Three ways to do it, and why the third was chosen
- How many bodies there actually are
- What this means for a method call
- Where a generic costs nothing
- The price people actually pay
- Sorting: a case where the argument is already over
- What should reproduce, and what should not
Common misconceptions
Generics in Go are templates: the compiler emits a version per type
It does not. Measured from the symbol table of a finished binary: eleven different types substituted into one function gave six code bodies, and three different pointers gave one. The compiler specialises not per type but per GC shape: The GC shape of a type means how that type appears to the allocator / garbage collector. It is determined by its size, its required alignment, and which parts of the type contain a pointer
. Full specialisation (“stenciling”) was one of the options considered; the middle one was chosen.
The number of code bodies is a property of the language: Go does it, so the specification says so
The specification does not say a word about it. Neither "GC shapes", nor dictionaries, nor a count of emitted bodies is defined by the language at all — it is one implementation's decision, and the article shows where the current compiler departs even from its own design document: that document merges any two pointers unconditionally, the compiler only under a methods-only constraint. Something like that has to be checked by running it, not by reading: between go1.24.7 and go1.27.0 the symbol table matched byte for byte, but that is checked, not promised. The first item in shapify's TODO is collapsing all pointer-shaped types into one; do that and the number of bodies under comparable changes without anything in the language changing.
After Go 1.27 and its generic methods, measurements on 1.24.7 are out of date
Re-checked, and they are not. The shape experiment on 1.27 gave the same symbol table byte for byte; the compiler refusals in the constraints section, the same word for word; the five refusals in the inference section too — including the one that 1.27's widened inference might seem to overturn (it is about assigning a generic function to a variable of function type, which already worked on 1.24.7). The timings were repeated as a pair of runs on one machine: the two toolchains' ranges overlapped in every row of every block. And generic methods do not by themselves move the generic-or-interface opposition: methods of INTERFACES still have no type parameters in 1.27 — the compiler answers interface method must have no type parameters.
A generic is a way to remove an interface and get a direct call
Exactly the opposite, if the constraint has methods. Since all pointers share one body, it does not know whose method to call: the address comes from a dictionary passed as a hidden argument, and the call stays indirect. Measured over a thousand calls: direct 321 ns, generic 1,342, interface 2,185. The design document names this price in advance, among the drawbacks of the approach: more conservative escape analysis, and lost inlining wherever a method call is not resolved at compile time. And most of the generic's fourfold gap is that inlining rather than the indirection: forbid inlining on the direct path and it rises from 321 to 1,143, leaving the generic 1.39× behind instead of 4.18×.
At least we know how much faster a generic is than an interface
We do not, and this is the least portable result in the article. In the run currently in the repository the generic came out at 1,342 ns against 2,185 for the interface — half again cheaper. In an earlier run of the same article, the same source and the same go1.24.7, they were level: 1,805 against 1,844. The two routes differ in WHERE the method's address is read from: the generic reads it from the call's dictionary, the interface from the value's own method table; on top of that the interface slice is twice as wide — 16 KiB of pairs against 8 KiB of pointers — and that alone costs about 222 ns. One thing carries over: both calls stay indirect. The ratio between them does not.
A generic always costs something
Not always, and the gap is enormous. If the constraint has no methods (~int64, cmp.Ordered), the body is compiled for a concrete shape and nothing of the generic survives into machine code. Measured: reading a thousand int64 — 307 ns by hand against 308 with a generic; building a slice — 3,303 ns / 8,192 B / 1 allocation against 3,290 / 8,192 / 1; one comparison — 0.4426 against 0.4523 ns. Bytes and allocations match exactly. What separates the cases is not the size of the data or the complexity of the code but one thing: whether the constraint has methods. In fairness: three cases were measured, on one element type and one length — enough to refute “always”, not enough to assert “never”.
A constraint is about which types are allowed
For the programmer, yes; and on go1.24.7 it also bore on how many code bodies the compiler emitted — in the cases checked, on one criterion: is the constraint a METHOD SET. The specification defines neither shapes nor dictionaries nor a count of bodies at all, so this is an observation about the compiler, not a rule of the language. any and Stringer are, so pointers collapse into one shape. comparable is not, so every pointer gets its own body: the same eleven types give eight bodies instead of six. Beyond that, comparable changes nothing — the whole difference is the pointer group breaking apart.
A named type over an underlying type gives the compiler more work
It does not: a named type's shape is its underlying type's shape. type Celsius float64 and type Meters float64 land in the same body as float64 — measured, all three on one row of the symbol table. In this experiment named types with the same underlying type did not produce separate shape bodies for the function under study; carrying that over to an arbitrary function and an arbitrary compiler version without checking is not allowed. What does NOT merge is int64 and float64, though both are eight bytes. And that is no omission: the implementation document keeps them apart deliberately — fundamentally different built-in types such as int and float64 are never in the same gcshape
, because their operations differ and the dictionary would have to carry different implementations of addition. What does diverge from the document is something else: there, any two pointers are declared one shape unconditionally, while the compiler merges them only under a method-set constraint.
The real price of generic code is in the generics
It is in what generics replace. Building a slice of a thousand values: []int64 — 8,192 bytes and one allocation; generic []T — exactly the same; []any — 24,384 bytes and 1,001 allocations. A slice of “type descriptor, pointer to value” pairs, sixteen bytes per element, plus a separate heap box per value: 16,384 plus 8,000 makes 24,384. A generic saves you from that in multiples, not percentages.
Boxing into any always allocates
The conversion to any does not itself require the heap. Where the value lands is decided by the escape analysis of the particular code and the particular compiler version: in the one-comparison benchmark the any variant costs 0.9188 ns against 0.4426 for hand-written code — more, but with not a single allocation. The thousand allocations show up in a different benchmark, where the values are collected into a slice and live on. "Outlives the call" is the commonest reason to escape, but the rule is the analysis, not that phrase.
slices.Sort is faster than sort.Ints because it is generic
They give the same numbers because they are the same function. Since Go 1.22 the standard library holds func Ints(x []int) { slices.Sort(x) } with the authors' note Note: as of Go 1.22, this function simply calls [slices.Sort]
. The agreement between their numbers checks the repeatability of the measurement. The real comparison is with the old interface route: sort.Sort(sort.IntSlice(x)) — 62,112 ns against 12,804, so 4.8× on this workload. That is not a "generics versus interfaces" coefficient: two whole APIs from different eras are being compared, and the difference includes dispatch, inlining, comparator representation and the algorithm itself.
Check yourself
One generic function is called with eleven different types: three pointers, int, int64, float64, two named types over float64, one over int, string and [2]int. How many code bodies end up in the binary?
Sources & further reading
12 SOURCES
- Generics implementation — GC Shape Stenciling (design document)Official documentation. The document where the implementation strategy was chosen. The definition of a shape: «The GC shape of a type means how that type appears to the allocator / garbage collector. It is determined by its size, its required alignment, and which parts of the type contain a pointer». On the hidden argument, plainly: «The implementation of f will have an additional argument which is the pointer to the dictionary structure». The same document names the price of the approach up front: it may come out slower than full stenciling because of more conservative escape analysis and because method calls are not resolved at compile time and therefore are not inlined.https://go.googlesource.com/proposal/+/refs/heads/master/design/generics-implementation-gcshape.md
- Generics implementation — Dictionaries (design document)Official documentation. What the dictionary holds: type descriptors for the instantiated types, descriptors for derived types, subdictionaries for calls to other generic functions, helper methods for operations on the generic types, stack frame layout and pointer maps. On methods: «the dictionary should contain methods that operate on the generic types». This is where the indirectness measured in this article comes from.https://go.googlesource.com/proposal/+/refs/heads/master/design/generics-implementation-dictionaries.md
- cmd/compile/internal/noder/reader.go — the shapify functionGo source code. The place where a type becomes a shape, and the only rule needed for it: «When a pointer type is used to instantiate a type parameter constrained by a basic interface, we know the pointer's element type can't matter to the generated code. In this case, we can use an arbitrary pointer type as the shape type. (To match the non-unified frontend, we use `*byte`.)», and right after: «Otherwise, we simply use the type's underlying type as its shape». The same function carries a TODO for what the implementation does not do yet, and its first item is precisely what the implementation document already treats as done: «collapsing all pointer-shaped types into a common type».https://go.dev/src/cmd/compile/internal/noder/reader.go
- Generics implementation — Dictionaries (Go 1.18): what was actually implementedOfficial documentation. The document the gcshape document itself points to as a more detailed and up-to-date description of the implementation. The grouping rule is stated there in one sentence: «Two concrete types are in the same gcshape grouping if and only if they have the same underlying type or they are both pointer types». And it explains why scalars are kept apart deliberately: «fundamentally different built-in types such as `int` and `float64` are never in the same gcshape» — their operations differ, and the dictionary would have to carry different implementations. Even `int16` and `int32` are separated, over shifts. Comparing this rule with the measurement yields the one divergence this article found: the document merges pointers unconditionally, the compiler merges them only under a basic interface, that is, a constraint defined by methods alone.https://go.googlesource.com/proposal/+/master/design/generics-implementation-dictionaries-go1.18.md
- The Go Specification — Type parameters, Type constraints, General interfacesOfficial documentation. The definition of a type parameter: «Within a type parameter list of a generic declaration, each name declares a type parameter». And the distinction the implementation's shaping depends on — an interface holding only methods is called basic: «Interfaces that are not basic may only be used as type constraints, or as elements of other interfaces used as constraints».https://go.dev/ref/spec
- sort.Ints in Go 1.24Go source code. One line that explains why the «generic versus specialised function» pairing in the sorting block compares nothing at all: `func Ints(x []int) { slices.Sort(x) }`. Next to it the authors' note: «Note: as of Go 1.22, this function simply calls [slices.Sort]». The standard library has already replaced the specialised version with a call to the generic one.https://go.dev/src/sort/sort.go
- The Go Specification — Type inferenceOfficial documentation. The rule that lets generics read like ordinary functions, and the closed list of places where it applies: "Type inference supports calls of generic functions and assignments of generic functions to (explicitly function-typed) variables." Assigning the result of a call is not on that list, which accounts for the commonest of the compiler refusals quoted in the article. The same section gives the precedence that explains why `Sum(i, 2.5)` fails when `var i int` while `Sum(1, 2.5)` widens happily to `float64`: "Type inference gives precedence to type information obtained from typed operands before considering untyped constants."https://go.dev/ref/spec#Type_inference
- The Go Specification — General interfaces (type sets and the tilde)Official documentation. Where the meaning of `~` comes from: "The type set of a non-interface type term is the set consisting of just that type" against "The type set of a term of the form ~T is the set of all types whose underlying type is T." And the prohibition that stops a type set and a method set being added together with a union: "A union (with more than one term) cannot contain the predeclared identifier comparable or interfaces that specify methods, or embed comparable or interfaces that specify methods."https://go.dev/ref/spec#General_interfaces
- Type Parameters Proposal (design/43651)Official documentation. The rule by which the compiler decides what operations a type-parameter value permits: "The rule is that a generic function may use a value whose type is a type parameter in any way that is permitted by every member of the type set of the parameter's constraint." Both compilation errors in the constraints section follow mechanically from that one sentence.https://go.googlesource.com/proposal/+/refs/heads/master/design/43651-type-parameters.md
- Go 1.27 Release Notes — Changes to the languageOfficial documentation. The boundary between the state of the language and the toolchain of the measurement. Generic methods: "Go 1.27 now supports generic methods: a method declaration may declare its own type parameters." And what that did NOT move, from the same paragraph: "Note that methods of interfaces may not declare type parameters nor can interface methods be implemented by generic methods." On inference, from the same notes: "Function type inference has been generalized to apply in all contexts where a generic function is assigned to a variable of (or converted to) a matching function type." None of these features is retyped from the document here: each is checked by the compiler. `
bench/gogenerics/versions.go` builds four cases as temporary modules and prints the answer; the runs on both versions are in `runs/versions-go124.txt` and `runs/versions-go127.txt`.https://go.dev/doc/go1.27 - Go 1.26 Release Notes — Changes to the languageOfficial documentation. The lifted ban on a constraint referring to itself: "The restriction that a generic type may not refer to itself in its type parameter list has been lifted. It is now possible to specify type constraints that refer to the generic type being constrained." The example in the notes is `type Adder[A Adder[A]] interface { Add(A) A }`. On go1.24.7, where every number in this article was taken, that declaration does not compile yet.https://go.dev/doc/go1.26
- go.dev/blog — When To Use GenericsOfficial documentation. The language authors' advice on when a generic is not what you want: "Inversely, if the implementation is different for each type, then use an interface type and write different method implementations, don't use a type parameter." The article joins it to the cost measurement, taken exactly as wide as it was measured: methods in a constraint make it an interface in meaning, and they cost an indirect call.https://go.dev/blog/when-generics