Backend Development 17 min read

Why Go Generics Hurt Performance: A Deep Dive into gcshape and Type Parameters

After a year of using Go's generics in production and open‑source projects, this article reveals the hidden performance penalties, pointer handling quirks, and best‑practice recommendations caused by the gcshape implementation and type‑parameter semantics.

Raymond Ops
Raymond Ops
Raymond Ops
Why Go Generics Hurt Performance: A Deep Dive into gcshape and Type Parameters

Go's generics have been available for over a year, and after using them extensively in production and open‑source projects, I share a candid review of the experience.

Conclusion: generics are useful but come with many problems, especially severe performance regressions.

Implementation of Generics

Common generic implementations in other languages are:

Template instantiation (C++ style) – type parameters are placeholders replaced by concrete types, generating separate code for each.

Type erasure (TypeScript, Java) – type parameters are erased to a generic Object or interface, producing a single implementation.

Hybrid (C#) – appears as erasure but actually instantiates code per concrete type.

Go uses a different approach called

gcshape

. Types that share the same underlying type belong to the same "shape". Pointers share a shape, but otherwise identical underlying types are considered different.

Example code demonstrating shape‑based instantiation:

<code>func Output[T any]() {
    var t T
    fmt.Printf("%#v\n", t)
}

type A struct{ a,b,c,d,e,f,g int64; h,i,j string; k []string; l, m, n map[string]uint64 }
type B A

func main() {
    Output[string]()
    Output[int]()
    Output[uint]()
    Output[int64]()
    Output[uint64]()
    Output[*string]()
    Output[*int]()
    Output[*uint]()
    Output[*A]()
    Output[A]()
    Output[*B]()
    Output[B]()
    Output[[]int]()
    Output[*[]int]()
    Output[map[int]string]()
    Output[*map[int]string]()
    Output[chan map[int]string]()
}</code>

Inspecting the symbol table (image) shows the generated shapes.

The motivation behind

gcshape

is to avoid code bloat and reduce GC overhead, because types with the same shape have identical memory layout. However, the implementation introduces serious issues.

Performance Problems

Benchmarks comparing generic code, generic‑with‑interface code, and non‑generic code show that pure generic functions are only ~10 % slower, but mixing interfaces with generics can be up to 100 % slower. Direct interface calls are fastest because the compiler optimises them.

Result image:

The slowdown stems from the fact that a type parameter

T

is not the concrete type; the compiler must look up the actual type in a type‑dictionary and perform an extra conversion. When the type parameter is an interface, an additional method‑lookup adds further overhead.

Only code that uses built‑in operators on the type parameter (e.g., arithmetic) avoids the penalty.

Creating Instances

When the constraint is

any

, you can create a zero value with:

<code>func F[T any]() T {
    var ret T
    // use new(T) for a pointer if needed
    return ret
}</code>

If the constraint is an interface with methods, attempting to instantiate

T

directly leads to a panic because the underlying type is unknown.

Passing Pointers to Type Parameters

Never use a type parameter that is itself a pointer type. The following generic function fails to compile because the pointer base types differ:

<code>func Set[T *int | *uint](ptr T) {
    *ptr = 1
}</code>

The fix is to make the pointer explicit:

<code>func Set[T int | uint](ptr *T) {
    *ptr = 1
}</code>

Method Sets and Type Parameters

Calling a method on a type parameter requires the constraint to include that method. Using a pointer constraint

~*A | ~*B

without the method leads to "a.Hello undefined". Adding the method to the constraint solves the issue:

<code>type API[T any] interface {
    *T
    Hello()
}
func SayHello[PT API[T]](a PT) {
    a.Hello()
}</code>

Copying Objects

Shallow copy works with

b := a

. For generic pointer types you can dereference, copy, and re‑wrap:

<code>func DoCopy[T any, PT API[T]](a PT) {
    b := *a               // shallow copy of the value
    (PT(&amp;b)).Set(222222) // modify through the interface
    fmt.Println(a, b)
}</code>

Deep copy requires a custom

Clone

method defined in an interface such as

Cloneable[T any] interface{ Clone() T }

.

Summary

The main pain points of Go generics are:

Use

*T

explicitly instead of letting

T

represent a pointer type.

Use

[]T

or

map[K]V

instead of letting

T

stand for a slice or map.

Prefer generic structs over generic functions.

Be aware that the core type of a constraint determines which operations are allowed.

For now, stick to well‑tested generic libraries that operate on slices and maps, and avoid complex generic tricks that involve pointer creation or method sets.

performanceGogenericstype-parametersbest practicesgcshape
Raymond Ops
Written by

Raymond Ops

Linux ops automation, cloud-native, Kubernetes, SRE, DevOps, Python, Golang and related tech discussions.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.