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:

  1. The call to log.Info with key-value pairs. slog takes ...any for its variadic args, so each passed argument boxes into an any. Event is a struct, so it allocates on the heap and gets a pointer stored in the any.
  2. Inside slog, when it formats the event, fmt.Sprintf is called with e.Data which is a map[string]any. Each value in the map is already an any, so those are pre-boxed, but if you had a concrete-typed field, it would box here.
  3. 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:

  • any or interface{} parameters, obviously
  • error values, because error is itself an interface
  • io.Reader/io.Writer parameters — if you pass a concrete *bytes.Buffer, it boxes at the call site (usually stack-allocated, but not always)
  • fmt.Stringer methods, via fmt.Sprintf, fmt.Println, etc.
  • Function values that capture large structs in their closure
  • Returning nil from a function whose return type is error: 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.