The hidden structs behind every interface
You are profiling a high-throughput service. The flame graph shows a spike in runtime.assertI2I. You trace it back to a simple line: var x interface{} = myStruct. It looks like a variable assignment. It feels like a no-op. It isn't. The runtime is building a small struct, checking method sets, and wiring up function pointers.
Interfaces in Go are not magic type erasure. They are concrete data structures that travel with your values. Every interface variable holds two machine words. One word points to type information. The other word holds the data. Understanding these internal structures explains why interfaces behave the way they do, why they have performance characteristics, and why nil checks can fail in surprising ways.
Two words, two structures
Go uses two internal representations for interfaces. The distinction depends on whether the interface has methods.
An eface represents an empty interface, interface{} (or its modern alias any). It carries no method constraints. It just holds a value and says "this is a value of some type."
An iface represents a non-empty interface. It carries a method set. It holds a value and says "this is a value of some type, and you can call these methods on it."
Think of a shipping label. An eface is a label that describes the contents. An iface is a label that describes the contents and includes a list of handling instructions. Both labels stick to the box. The box is the interface variable.
package main
import "fmt"
// Shape defines a behavior.
// The compiler tracks this method set.
type Shape interface {
Area() float64
}
// Circle is a concrete type.
// Receiver name is short, matching the type.
type Circle struct {
Radius float64
}
// Area satisfies Shape.
// Methods are just functions with a receiver.
func (c Circle) Area() float64 {
return 3.14 * c.Radius * c.Radius
}
func main() {
c := Circle{Radius: 5}
// iface creation: runtime builds itab + data.
// The compiler checks that Circle implements Shape.
var s Shape = c
// eface creation: runtime builds typ + data.
// assertI2I checks if s can be converted to any.
var i any = s
fmt.Println(s.Area())
fmt.Println(i)
}
The code above runs fast. The runtime optimizes heavily, but the mental model remains: assignments to interfaces construct these two-word structs. The compiler generates the code to fill them. You never import runtime to see them, but they exist in memory.
Trust the compiler to optimize the layout. Focus on the logic. gofmt handles the formatting. Argue about design, not indentation.
Inside the iface: itab and data
An iface contains two pointers. The first points to an itab. The second points to the data.
The itab is the interface table. It bridges the gap between the interface definition and the concrete type. The itab contains:
- A pointer to the interface type description (
inter). - A pointer to the concrete type description (
type). - An array of function pointers, one for each method in the interface.
When you call s.Area(), the runtime does not know the concrete type at compile time. It looks up the Area function pointer inside the itab and jumps to it. This is dynamic dispatch. The cost is a pointer indirection. The benefit is flexibility.
The data word holds the value. For large values, data is a pointer to the heap. For small values, the runtime may store the value directly in the data word. This optimization avoids heap allocation for small interfaces. A int or a small struct can live inside the interface variable itself.
This direct storage is why interfaces are often cheaper than you expect. Passing a small value as any does not always allocate memory. The runtime checks the size and pointer content. If it fits and is safe, it inlines the value.
Inside the eface: typ and data
An eface is simpler. It has no methods, so it needs no itab. It contains:
- A pointer to the type description (
typ). - A pointer to the data (
data).
The typ points to the runtime's internal type structure. This structure holds the name, size, and other metadata. The data word works the same way as in iface: it can be a pointer or a direct value.
When you convert an iface to an eface, the runtime extracts the concrete type from the itab and builds an eface. This is the assertI2I operation you saw in the flame graph. It's fast, but it's not free. In a tight loop, millions of these conversions add up.
package main
import "fmt"
// Logger accepts any value.
// The parameter is an eface.
func Log(v any) {
// Type switch inspects the typ pointer.
// The compiler generates efficient checks.
switch val := v.(type) {
case string:
fmt.Println("String:", val)
case int:
fmt.Println("Int:", val)
default:
// Fallback for unknown types.
// The default case prevents panics.
fmt.Println("Other:", val)
}
}
func main() {
// Each call builds an eface.
// Small values may be inlined.
Log("hello")
Log(42)
Log(struct{ Name string }{Name: "test"})
}
The type switch in Log works by comparing the typ pointer. The compiler can optimize this into a jump table for common cases. The any type is a tool for generic handling, but it sacrifices compile-time safety. Use it when you need to hold arbitrary data, like in a cache or a JSON parser.
Realistic dispatch and the itab cache
In a real application, interfaces enable polymorphism. You write a function that accepts an interface, and callers pass different types. The itab makes this work.
Consider a plugin system. Plugins register handlers that implement a common interface. The core loops over the handlers and calls them.
package main
import "fmt"
// Handler defines the plugin contract.
// Accept interfaces, return structs.
// Functions take Handler, not concrete types.
type Handler interface {
Handle(req string) string
}
// AuthHandler implements Handler.
// Concrete types are returned by constructors.
type AuthHandler struct{}
func (AuthHandler) Handle(req string) string {
return "Authenticated: " + req
}
// LogHandler implements Handler.
// Multiple types can satisfy the same interface.
type LogHandler struct{}
func (LogHandler) Handle(req string) string {
return "Logged: " + req
}
// Chain runs all handlers.
// Dynamic dispatch happens on each call.
func Chain(handlers []Handler, req string) string {
result := req
for _, h := range handlers {
// Runtime looks up Handle in h.itab.
// Jumps to the concrete method.
result = h.Handle(result)
}
return result
}
func main() {
// Slices of interfaces hold iface values.
// Each element has its own itab and data.
handlers := []Handler{
AuthHandler{},
LogHandler{},
}
output := Chain(handlers, "request")
fmt.Println(output)
}
The Chain function doesn't know about AuthHandler or LogHandler. It only knows about Handler. The itab inside each slice element points to the correct Handle method. The runtime jumps to the right code.
The itab is cached. The first time a type is assigned to an interface, the runtime builds the itab. Subsequent assignments reuse the cached itab. This amortizes the cost. The cache is keyed by the interface type and the concrete type.
Convention aside: receiver names should be short. (h AuthHandler) is better than (self AuthHandler). The community expects one or two letters matching the type. This keeps code scannable.
Pitfalls: nil interfaces and nil pointers
The internal structure explains the most common interface bug. A nil pointer inside an interface is not a nil interface.
An interface is nil only when both words are nil. The itab (or typ) must be nil, and the data must be nil. If you assign a nil pointer to an interface, the itab is valid. The data is nil. The interface is not nil.
package main
import "fmt"
type Stringer interface {
String() string
}
type MyString string
func (s MyString) String() string {
return string(s)
}
func main() {
var p *MyString = nil
// iface has valid itab, nil data.
// The interface is NOT nil.
var s Stringer = p
// This check fails.
// s != nil is true.
if s == nil {
fmt.Println("nil")
} else {
fmt.Println("not nil")
}
// Type assertion panics if you expect nil.
// The runtime checks the itab, not the data.
if _, ok := s.(string); ok {
fmt.Println("is string")
}
}
The compiler rejects invalid assignments. If you try to assign a type that doesn't implement the interface, you get an error like cannot use p (type *MyString) as Stringer in assignment: *MyString does not implement Stringer (method String has pointer receiver). The compiler checks the method set at compile time.
At runtime, type assertions can panic. If you assert a type that doesn't match, you get panic: interface conversion: interface is nil, not string. The panic message tells you the interface is nil, but the code might have a nil pointer inside a non-nil interface. Check the type, not just the value.
When you type assert and don't need the result, use _ to discard the value. _, ok := s.(Stringer) checks the type without binding the value. This is the idiomatic way to test for an interface.
Decision: when to use interfaces
Interfaces add indirection. They enable polymorphism but cost a pointer lookup. Generics add compile-time checks but can increase binary size. Concrete types are fast but rigid. Pick the right tool for the job.
Use any when you need to store arbitrary data without method constraints. Use it for caches, JSON parsing, or configuration maps where the type is unknown until runtime.
Use a defined interface when multiple types share a behavior and you want to write code against that behavior. Use it for dependencies, plugins, or abstractions over I/O.
Use a concrete type when you only ever work with one type. Interfaces add overhead. If the abstraction leaks, remove it. The simplest thing that works is usually the right thing.
Use generics with constraints when you need compile-time type safety and want to avoid interface allocation overhead. Generics let you write polymorphic code without itab lookups.
Where to go next
- The Biggest Interface Gotcha: Nil Interface vs Nil Pointer
- Go Generics Best Practices and Common Patterns
- How to Use Generics with Methods in Go (And the Limitations)
Interfaces are values. They carry their own type information. The itab is the bridge. A nil pointer inside an interface is not a nil interface. Check the type, not just the value.