The copy-paste trap
You write a function to find the first even number in a slice of integers. It works perfectly. Two weeks later you need the exact same logic for a slice of custom structs. You copy the function, change int to MyStruct, and adjust the comparison. Six months later you have four nearly identical functions living in different packages. The duplication is noisy, hard to maintain, and invites subtle bugs when you update one but forget the others.
Go solved this problem in version 1.18 with generics. The feature lets you write type parameters that the compiler fills in later. The question that follows every generics announcement is the same: does this flexibility cost performance. Some languages pay a runtime tax for generic code. Go does not. The tradeoff happens at compile time, not at runtime.
Generics in Go are fast because the compiler refuses to leave any type resolution for the program to handle later. You get the reuse of templates without the overhead of runtime dispatch.
How Go actually implements generics
Programming languages generally implement generics in one of two ways. Dictionary passing keeps a single compiled copy of the generic function and passes a type descriptor at runtime. The function looks up how to compare, allocate, or serialize the type on the fly. Monomorphization generates a separate copy of the function for every concrete type you actually use. The compiler bakes the type into the machine code before the program ever runs.
Think of dictionary passing like handing out a blank form and a reference manual. Every time someone fills out the form, they flip to the manual to see how to format the date, calculate the tax, or validate the signature. The form is universal, but the lookup happens every time.
Monomorphization is like photocopying the form and pre-filling the instructions for each department. The accounting team gets a version with tax rules printed directly on the page. The shipping team gets a version with weight formulas baked in. No one flips to a manual. The paperwork moves faster, but the printer uses more paper.
Go chose monomorphization. The language designers prioritized predictable performance and zero runtime indirection. Generic functions compile down to the exact same instructions as hand-written, type-specific functions. The compiler does the heavy lifting so your program does not.
Monomorphization trades compile-time work and binary size for runtime speed. You pay for the copies upfront, not while the program is running.
What the compiler generates
Here is the simplest generic function you can write:
// First returns the first element of a slice, or the zero value if empty.
func First[T any](s []T) T {
// Check length to avoid out-of-bounds panic
if len(s) == 0 {
// Allocate zero value safely without knowing T
return *new(T)
}
// Return the head element directly
return s[0]
}
When you call First([]int{10, 20}), the compiler does not generate a single First function that somehow handles integers. It generates First_int. When you call First([]string{"a", "b"}), it generates First_string. The T placeholder disappears from the final binary. The generated code contains direct integer loads and direct string header copies. There is no type switch, no interface box, and no virtual call.
The compiler tracks which type arguments you actually pass. If you only ever call First with int and float64, the binary contains exactly two copies. Unused type combinations never get generated. This keeps the compile-time work proportional to your actual usage.
Go functions get a // FuncName does X doc comment above them. The sentence starts with the function name. The compiler ignores the comment, but the tooling and your future self will thank you. Follow the convention and your godoc output stays clean.
The generated machine code is identical to what you would write by hand. If you wrote FirstInt and FirstString manually, the optimizer would produce the same instruction sequences. The generic syntax is purely a compile-time convenience.
Real-world usage and binary size
Speed is not the only metric. Monomorphization trades runtime performance for binary size and compile time. Every concrete type combination adds a copy of the function body to the object file. A heavily generic codebase with many type parameters can produce larger binaries. The effect is usually negligible for small utilities, but it compounds in large frameworks.
Consider a generic cache that stores values by key:
// Cache holds key-value pairs with a simple eviction policy.
type Cache[K comparable, V any] struct {
// Map stores the actual data
items map[K]V
// Order tracks insertion for LRU eviction
order []K
}
// Get retrieves a value or returns the zero value and false.
func (c *Cache[K, V]) Get(key K) (V, bool) {
// Direct map lookup with no type dispatch
val, ok := c.items[key]
return val, ok
}
// Set adds or updates a value in the cache.
func (c *Cache[K, V]) Set(key K, val V) {
// Skip if key already exists to preserve order
if _, exists := c.items[key]; exists {
c.items[key] = val
return
}
// Append to tracking slice and store value
c.order = append(c.order, key)
c.items[key] = val
}
When you instantiate Cache[string, User] and Cache[int, Config], the compiler emits two distinct struct layouts and two sets of methods. The Get and Set methods for each instantiation are independent. The compiler can inline them, constant-fold keys, and optimize map access patterns exactly as it would for concrete types.
The convention for type parameters is to use short, descriptive names. K and V for key and value. T for a single type. E for error types. Keep them consistent across your package. The compiler does not care about the names, but the community expects them to follow this pattern.
Binary bloat is real but manageable. The Go linker performs dead code elimination. If a generic function is never called with a specific type, the linker strips it. You only pay for what you use. Profile your binary size if it matters. Most applications see a single-digit percentage increase, which is a fair price for eliminating copy-paste duplication.
Let the linker strip what you do not use. Write constraints that match your actual invariants.
Where generics trip you up
Generics are not a performance silver bullet. They can introduce subtle bottlenecks if you misuse type constraints or combine them with interfaces carelessly.
The any constraint is an alias for interface{}. When you write func Process[T any](item T), the compiler still monomorphizes, but the function body cannot assume anything about T. You cannot call methods on it. You cannot compare it with == unless you add a type switch. If you reach for reflection inside a generic function, you have already lost the performance benefit. Reflection forces runtime type inspection, which defeats the purpose of compile-time resolution.
Type constraints can also trigger unexpected allocations. If you constrain T to an interface and pass a struct that satisfies it, the compiler may box the value to satisfy the interface constraint. The error message is usually clear: invalid type parameter constraint: interface does not contain method X. If you ignore the constraint and force an interface type, you get heap allocations where stack allocation would have worked.
The compiler rejects invalid constraints early. If you write func Add[T int | string](a, b T) T, you get invalid type parameter constraint: int is not an interface type. Go requires constraints to be interface types or type sets. You must write func Add[T ~int | ~string](a, b T) T using the tilde to allow underlying types, or define a custom interface constraint. The compiler complains with cannot use T as non-type parameter if you try to use T in a place that requires a concrete type at compile time, like a channel element type in older Go versions. Modern Go handles this better, but the rule remains: constraints must be resolvable before code generation.
Goroutine leaks and generic channels are another trap. If you write a generic worker pool that passes chan T between stages, forgetting to close the output channel will hang the consumer forever. The leak is invisible to the compiler. The worst goroutine bug is the one that never logs. Always pair channel creation with a defer close or a context-driven shutdown path.
Generics also do not replace interfaces when you need polymorphism across unrelated types. A generic function operates on one type at a time. An interface lets you pass different types to the same function signature. Choosing the wrong abstraction creates friction later.
Write constraints that match your actual invariants. Keep your type parameters short and consistent.
Picking the right abstraction
Use generics when you need type-safe collection utilities, algorithm implementations, or data structures that operate on homogeneous values. Use interfaces when you need to accept multiple unrelated types that share a behavior, like io.Reader or fmt.Stringer. Use concrete types when the function only ever handles one type and adding a type parameter adds noise without reuse. Use reflection only when you are building serialization libraries or dynamic proxies, and accept the runtime cost.
Generics are compile-time templates, not runtime magic. Trust the compiler to do the substitution. Write constraints that match your actual invariants. Keep your type parameters short and consistent. Let the linker strip what you do not use.