Go Performance Guide
I/O & Data Handling

Interface Boxing Costs

Understand interface overhead, inlining prevention, and when to use generics instead

Interface Boxing Costs in Go

Interfaces are one of Go's defining features, enabling flexible, composable code. However, they come with runtime costs that matter in performance-critical paths. This article explores how interfaces work internally and when those costs become significant.

How Go Interfaces Work Internally

Go has two interface types:

  • iface - Non-empty interfaces with methods
  • eface - Empty interface (interface{})

The iface Structure

A non-empty interface is represented at runtime by this structure:

type iface struct {
	tab  *itab
	data unsafe.Pointer
}

type itab struct {
	inter  *interfacetype
	_type  *rtype
	hash   uint32
	_      [4]byte
	fun    [1]uintptr // method pointers
}

When you assign a value to an interface:

  1. The concrete type is recorded in the itab
  2. A pointer to the data is stored
  3. For small types, the data itself is stored inline

The eface Structure

Empty interfaces are simpler:

type eface struct {
	_type *rtype
	data  unsafe.Pointer
}

Both require pointer indirection and dynamic dispatch, which prevent compiler optimizations.

Boxing and Unboxing Overhead

Boxing means wrapping a concrete value in an interface. Unboxing is extracting the value.

Boxing Overhead

package main

import (
	"io"
	"os"
)

func main() {
	// Boxing: concrete type -> interface
	var w io.Writer = os.Stdout

	// The Go runtime:
	// 1. Allocates space for the interface structure
	// 2. Records the concrete type (*os.File)
	// 3. Stores a pointer to the data
}

For small values, boxing stores them inline:

package main

func main() {
	// Small value (fits in pointer) - no allocation
	var i interface{} = 42

	// Larger value (doesn't fit) - needs allocation
	var i interface{} = [1000]byte{}
}

Unboxing Overhead

Type assertions have runtime cost:

package main

func processValue(v interface{}) {
	// Type assertion with runtime check
	f, ok := v.(float64)  // Expensive!
	if ok {
		_ = f * 2
	}

	// Type assertion without check (panics if wrong)
	b := v.(bool)         // Still expensive!
}

Each assertion checks the concrete type against the target type.

When Interface Calls Prevent Inlining

The Go compiler cannot inline through interface boundaries:

package main

// Direct call - can be inlined
func directAdd(a, b int) int {
	return a + b
}

// Interface method - cannot be inlined
type Adder interface {
	Add(a, b int) int
}

type IntAdder struct{}

func (ia IntAdder) Add(a, b int) int {
	return a + b
}

func main() {
	// This can be inlined
	result := directAdd(5, 3)

	// This cannot be inlined - must use dynamic dispatch
	var adder Adder = IntAdder{}
	result = adder.Add(5, 3)
}

When the compiler can't inline, it can't:

  • Eliminate the function call overhead
  • Eliminate bounds checks
  • Optimize surrounding code
  • Remove dead code paths

This is especially damaging in loops:

package main

func sumDirect(values []int) int {
	sum := 0
	for _, v := range values {
		sum += directAdd(v, 1)  // Can be inlined: sum += v + 1
	}
	return sum
}

func sumInterface(values []int, adder Adder) int {
	sum := 0
	for _, v := range values {
		sum += adder.Add(v, 1)  // Cannot inline, function call overhead in loop
	}
	return sum
}

Type Assertions vs Type Switches

Type assertions are faster when you know the target type:

func typeAssertionFast(v interface{}) {
	// Fast: direct type check
	f, ok := v.(float64)
	if ok {
		_ = f * 2
	}
}

func typeAssertionSlow(v interface{}) {
	// Slower: panic if wrong type
	f := v.(float64)
	_ = f * 2
}

func typeSwitchMultiple(v interface{}) {
	// For multiple types, switch is often better
	switch v.(type) {
	case float64:
		// Optimized path
	case string:
		// Another optimized path
	default:
		// Default path
	}
}

Type switches are optimized by the compiler for multiple types. For a single assertion, direct type assertion is faster.

Generics (Go 1.18+) as Alternative to Interfaces

Generics allow type-safe operations without interface overhead:

package main

// Generic function - inlined for each type
func genericAdd[T interface{ ~int | ~float64 }](a, b T) T {
	return a + b
}

// Interface version - cannot be inlined
func interfaceAdd(a interface{}, b interface{}) interface{} {
	// This is much slower!
	switch a.(type) {
	case int:
		return a.(int) + b.(int)
	case float64:
		return a.(float64) + b.(float64)
	}
	return nil
}

func main() {
	// Generic: inlined, no overhead
	result := genericAdd(5, 3)

	// Interface: slower, has type assertions
	var i interface{} = 5
	var j interface{} = 3
	_ = interfaceAdd(i, j)
}

For hot paths with performance requirements, generics are superior because:

  • Each instantiation is specialized for the type
  • Full inlining is possible
  • No dynamic dispatch
  • Monomorphization - compiler creates separate code for each type

Compiler Devirtualization

Go's compiler can sometimes eliminate interface overhead through devirtualization:

package main

type Reader interface {
	Read(b []byte) (int, error)
}

// Concrete type known at compile time
type MyReader struct {
	data []byte
	pos  int
}

func (mr *MyReader) Read(b []byte) (int, error) {
	// Read implementation
	return 0, nil
}

func processReader(r Reader) {
	// If compiler can prove r is always MyReader,
	// it may devirtualize this call
	r.Read(make([]byte, 1024))
}

func main() {
	// Compiler may devirtualize since type is known
	r := &MyReader{}
	processReader(r)
}

Devirtualization typically works when:

  • The concrete type is assigned at the call site
  • The function is not called from multiple places
  • The compiler can prove it's safe

This is why inlining concrete types works but interfaces don't.

Practical Benchmark: Interface vs Generics vs Direct Calls

package main

import (
	"testing"
)

// Direct function - can be fully inlined
func directProcess(values []int) int {
	sum := 0
	for _, v := range values {
		sum += v * 2
	}
	return sum
}

// Interface-based - cannot inline method calls
type Processor interface {
	Process(v int) int
}

type IntProcessor struct{}

func (ip IntProcessor) Process(v int) int {
	return v * 2
}

func interfaceProcess(p Processor, values []int) int {
	sum := 0
	for _, v := range values {
		sum += p.Process(v)  // Cannot inline
	}
	return sum
}

// Generic-based - inlined for each type
func genericProcess[T interface{ ~int }](values []T) T {
	var sum T
	for _, v := range values {
		sum += v * 2
	}
	return sum
}

func BenchmarkDirectProcess(b *testing.B) {
	values := make([]int, 1000)
	for i := range values {
		values[i] = i
	}

	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		_ = directProcess(values)
	}
}

func BenchmarkInterfaceProcess(b *testing.B) {
	values := make([]int, 1000)
	for i := range values {
		values[i] = i
	}

	var p Processor = IntProcessor{}

	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		_ = interfaceProcess(p, values)
	}
}

func BenchmarkGenericProcess(b *testing.B) {
	values := make([]int, 1000)
	for i := range values {
		values[i] = i
	}

	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		_ = genericProcess(values)
	}
}

Expected results (approximately):

  • Direct: Baseline, fully optimized
  • Interface: 10-50% slower, depends on loop size and inlining
  • Generic: Same as direct, fully optimized

The key difference: generics generate specialized code for each type, while interfaces use dynamic dispatch.

When to Use Interfaces Despite Overhead

Interfaces have legitimate uses even with overhead:

  1. Plugin architectures - Different implementations at runtime
  2. Testing - Mock implementations for unit tests
  3. Library APIs - Flexible public interfaces
  4. I/O abstractions - io.Reader, io.Writer, etc.

The overhead matters when:

  • In tight loops called millions of times
  • With small, simple methods
  • Where inlining would provide significant benefit
  • In latency-sensitive applications

Don't optimize prematurely - profile first.

Best Practices for Interface Performance

  1. Minimize interface usage in hot paths

    // Bad: interface call in tight loop
    for _, item := range items {
        process(processor, item)  // Interface call each iteration
    }
    
    // Good: resolve outside loop
    processFunc := processor.Process  // Method value
    for _, item := range items {
        processFunc(item)            // Direct function call
    }
  2. Use method values to reduce dispatch overhead

    var reader io.Reader
    
    // Create a method value outside hot path
    readFunc := reader.Read
    
    // Use the method value in hot path
    for i := 0; i < 1000000; i++ {
        readFunc(buffer)
    }
  3. Prefer generics for type-parametric hot paths

    // Better than interface-based generic
    func processSlice[T any](items []T) {
        // Specialized for each T
    }
  4. Profile before optimizing

    go test -bench=. -cpuprofile=cpu.prof
    go tool pprof cpu.prof
  5. Consider concrete types for critical paths

    // Instead of passing io.Reader interface,
    // specialize function for *os.File if possible
    func processFile(f *os.File) error {
        // Direct method calls, full optimization
    }

Key Takeaways

  • Interfaces prevent inlining - Method calls through interface cannot be inlined
  • Boxing has overhead - Storing values in interfaces requires runtime type information
  • Generics provide safety without overhead - Go 1.18+ generics are monomorphized (compiled separately for each type)
  • Profile first - Measure whether interface overhead actually matters in your application
  • Hot path optimization - Only worry about interface overhead in performance-critical loops
  • Choose the right tool - Interfaces for flexibility, generics for type-safe performance, concrete types for maximum speed

Interface overhead is real but often not the bottleneck. Modern Go tooling and profilers help identify when it matters.

On this page