Go Generics Deep Dive: Type Parameters, Constraints, and When to Use Them
— golang, generics, type-safety, programming, go1.18, data-structures, performance — 12 min read
Go added generics in 1.18, after years of debate about whether it should at all. If you are newer to Go, generics can look intimidating, so this guide builds up slowly: what problem they solve, the exact syntax and vocabulary, how the compiler fills in types for you, the standard library you will actually use, and the honest performance picture. By the end you should be able to read generic Go, write your own, and know when to leave them alone.
One myth to clear up first, because you will hear it repeated: Go generics are not "zero cost." They are usually fine, and often faster than the interface{} code they replace, but the implementation can add real overhead in specific cases. I will show you exactly when, near the end. Reach for generics for type safety and less duplication, not for raw speed.
The problem generics solve
Before generics, if you wanted a Min function that worked for more than one type, you had three bad options.
You could write the same function once per type, and keep them in sync by hand:
func MinInt(a, b int) int { if a < b { return a }; return b }func MinFloat64(a, b float64) float64 { if a < b { return a }; return b }func MinString(a, b string) string { if a < b { return a }; return b }You could use interface{} (now spelled any) and give up compile-time type safety, paying for runtime type assertions and boxing:
func Min(a, b any) any { /* type switch, assertions, can fail at runtime */ }Or you could generate code with go generate and a tool, which works but adds a build step and a file to review.
Generics let you write the logic once, keep the types checked at compile time, and skip the codegen:
import "cmp"
func Min[T cmp.Ordered](a, b T) T { if a < b { return a } return b}Read Min[T cmp.Ordered] as: "Min is a function with a type parameter named T, and T can be any type that satisfies the cmp.Ordered constraint." Everything in the square brackets is the new part. The rest is ordinary Go.
Type parameters and instantiation
A type parameter is a placeholder for a type, declared in square brackets before the normal parameter list. any is the least restrictive constraint: it means "any type at all."
func Transform[T, U any](in []T, fn func(T) U) []U { out := make([]U, len(in)) for i, v := range in { out[i] = fn(v) } return out}When you call a generic function, the compiler turns T and U into concrete types. That step is called instantiation. You rarely write it out, because Go infers it from the arguments:
nums := []int{1, 2, 3}labels := Transform(nums, func(n int) string { return fmt.Sprintf("#%d", n) })// inferred as Transform[int, string]You can also instantiate explicitly when inference cannot figure it out, or when you want to be clear:
Min[float64](3.14, 2.71) // force T = float64Types can be generic too, not just functions:
type Pair[T, U any] struct { First T Second U}
// Methods use the type's parameters, but a method cannot add new ones of its own.func (p Pair[T, U]) Swap() Pair[U, T] { return Pair[U, T]{First: p.Second, Second: p.First}}One rule that surprises newcomers: methods cannot have their own type parameters. The parameters belong to the type. If you need a per-call type parameter, use a free function instead of a method.
Constraints: what a type parameter is allowed to do
A constraint is an interface that describes what you can do with a type parameter. This is the core idea, so it is worth slowing down.
Inside a generic function, the compiler only lets you use operations that every type allowed by the constraint supports. With any, you can do almost nothing to a value of type T except move it around, because not all types support <, +, or .String(). To use <, the constraint must promise that T is ordered. That is what cmp.Ordered does.
Constraints come in two flavors, and you can combine them.
Method constraints are ordinary interfaces. If the constraint requires a method, T must have it:
func JoinStrings[T fmt.Stringer](items []T) string { var b strings.Builder for _, item := range items { b.WriteString(item.String()) } return b.String()}Type-set constraints are the new part. An interface can list the concrete types that satisfy it, separated by |:
type Numeric interface { ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~float32 | ~float64}
func Sum[T Numeric](values []T) T { var sum T // the zero value of T for _, v := range values { sum += v // legal because every type in the set supports + } return sum}The tilde, explained
The ~ in ~int is the one piece of syntax people get stuck on. int means exactly the type int. ~int means "any type whose underlying type is int." That matters because Go programs define named types constantly:
type Celsius float64type UserID intCelsius is not float64, but its underlying type is float64. If your constraint says float64, then Celsius does not satisfy it. If it says ~float64, it does. As a rule, use ~ in your numeric and ordered constraints so they work with named types, which is almost always what callers expect.
A trap: mixing type sets and methods
You can write a constraint that has both a type set and a method:
type StringableInt interface { ~int | ~int64 String() string}This is legal, but read it carefully: it means "an integer-based type that also has a String() string method." A plain int has no methods, so it does not satisfy this. Only a defined type like type Level int with a String() method does. That is occasionally what you want, but if you write this by accident you will get a constraint that almost nothing satisfies, and confusing compile errors. When you combine the two, make sure a real type in your codebase actually meets both halves.
The built-in constraints you will actually use
Since Go 1.21, the standard library gives you the common ones, so you usually do not need a third-party package:
anyfor no restriction.comparablefor types you can use with==and!=(needed for map keys and sets).cmp.Orderedfrom thecmppackage for types that support<,<=,>,>=.
For integer-only or float-only constraints, golang.org/x/exp/constraints still provides constraints.Integer, constraints.Float, constraints.Signed, and constraints.Unsigned, which have no standard-library equivalent yet.
Type inference, and when it gives up
Inference is what lets you write Min(3, 5) instead of Min[int](3, 5). The compiler looks at the arguments and works backward to the type parameters. It is good, but it has limits worth knowing so its errors do not surprise you.
Inference works from argument types. It does not work from the return type. This fails, because there is nothing to infer T from:
func Zero[T any]() T { var z T; return z }
x := Zero() // error: cannot infer Ty := Zero[int]() // fineUntyped constants can also trip it up. Min(1, 2.0) will not compile, because 1 and 2.0 push toward different types for the single parameter T. Give them the same type, or instantiate explicitly.
When inference fails, the fix is always the same: write the type argument in brackets. Do that freely. Explicit instantiation is not a failure, it is documentation.
Writing a generic data structure
Containers are the clearest win for generics, because before 1.18 a reusable stack or set had to use interface{} and cast on the way out. Here is a small, honest stack:
type Stack[T any] struct { items []T}
func (s *Stack[T]) Push(v T) { s.items = append(s.items, v)}
func (s *Stack[T]) Pop() (T, bool) { var zero T if len(s.items) == 0 { return zero, false } v := s.items[len(s.items)-1] s.items = s.items[:len(s.items)-1] return v, true}Two details teach the whole pattern. var zero T gives you the zero value of whatever T turns out to be (0, "", nil), which you need for the "empty" return. And Pop returns (T, bool) rather than panicking, so the caller handles the empty case, the same shape as a map read.
A set is just as direct, and shows why comparable exists:
type Set[T comparable] map[T]struct{}
func (s Set[T]) Add(v T) { s[v] = struct{}{} }func (s Set[T]) Has(v T) bool { _, ok := s[v]; return ok }T must be comparable here because map keys must support ==. If you tried Set[T any], the compiler would stop you, which is the type system doing its job.
The standard library did a lot of this for you
This is the part a newcomer most needs to hear: before you write a generic helper, check whether the standard library already has it. Since Go 1.21, slices and maps cover the common cases, and they are generic under the hood.
import ( "cmp" "slices")
nums := []int{3, 1, 2}slices.Sort(nums) // [1 2 3]biggest := slices.Max(nums) // 3found := slices.Contains(nums, 2) // truei, ok := slices.BinarySearch(nums, 2) // 1, true
// Sort structs by a field without a custom Less:type User struct{ Name string; Age int }users := []User{{"b", 30}, {"a", 20}}slices.SortFunc(users, func(a, b User) int { return cmp.Compare(a.Age, b.Age)})You do not need to write your own Min, Max, Sort, or Contains. Learn slices, maps, and cmp first. Write your own generic code only for the shapes the standard library does not cover.
The honest performance story
Now the part the older version of this post got wrong. It is tempting to say generics compile to the same machine code as hand-written type-specific functions, with zero overhead. That is not how Go implements them.
Go uses an approach called GC shape stenciling. Instead of generating a separate copy of the function for every concrete type (full monomorphization, like C++ templates) or erasing types and boxing everything (like Java), it generates one copy per "GC shape." Types with the same memory shape share a single compiled function. All pointer-typed instantiations, for example, share one copy and receive a hidden dictionary argument that carries the type-specific information they need at runtime.
Foo[*A], Foo[*B], Foo[*C] (all pointers, same GC shape) │ ▼ one compiled Foo body + a per-call dictionary (which concrete type, its methods, ...)That design keeps binary size and compile times reasonable, but it has a cost: when generic code calls a method on T, it often goes through the dictionary, which the compiler cannot always inline. The result is that generic code can be slower than the equivalent non-generic code, and in some method-heavy cases slower than an interface. This is documented and measurable; the PlanetScale write-up "Generics can make your Go code slower" is the canonical example.
The practical guidance for an engineer:
- Use generics to remove duplication and keep type safety, which is what they are good at.
- Do not assume they are a speedup. For value types with simple operators (
<,+) they are usually fine. For code that calls methods on the type parameter in a hot loop, measure. - If a benchmark shows the dictionary is hurting a genuine hot path, a concrete, non-generic version is a legitimate optimization.
Always benchmark with for b.Loop() (Go 1.24) or b.N, on your real types. Do not trust round numbers in a blog post, including this one.
When not to use generics
The Go team's own advice is conservative, and it is good advice: write the ordinary code first, and introduce a type parameter only when you see the same logic repeated across types, or when you are writing a general-purpose container or algorithm.
Signs you do not need generics:
- The function only ever handles one type. A type parameter with one instantiation is just noise.
- You are switching on the type parameter's concrete type inside the function. If you find yourself doing a type switch on
T, an interface with methods is usually the clearer design. - An interface already expresses the behavior cleanly and the boxing cost does not matter. Interfaces are still the right tool for polymorphism through behavior.
Generics are for when the logic is identical across types and only the type differs. Interfaces are for when the behavior differs and you call it through a common method set. Reaching for the wrong one is the most common mistake.
Migrating existing code
You do not have to convert a codebase in one pass. A safe path:
- Find a cluster of near-identical functions that differ only by type, like
MinInt,MinFloat64,MinString. - Write one generic version next to them, with the right constraint (
cmp.Orderedhere). - Point the old functions at the generic one so callers keep compiling, then remove them as you update call sites.
- Delete the shims once nothing calls them.
Because the generic version is type-checked at compile time, this refactor is low risk: if a call site was relying on a behavior the constraint does not allow, the build tells you before anything ships.
Takeaways
- A type parameter is a placeholder type; a constraint says what you can do with it.
any,comparable, andcmp.Orderedcover most needs. - Use
~in numeric and ordered constraints so they work with named types liketype Celsius float64. - Let inference do the work, and instantiate explicitly when it cannot infer from arguments.
- Check
slices,maps, andcmpbefore writing your own generic helper. Most of what you need is already there. - Generics are for type safety and less duplication, not guaranteed speed. Go's GC shape stenciling can add dictionary overhead, so measure hot paths.
- When behavior differs across types, use an interface. When only the type differs, use a type parameter.