When interfaces in Go bite you at the allocation level
Every Go engineer eventually learns that passing a concrete type through an interface{} boxes it, which allocates. What took me longer to internalize is how often this happens by accident, in places I didn’t think of as “using interfaces.”
The basic rule: when you assign a concrete type to an interface-typed variable or parameter, and the concrete type doesn’t fit into a word-sized pointer, the runtime allocates a small heap object to hold the value and then stores a pointer to it in the interface. Interfaces are two-word structs: one word for the type descriptor, one for the data. If the data is bigger than a word, or if it’s a type whose address escape-analysis can’t prove is stack-safe, you get a heap allocation.
Here’s an innocent-looking piece of code from a service I was profiling:
type Event struct {
Timestamp time.Time
Kind string
Data map[string]any
}
func (e Event) String() string {
return fmt.Sprintf("[%s] %s: %v", e.Timestamp.Format(time.RFC3339), e.Kind, e.Data)
}
func logEvent(log *slog.Logger, e Event) {
log.Info("event", "event", e)
}
Three allocations per call, and I didn’t see any of them. Let’s count:
- The call to
log.Infowith key-value pairs.slogtakes...anyfor its variadic args, so each passed argument boxes into anany.Eventis a struct, so it allocates on the heap and gets a pointer stored in theany. - Inside
slog, when it formats the event,fmt.Sprintfis called withe.Datawhich is amap[string]any. Each value in the map is already anany, so those are pre-boxed, but if you had a concrete-typed field, it would box here. - The
e.Timestamp.Format(time.RFC3339)allocates a string (which is fine, you needed a string).
Most of these look unavoidable, but #1 is the sneakiest. Before I profiled, I would have told you “I’m just passing a struct to a logger.” But slog’s API is func (l *Logger) Info(msg string, args ...any), and that ...any is an interface conversion per argument. If you call log.Info("x", "a", 1, "b", 2, "c", obj), you box three values: the int 1, the int 2, and obj.
One mitigation is to implement slog.LogValuer on types you log:
func (e Event) LogValue() slog.Value {
return slog.GroupValue(
slog.Time("ts", e.Timestamp),
slog.String("kind", e.Kind),
)
}
This doesn’t eliminate the initial boxing of the Event, but it gives the logger a chance to extract typed fields cheaply instead of calling String() (which allocates). For structured logging backends, it also produces much better output.
But the bigger lesson is about where interfaces sneak in:
anyorinterface{}parameters, obviouslyerrorvalues, becauseerroris itself an interfaceio.Reader/io.Writerparameters — if you pass a concrete*bytes.Buffer, it boxes at the call site (usually stack-allocated, but not always)fmt.Stringermethods, viafmt.Sprintf,fmt.Println, etc.- Function values that capture large structs in their closure
- Returning
nilfrom a function whose return type iserror: the compiler knows this is the zero value, no allocation. But returning a typed nil (e.g.,var err *MyError = nil; return err) creates a non-nil interface with a nil data pointer. This is the classic “non-nil nil” bug that also happens to involve an allocation.
Here’s one I see a lot — returning a concrete error type through an error interface:
type NotFound struct {
Key string
}
func (n NotFound) Error() string { return "not found: " + n.Key }
func Lookup(k string) (Value, error) {
if k == "" {
return Value{}, NotFound{Key: k}
}
// ...
}
NotFound{Key: k} boxes into an error. Every error path allocates. For a rare code path, fine — GC can handle that. But I’ve seen services that return a specific sentinel error on every cache miss, with cache miss rates of 30%+. That’s a lot of small allocations.
The classic fix is to use a package-level error variable:
var ErrNotFound = errors.New("not found")
func Lookup(k string) (Value, error) {
if k == "" {
return Value{}, ErrNotFound
}
}
Now ErrNotFound is allocated once, at package init, and the error return is just a pointer copy. Callers check errors.Is(err, ErrNotFound). If you need to carry dynamic data (like the key), you can still do this at a lower rate by only allocating when you have a specific reason.
For the allocation-obsessed, there’s a useful tool: go build -gcflags="-m". This prints escape analysis decisions. Look for “moved to heap” messages. They tell you which variables the compiler couldn’t prove stack-safe. You can then restructure to keep things on the stack — often by not passing through an interface.
$ go build -gcflags="-m" ./... 2>&1 | grep "moved to heap"
./logger.go:42:9: moved to heap: e
I don’t chase every single one of these. Most of the time, an allocation here or there doesn’t matter. But for hot paths — request handlers, serialization loops, anything called millions of times a day — knowing where your interface conversions are is worth the hour you’ll spend reading escape-analysis output.
More on profiling in the pprof graph post. For allocations specifically, go tool pprof -alloc_objects heap.pprof is the command I reach for first.