Unveiling the Mechanisms: The Hidden World of Go Interface Values
Daniel Hayes
Full-Stack Engineer · Leapcell

Go's interface system is one of its most powerful and distinguishing features, enabling polymorphism, flexible code design, and robust type checking. Yet, beneath the clean syntax of interface{}
, io.Reader
, or fmt.Stringer
lies a sophisticated mechanism that Go employs to manage these dynamic types. Understanding this underlying machinery, specifically the iface
and eface
structures, is crucial for truly mastering Go and writing highly efficient code.
The Dual Nature of Interface Values
In Go, an interface value is not just a pointer to some data; it's a two-word structure. These two words typically hold:
- A pointer to type information (the "type descriptor" or "ittable").
- A pointer to the actual data (the "data word").
The specific names for these internal structures are iface
and eface
, and they serve slightly different purposes depending on whether the interface is empty (interface{}
) or non-empty (has methods).
1. iface
: For Non-Empty Interfaces
A non-empty interface is one that declares at least one method, such as io.Reader
or fmt.Stringer
.
type Reader interface { Read(p []byte) (n int, err error) }
When you assign a concrete value to an io.Reader
, Go internally represents it using the iface
structure. While not directly exposed in Go's source, its C-like representation looks conceptually like this:
type iface struct { tab *itab // itab (interface table) pointer data unsafe.Pointer // actual data pointer }
Let's break down these two components:
-
data
(unsafe.Pointer
): This pointer points to the actual value stored in the interface. This value always resides on the heap if it's a composite type (like a struct or slice) or if its address is taken. If it's a primitive type that fits within a single word (e.g.,int
,bool
,float64
), the value might be directly stored within thedata
word itself to avoid an extra indirection, depending on the compiler's optimizations and the Go version. However, for conceptual understanding, it's safer to assume it points to the value. -
tab
(*itab
): This is the more complex and crucial part. Anitab
(interface table) is a statically allocated, read-only structure that contains:- Concrete Type Information: A pointer to the
_type
information of the concrete type currently held by the interface (e.g.,*os.File
or*bytes.Buffer
for anio.Reader
). This includes the type's size, alignment, and other metadata. - Interface Type Information: A pointer to the
_type
information of the interface type itself (e.g.,io.Reader
). - Method Table: A list of function pointers (or method descriptors) for the methods required by the interface, implemented by the concrete type. For example, for an
io.Reader
holding an*os.File
, theitab
would contain a pointer toFile.Read
.
- Concrete Type Information: A pointer to the
Essentially, the itab
acts as a lookup table. When you call a method on an interface value (e.g., r.Read(...)
), Go uses the itab
's method table to find the correct implementation for that concrete type and then dispatches the call using the data
pointer as the receiver.
Example:
package main import ( "bytes" "fmt" "io" ) type MyReader struct { Count int } func (mr *MyReader) Read(p []byte) (n int, err error) { n = copy(p, []byte("Hello, Go!")) mr.Count += n return n, nil } func main() { var rdr io.Reader // rdr is an iface value conceptually (tab=nil, data=nil initially) buf := bytes.NewBufferString("Hello, Go Interfaces!") rdr = buf // rdr now holds (*bytes.Buffer, pointer to buf) // Internally, rdr's 'tab' pointer points to an itab for (*bytes.Buffer, io.Reader) // rdr's 'data' pointer points to the buf variable on the heap p := make([]byte, 5) n, err := rdr.Read(p) // Go uses the itab to find bytes.Buffer.Read and calls it fmt.Printf("Read %d bytes: %s, error: %v\n", n, string(p), err) myR := &MyReader{} rdr = myR // rdr now holds (*MyReader, pointer to myR) // Internally, rdr's 'tab' pointer points to an itab for (*MyReader, io.Reader) // rdr's 'data' pointer points to the myR variable on the heap p = make([]byte, 10) n, err = rdr.Read(p) // Go uses the new itab to find MyReader.Read and calls it fmt.Printf("Read %d bytes: %s, error: %v, MyReader count: %d\n", n, string(p), err, myR.Count) }
When rdr = buf
happens, Go determines if an itab
for (*bytes.Buffer, io.Reader)
already exists. If not, it generates one (or instructs the runtime to do so during compilation/linking) and stores its address in rdr
's tab
field. The address of buf
(or its underlying data) is stored in rdr
's data
field. The same process applies when rdr = myR
.
2. eface
: For Empty Interfaces (interface{}
)
An empty interface, interface{}
, means it declares no methods. This is Go's equivalent of a void*
or Object
in other languages, capable of holding any value.
type eface struct { _type *_type // concrete type information pointer data unsafe.Pointer // actual data pointer }
The eface
structure is simpler than iface
because there's no need for a method table.
-
data
(unsafe.Pointer
): Just like iniface
, this pointer points to the actual value. Similar optimizations might apply for small, primitive types. -
_type
(*_type
): This pointer points directly to the_type
information of the concrete value stored in the interface. Since there are no methods to dispatch, all that's needed is the type information itself for operations like type assertions (v.(T)
) or type switches (switch v.(type)
).
Example:
package main import ( "fmt" "reflect" ) type Person struct { Name string Age int } func describe(i interface{}) { // i is internally an eface value // Its '_type' pointer points to the type information of the concrete value it holds // Its 'data' pointer points to the actual value fmt.Printf("Value: %+v, Type: %T\n", i, i) // Type assertion 'ok' check uses the _type pointer if s, ok := i.(string); ok { fmt.Println("It's a string:", s) } // Type switch uses the _type pointer switch v := i.(type) { case int: fmt.Println("It's an int:", v) case Person: fmt.Println("It's a Person struct:", v.Name) default: fmt.Println("Unsupported type.") } // Reflect can access the underlying type and value via the eface's components // (though not directly to _type and data pointers from user code) val := reflect.ValueOf(i) typ := reflect.TypeOf(i) fmt.Printf("Reflect: Value Kind: %s, Type Name: %s\n", val.Kind(), typ.Name()) fmt.Println("---") } func main() { var emptyI interface{} // emptyI is an eface value (type=_type(nil), data=nil) emptyI = 42 describe(emptyI) // _type points to int's type, data points to 42 (likely inlined) emptyI = "hello world" describe(emptyI) // _type points to string's type, data points to string's content p := Person{Name: "Alice", Age: 30} emptyI = p describe(emptyI) // _type points to Person's type, data points to a copy of p on heap // (because p is a struct and passed by value to interface) ptrP := &Person{Name: "Bob", Age: 25} emptyI = ptrP describe(emptyI) // _type points to *Person's type, data points to ptrP }
When emptyI = 42
happens, the _type
field of emptyI
is set to point to the runtime type descriptor for int
, and the data
field contains the integer value 42
itself (as int
typically fits in a single word). When emptyI = p
, where p
is a Person
struct, the _type
field points to the Person
type descriptor, and the data
field points to a copy of p
which is allocated on the heap. This copy is made because struct
s are value types, and when assigned to an interface, a copy is boxed into the interface. For emptyI = ptrP
, _type
points to the *Person
type descriptor, and data
points directly to the ptrP
variable (which is already a pointer).
The Cost of Flexibility: Boxing and Indirection
Understanding iface
and eface
sheds light on the inherent costs associated with Go's interface system:
-
Memory Allocation (Boxing): When a concrete value is assigned to an interface, if it's not a small primitive type that can be inlined, it's typically "boxed" – meaning a copy of the value is allocated on the heap, and the interface's
data
pointer refers to this heap-allocated copy. This allocation incurs garbage collection overhead. This is particularly relevant for structs. If you assign astruct
directly to an interface, a copy is made. If you assign a pointer to astruct
, only the pointer itself is copied, and the original struct may remain on the stack or in its original heap location.type MyStruct struct { Data [1024]byte // Large struct } func main() { // Case 1: Assigning a struct directly (causes boxing) var i1 interface{} s1 := MyStruct{} i1 = s1 // s1 is copied to heap, i1.data points to the copy // Case 2: Assigning a pointer to a struct (no heap copy of struct data itself) var i2 interface{} s2 := &MyStruct{} i2 = s2 // s2 (the pointer) is copied to heap, i2.data points to the s2 pointer // which in turn points to the stack-allocated MyStruct (or heap if escaped) }
-
Indirection: Accessing the underlying data or calling methods through an interface requires at least one level of indirection through the
data
pointer. For method calls on non-empty interfaces, there's an additional indirection through theitab
to find the correct method. This overhead, while often negligible, can become noticeable in performance-critical loops or hot paths compared to direct method calls on concrete types. -
No Inlining: Because method calls on interfaces are dynamic dispatches determined at runtime via the
itab
, the Go compiler's inlining optimizations cannot be applied to these calls. This can slightly impact performance compared to static calls that can be inlined.
Type Assertions and Type Switches
The _type
and itab
pointers are what make Go's runtime type checks possible:
-
Type Assertions (
value.(Type)
):- For
value.(ConcreteType)
, Go checks ifvalue
's_type
(foreface
) ortab->concrete_type
(foriface
) matchesConcreteType
. - For
value.(InterfaceType)
, Go checks if the concrete type invalue
implementsInterfaceType
by looking up the appropriateitab
.
- For
-
Type Switches (
switch v.(type)
): This is essentially a series of type assertions, allowing for different code paths based on the concrete type held by the interface.
Comparison and Implications
Feature | Go Interfaces (iface /eface ) | C++ Virtual Functions (vtable ) | Java/C# Interfaces (Object model) |
---|---|---|---|
Structure | Two words (_type /itab + data ) | Pointer to object, first member often vptr to vtable | Object reference (pointer) |
Type Info | Explicit _type or itab pointer | Via vtable pointer (runtime type info usually separate) | Part of object header |
Boxing | Implicit for concrete values assigned (unless inlined/pointers) | Explicit for value types, implicit for reference types | Implicit for primitive types, reference types handled directly |
Method Call | iface.tab->methods[idx](iface.data) | object->vptr->methods[idx](object) | object.method() (JVM looks up method in class's method table) |
Null state | Both tab /_type and data are nil | Object pointer is nullptr | Object reference is null |
Overhead | Two words per interface value + lookup cost + potential allocation | Single pointer + lookup cost + object allocation | Single pointer + lookup cost + object allocation |
Type Safety | Strong compile-time & runtime checks | Strong compile-time & runtime checks | Strong compile-time & runtime checks |
Key Takeaways for Go Programmers:
-
Interfaces are not zero-cost abstractions, but their cost is generally low and highly optimized by the Go runtime.
-
Value type boxing: Be aware that assigning a struct value to an interface makes a copy on the heap. If performance or mutability of the original struct is critical, pass a pointer to the struct to the interface.
-
Empty interfaces (
interface{}
): While versatile, their lack of compile-time method checking and the need for runtime type assertions make them less type-safe and potentially slower than non-empty interfaces. Use them sparingly, primarily for generic data containers orfmt.Println
-like functions. -
Performance considerations: In extremely hot loops where every nanosecond counts, avoiding interfaces and using concrete types can offer slight performance benefits due to direct calls and potential inlining. However, for most applications, the performance overhead of interfaces is perfectly acceptable and outweighed by the benefits of cleaner, more flexible code.
-
Understanding
nil
: An interface value isnil
only if both its_type
/itab
pointer and itsdata
pointer arenil
. This explains why anil
pointer of a concrete type (e.g.,var p *SomeType = nil
) assigned to an interface is notnil
: the_type
oritab
pointer will point to*SomeType
's type information, while only thedata
pointer will benil
.package main import "fmt" type MyStruct struct{} func main() { var a *MyStruct // a is nil (*MyStruct, nil concrete pointer) fmt.Println("a is nil:", a == nil) // true var i interface{} // i is nil (neither type nor data are set) fmt.Println("i is nil:", i == nil) // true i = a // Assign nil pointer 'a' to interface 'i' // i's eface becomes: (_type:*MyStruct, data:nil) fmt.Println("i is nil after a = nil:", i == nil) // false! fmt.Println("i == a:", i == a) // true, because Go compares the underlying values/types // To check if the inner concrete value is nil: if i != nil { // Check if the interface itself is non-nil if _, ok := i.(*MyStruct); ok { // Assert it's a *MyStruct fmt.Println("Inner value of i is nil:", i.(*MyStruct) == nil) // true } } }
This
nil
behavior is a common source of bugs and confusion for Go newcomers, and understanding theiface
/eface
structure makes it crystal clear.
Conclusion
Go's interface system, underpinned by the iface
and eface
structures, is a marvel of elegant engineering that balances compile-time safety with runtime flexibility. By understanding how these two-word structures manage type descriptors and data pointers, Go developers can write more efficient, idiomatic, and bug-free code, truly harnessing the power of polymorphism in their applications. While there's a small performance cost for the dynamic dispatch and potential boxing, the benefits of cleaner APIs, easier refactoring, and broader code reusability far outweigh these considerations in most practical scenarios. The true mastery comes from discerning when to embrace interfaces for their flexibility and when to opt for concrete types for maximum performance.