Last Updated: May 22, 2026
Generics let you write a function or type once and use it with many different element types, without giving up the compile-time safety Go is known for. They were added in Go 1.18, after years of debate, and they suit a narrow set of problems: containers, algorithms that operate on collections, and small type-safe wrappers. This lesson covers the motivation (what made Go 1.17 code repetitive or unsafe), what the language added in 1.18, and a single end-to-end example so the syntax feels concrete.
Go before 1.18 had two ways to write a function that worked across types. Both had real costs.
The first option was to write the same function once per type. Consider an e-commerce service that wants a Min helper: the cart code needs min(price, discountCap) on float64, the inventory code needs min(stockA, stockB) on int, and the order timestamp code needs min(t1, t2) on time.Time. Three call sites, three different types, three almost-identical functions:
Three functions with one line of meaningful logic each. Every new element type means another copy. Worse, if you ever change how Min works (say, you decide ties should go to the second argument), you have to remember to change it in every copy. The standard library lived with this pain for years: math.Min worked only on float64, and there was no intmin in the standard library at all until generics arrived.
Real codebases didn't stop at three. A typical service might have MinInt, MinInt64, MinUint64, MinFloat64, MinDuration, and MinTime, plus Max versions of each, plus Sum, Average, and Sort in the same shape. That's two dozen functions whose only difference is the element type. Even worse, library authors couldn't predict every type their callers would want, so users routinely had to write their own copies in application code. The repetition wasn't just inside one project; it was duplicated across every Go project that needed numeric helpers.
The second option was to use interface{} (now spelled any), accept any value, and use a type assertion or type switch inside. That works, but it gives up two things Go values: compile-time type safety, and a clean signature.
Notice how much friction interface{} introduces. The function has to type-switch internally to know what < even means. The caller gets back an interface{} and has to assert the type back out before doing anything useful. And if you pass two different types ( Min(1, 2.0) ), the function compiles fine but panics at runtime when the assertion fails. The whole point of writing one helper turned into runtime gymnastics.
There's also a real performance cost. Every primitive value passed through interface{} gets boxed, meaning the runtime allocates an interface header that holds a type descriptor and a pointer to the underlying value. A Min call that should be a single comparison instruction becomes an allocation plus a type switch plus a pointer dereference.
To see why this matters, picture a recommendation engine that ranks 10,000 candidate products. It calls Min 10,000 times to find the cheapest matching item. With MinFloat64, the cost is 10,000 plain comparisons. With the interface{} version, the cost is 10,000 allocations of two-word interface headers, 10,000 type switches, and 10,000 type assertions in the caller. The functional behavior is identical; the runtime profile is not.
Cost: Passing primitives like int or float64 through interface{} boxes them on the heap. In a hot loop, that allocation pressure can dominate the actual work. Generics avoid the boxing entirely.
A third drawback is documentation. A signature like func Min(a, b interface{}) interface{} tells a reader nothing about what the function is for or what types make sense to pass. The generic signature func Min[T cmp.Ordered](a, b T) T says, in the signature alone, "this works for any ordered type, the two arguments are the same type, and the return is that same type." Type information is part of the API, and interface{} throws it away.
Let's draw the duplication problem more clearly. Each of these functions has the same shape; only the element type changes:
Four function signatures, one body. The shape is "given two comparable values, return the smaller one." The element type is incidental, but the pre-1.18 language had no way to say that, so the function had to be rewritten or the type information had to be erased through interface{}.
Go 1.18, released in March 2022, introduced type parameters. A type parameter is a placeholder for a type, declared in square brackets right after the function or type name. When you call the function, Go figures out the concrete type from the arguments and produces a version that works on that type.
The feature took years to land. The original Go team considered and rejected several proposals because none preserved the language's simplicity. The proposal that did ship, based on the Featherweight Go design, kept the surface area small: type parameters on functions and types, constraints that look like interfaces, no variance, no higher-kinded types. The bet was that "generics that match Go's style" was more valuable than "generics that match what other languages do." Whether that bet paid off is a matter of opinion, but the result is a language addition you can ignore until you need it, which is a deliberate property of the design.
The shape is:
Type parameters can also appear on type declarations. A generic type uses the same square-bracket list right after the type name:
Both forms (generic functions and generic types) compose: a generic type can have generic methods (with the same type parameters declared on the receiver), and a generic function can accept or return generic types.
Three pieces are new compared to a regular function declaration:
| Piece | What it is |
|---|---|
[TypeParam Constraint] | The type parameter list, in square brackets after the function name |
TypeParam | The placeholder name, conventionally a single uppercase letter like T, K, V |
Constraint | An interface that says what operations the type parameter must support |
Here's the smallest example that compiles. A function that returns its argument unchanged, working for any type:
T is the type parameter. any is the constraint, and it means "any type at all." The compiler looks at each call site, sees the argument type, and uses that as T. You didn't write Identity[int](42); the compiler inferred T = int from the literal 42.
The function is one definition, not three. And the result of Identity(42) is an int, not an interface{}. There's no boxing, no type assertion at the call site, and any misuse is caught at compile time. If you tried to do arithmetic inside Identity, the compiler would reject it because any doesn't promise that + works.
That last point is what makes constraints important. The any constraint allows nothing except passing the value around. To actually use operators like < or +, you need a stricter constraint that says the type supports them. The constraint is just the smallest interface that lets the body compile.
A small table summarizes the three constraints you'll encounter in this chapter:
| Constraint | Allows | Use when |
|---|---|---|
any | Storing, copying, passing the value | The body never inspects the value's contents |
comparable | == and != | Looking up in maps, deduping, equality checks |
cmp.Ordered | <, <=, >, >=, ==, != | Sorting, min, max, range comparisons |
cmp.Ordered (and its older sibling constraints.Ordered) is just a named interface whose type set is the union of integer, floating-point, and string types defined in the language. It's not magic; it's a normal Go interface, only one that lists allowed types rather than required methods.
A useful way to think about choosing a constraint is to look at the body and list every operator and method it uses on T. If the body has if a < b, you need cmp.Ordered. If it has if a == b, you need comparable. If it calls T.String(), you need a constraint interface with a String() string method. The constraint is just the smallest interface that lets the body compile.
Clamp constrains a value to a range. It needs both < and >, both provided by cmp.Ordered. One generic helper covers integer quantities (clamping a cart count to the available stock) and float prices (clamping a discount to a valid range), with no duplication and no interface{} round-trip.
Here's the e-commerce Min rewritten as a single generic function. We want it to work on any type that supports <, so we'll use the standard cmp.Ordered constraint from the cmp package (added in Go 1.21). Before 1.21, the same constraint lived in golang.org/x/exp/constraints.Ordered.
One function, three call sites with three different types. The cmp.Ordered constraint guarantees that < works on T, which is why the body of Min is allowed to write a < b. If you tried to write Min[time.Time](t1, t2), the compiler would reject the call because time.Time doesn't satisfy cmp.Ordered (you'd need a custom comparison function for that, which slices.MinFunc provides).
Look at what the call sites do not say. We never wrote Min[float64](29.99, 24.99) or Min[int](12, 7). The compiler figures out T from the argument types. This is called type inference, and it's why generic Go code reads almost like the non-generic version. The reader sees Min(29.99, 24.99) and doesn't have to think about the type parameter at all. Inference is the property that makes generics feel like an addition rather than a rewrite of the language.
You can be explicit when you want to be:
The explicit form is useful when inference fails (for example, when the type parameter doesn't appear in the arguments, only in the return type) or when you want to force a particular type. Most of the time, you leave the brackets off and let the compiler decide.
One subtle point about inference: if you mix types at a call site, Go won't quietly convert them. Min(29.99, 25) looks innocent, but 29.99 is a float64 and 25 is an int, and there's no single T that satisfies both. The compiler will reject the call with an error like "default type float64 of 29.99 does not match default type int of 25." This is the same strict typing Go has always had, applied through the generic machinery. To fix the call you'd write Min(29.99, 25.0) or Min[float64](29.99, 25).
The same Min works on a custom defined type as long as its underlying type is ordered. If you define type Price float64, then cmp.Ordered is satisfied because the underlying float64 supports <, and Min[Price](Price(29.99), Price(24.99)) compiles and runs:
This matters because real codebases define named types for clarity (type ProductID int, type StockCount int, type Currency string), and a generic helper should work on them, not just on the underlying primitives. cmp.Ordered covers all of them.
A second small example shows generics working on slices. A typed Contains function answers "is this value in this slice?" without using interface{}:
The constraint here is comparable, not cmp.Ordered. comparable is a built-in constraint meaning "supports == and !=," which is exactly what Contains needs. It's a wider constraint than cmp.Ordered: every ordered type is comparable, but not every comparable type is ordered (for example, bool is comparable but not ordered).
If a developer tries to call Contains with mismatched types, the compiler stops them:
That error happens at build time, not in production. With interface{} the same call would compile fine and panic at runtime, or worse, silently return false for an apples-to-oranges comparison. This is the type-safety side of the win.
Cost: Generic functions on slices avoid the per-element boxing of interface{} versions. A Contains[int] over a million-element slice does a million plain integer comparisons; the interface{} version would do a million type assertions and pointer comparisons.
When you call Min(29.99, 24.99), what does the compiler actually do with the T? The answer is "it depends on the implementation," and the details affect performance, but the high-level picture is consistent.
Two strategies are common across compilers that support generics: full monomorphization and a hybrid approach Go calls GC shape stenciling.
Full monomorphization means the compiler generates a completely separate copy of the function for each concrete type used at a call site. Min[int], Min[float64], and Min[string] each become independent compiled functions. The generated code is as fast as if you had written MinInt, MinFloat64, and MinString by hand, because that's literally what the compiler ends up emitting. The downside is binary size: every type combination grows the binary, and for a heavily generic standard library across hundreds of types, the growth can be significant.
GC shape stenciling is what Go's standard compiler (gc) uses as of Go 1.18 through at least 1.22. The compiler groups types by their memory layout, called a GC shape, and generates one copy of the function per shape, not per type. Pointers all share a shape. int32 and float32 share a shape. int64, float64, and uintptr share a shape on 64-bit machines. Inside the function, the compiler uses a small per-call dictionary to track the precise type when it matters (for example, when calling methods or accessing struct fields). The dictionary itself is a pointer to a small static table generated at compile time, so the overhead is a pointer load, not a full method-table lookup.
The diagram below sketches the difference. Two call sites with different types go through the same compiled body when their shapes match:
Four call sites, two compiled bodies. int, int64, and float64 all fit the "8-byte word" shape, so they share one body. string has a different layout (pointer plus length), so it gets its own. The compiler chooses this tradeoff to balance code size against runtime overhead; pure monomorphization would generate four bodies and run slightly faster on some patterns, but inflate the binary.
The practical takeaway is short. Generic code compiles to something close to hand-specialized code. There is a small dictionary overhead in the GC-shape model that can show up in microbenchmarks, but for most application code the cost is invisible. There is no interface{} boxing, no type switch, no runtime type assertion. The cost you'd pay with interface{} simply isn't there.
It's also worth noting what generics are not. They don't add runtime type information you didn't already have, they don't slow down non-generic code, and they don't change how interface{} behaves. A function that doesn't use type parameters compiles exactly as it did before 1.18. The choice between generics, interfaces, and concrete types is a design choice, not a performance one, except in the narrow case where you'd otherwise pay for interface{} boxing on primitives.
Cost: GC shape stenciling adds a small dictionary lookup when a generic function calls methods on T through a constraint interface. For straightforward functions like Min and Contains that only use operators, the overhead is negligible. For generic functions that call methods extensively, profile before assuming generics are free.
Generics solve a specific class of problems. They're not a tool you sprinkle everywhere. Use them when the function or type genuinely operates on many element types and the logic doesn't depend on the type's specifics.
Three categories cover most real uses:
1. Container types. Stacks, queues, sets, ordered maps, LRU caches. These are data structures whose code doesn't care what's inside, only that "what's inside" is consistent. Before generics, you either wrote a Stack per type or used []interface{} and lost type safety. With generics, a Stack[Product] and a Stack[Order] are distinct types, both compiled from one definition. A user of Stack[Product] can't accidentally push an Order onto it; the compiler catches the mistake at the call site.
The Stack[T any] is one type definition. Stack[string] and Stack[int] are distinct concrete types at compile time. Pushing an int onto a Stack[string] is a compile error. The var zero T line uses the zero value of whatever T is, which is one of the patterns that comes up repeatedly in generic code. Generic types are otherwise ordinary Go types: they have methods (declared with the type parameter list after the receiver), they can have unexported fields, they can satisfy interfaces, and they participate in the usual type system.
2. Algorithms over collections. Mapping, filtering, reducing, sorting, finding min or max, deduping. Each of these has a shape that doesn't depend on the element type. The standard library's slices package (also added in 1.21) is built almost entirely on this idea: slices.Contains, slices.Index, slices.Sort, slices.Max, and so on, all generic. The maps package added in the same release brings the same idea to maps: maps.Keys, maps.Values, and maps.Equal are all generic.
These functions all use cmp.Ordered or comparable internally. Before 1.21, every team rewrote them locally. Now they're standard.
3. Small type-safe wrappers. Optional values, result types, typed channels with shared logic, typed event buses. These are situations where you want one piece of code to handle multiple value types without losing the type information through any. A typed Cache[K comparable, V any] is a good example: the cache logic (eviction, lookup, expiry) is independent of the key and value types, but callers want a Cache[string, Product] to behave differently from a Cache[int, Order] at compile time.
What generics are not good for:
Write([]byte) (int, error) method). Generics fit when the operation is shape-preserving across element types.Min on float64, a regular func minPrice(a, b float64) float64 is shorter and clearer than a generic one. The Go proverb "a little copying is better than a little dependency" applies here too.Use generics when the function or type would otherwise need to be duplicated across types with no real change to the body, and when an interface would erase information the caller wants to keep.
A useful sanity test before writing a generic function is to ask, "If I wrote this for one concrete type and copy-pasted it for two more types, would the bodies be identical?" If yes, generics fit. If you'd have to tweak the body each time (different fields accessed, different formatting, different rules), the duplication wasn't really duplication, and separate functions or an interface might be a better answer.
Another way to phrase the same idea: generics replace duplication, not abstraction. If your three functions all do sort items, take the first, replace them with a generic helper. If your three functions all do "something domain-specific that happens to start with sorting," keep them separate and share the sorting helper but not the rest.
A short example shows the difference between "shape-preserving across types" (where generics fit) and "behavior varies by type" (where interfaces fit). Suppose the e-commerce service needs to send notifications about events. There are OrderShipped, PaymentFailed, and RefundIssued events, each with different fields. Two helpers might handle them:
notify takes an Event interface because the behavior of describing the event varies by type. recordAll is generic because copying a slice is a shape-preserving operation that doesn't care about the element type. The two tools sit side by side in the same file, each doing what it does best. If you tried to make notify generic, you'd lose the ability to mix event types in one slice. If you tried to make recordAll interface-based, you'd lose the typed return value (it would have to be []Event or []any).
The following end-to-end example ties the lesson together. An e-commerce service has cart items at different price points and stock levels. We want one helper to find the cheapest item, one to check whether a product ID is in a wishlist, and one to dedupe a slice. Without generics, each would be a separate function per type. With generics, we get one definition each:
Three small generic functions, three different element types in main. The cheapest price uses float64, the wishlist check uses int, and the dedupe uses string. Each call site has its type inferred. No interface{}, no type assertions, no per-type duplicates.
The pattern that runs through all three is the same: the function shape is independent of the element type, and the constraint (cmp.Ordered or comparable) is the smallest thing the body actually needs. If the body uses <, the constraint is cmp.Ordered. If it only uses ==, the constraint is comparable. If it does neither, any is enough but the function probably can't do much.
Before moving on, compare the generic version against the alternatives you'd have written before 1.18. The pre-generic options were: write Min, Contains, and Dedupe three times each (nine total functions for three element types), or write each one once with interface{} and pay for boxing plus runtime type assertions on every call. Both options grow with each new element type. The generic version doesn't. Add a fourth element type next month, and the same three functions handle it without a single new line of code.
That property, "the code doesn't grow when the type set grows," is the practical promise of generics. It's narrow, it doesn't apply to every function in a codebase, but where it applies, the savings are real and the type safety stays intact.