What is the empty interface

The empty interface is a Go type that accepts any value, enabling generic storage and flexible function parameters.

The universal placeholder

You are writing a configuration loader that reads a JSON file and processes each field. Some fields are strings, some are numbers, some are nested objects. You want a single function to accept any value and route it to the correct handler. Go's type system refuses to let you write a function that accepts "anything" without a declared contract. You need a type that acts as a universal placeholder. That type is the empty interface.

The empty interface is written as interface{} in older code and any in Go 1.18 and later. Both spellings mean exactly the same thing. The compiler treats them as identical aliases. The name comes from the interface definition syntax: a list of required methods. An empty list means zero requirements. If a type needs to implement zero methods to qualify, every single type in Go automatically qualifies. Integers, strings, structs, slices, functions, and even other interfaces all satisfy it.

Think of it like a universal shipping container. The port crane does not care whether the container holds electronics, furniture, or raw materials. It only cares that the container fits the standard frame. The empty interface does the same thing. It accepts any Go value, wraps it in a standard runtime container, and passes that container around. You lose the original shape until you explicitly ask for it back.

The empty interface is a bridge between strict typing and dynamic flexibility. Cross it with intention.

How the empty interface works

Assigning a value to an empty interface does not copy the value into a new variable. It boxes it. Under the hood, an interface value is a two-word structure maintained by the runtime. The first word points to the type information, known as the type descriptor. The second word points to the actual data. When you assign 42 to a variable of type any, the runtime allocates space for an integer, stores 42 there, and updates the interface header to point to the int type descriptor and that memory address. When you reassign "hello", the runtime updates both pointers. The old integer becomes unreachable and the garbage collector cleans it up.

This two-word layout is why interfaces have a small memory cost. Every interface value carries type metadata alongside the data pointer. The runtime uses that metadata to verify type assertions and to dispatch method calls when the interface contains methods. The empty interface is special because it carries zero method requirements, so the runtime skips method dispatch entirely. It only tracks the type and data pointers.

Convention aside: the Go community prefers any in new code for readability, but interface{} remains standard in the standard library and older packages. Both compile to identical machine code. Pick one style per project and stick with it. The compiler does not care, but your teammates will.

Know the shape of the box before you pack it.

Assigning and unboxing values

Here is the simplest assignment pattern. Declare a variable with the empty interface type and assign values of completely different shapes.

package main

import "fmt"

func main() {
    // any is an alias for interface{}. They are interchangeable.
    var box any
    box = 42
    fmt.Println(box)
    // Reassign to a string. The old integer is garbage collected.
    box = "hello"
    fmt.Println(box)
    // Reassign to a slice. The interface value now holds a slice header.
    box = []int{1, 2, 3}
    fmt.Println(box)
}

Retrieving the value requires unboxing. You must tell the compiler what type you expect. Go provides two tools for this. A type assertion checks the box and extracts the value if the types match. A type switch tests multiple types in sequence without repeating the variable name.

package main

import "fmt"

func main() {
    var box any = "world"
    // Type assertion with the comma-ok idiom prevents panics.
    // It returns the value and a boolean indicating success.
    if str, ok := box.(string); ok {
        fmt.Println("Got string:", str)
    } else {
        fmt.Println("Not a string")
    }
    // Type switch handles multiple possible types cleanly.
    // The compiler verifies that each case uses a valid type.
    switch v := box.(type) {
    case string:
        fmt.Println("It is a string:", v)
    case int:
        fmt.Println("It is an int:", v)
    default:
        fmt.Println("Unknown type")
    }
}

The comma-ok idiom is the standard way to assert types safely. It returns the extracted value and a boolean. If the boolean is false, the value is the zero value of the requested type, and no panic occurs. The type switch is syntactic sugar that expands to a series of assertions, but it avoids repeating the variable name and guarantees exhaustive checking when you include a default case.

Unboxing is a contract. Verify it before you spend it.

The runtime representation

Go distinguishes between two internal interface representations. The empty interface uses eface (empty face). Interfaces with methods use iface. The eface structure contains only the type pointer and the data pointer. The iface structure adds a table of method pointers so the runtime can call methods dynamically. When you use any, the compiler generates eface operations. These are slightly faster because the runtime skips method table lookups.

The data pointer behavior changes based on size. Small values that fit in a machine word, like int, bool, or pointers, are often stored directly in the data pointer without heap allocation. Larger values, like structs or slices, are copied to the heap, and the data pointer references that heap allocation. This means assigning a large struct to any triggers a heap allocation and a memory copy. The garbage collector must track that allocation. If you pass large structs through any in a tight loop, you will see allocation pressure and GC pauses.

This memory behavior explains why any is not a free lunch. It trades compile-time guarantees for runtime flexibility, and that flexibility costs memory and CPU cycles. The runtime must allocate, copy, and track types that the compiler could have otherwise optimized away.

Measure the cost before you generalize.

Realistic usage patterns

Real code uses the empty interface when building generic containers or serialization pipelines. JSON parsers are the classic example. JSON objects map to map[string]any because values can be strings, numbers, booleans, arrays, or nested objects. You write a recursive walker that inspects each value, asserts its type, and processes it.

package main

import "fmt"

// WalkJSON recursively prints the structure of a parsed JSON object.
// It accepts any type because JSON values are inherently heterogeneous.
func WalkJSON(key string, val any, indent string) {
    // Type switch determines how to handle the current value.
    // Each branch extracts the concrete type safely.
    switch v := val.(type) {
    case string:
        fmt.Printf("%s%s: %q (string)\n", indent, key, v)
    case float64:
        // JSON numbers decode to float64 by default.
        // We print them without unnecessary decimal places.
        fmt.Printf("%s%s: %.0f (number)\n", indent, key, v)
    case bool:
        fmt.Printf("%s%s: %t (bool)\n", indent, key, v)
    case []any:
        fmt.Printf("%s%s: array with %d items\n", indent, key, len(v))
        // Recurse into each array element with an incremented indent.
        for i, item := range v {
            WalkJSON(fmt.Sprintf("[%d]", i), item, indent+"  ")
        }
    case map[string]any:
        fmt.Printf("%s%s: object with %d keys\n", indent, key, len(v))
        // Recurse into each nested object key.
        for k, nested := range v {
            WalkJSON(k, nested, indent+"  ")
        }
    default:
        fmt.Printf("%s%s: %T (unknown)\n", indent, key, val)
    }
}

Another common pattern is an event bus or message queue where different handlers process different payloads. You define a single channel of type chan any and send structs of varying shapes into it. The receiver uses a type switch to route the event to the correct handler. This works well for debugging, logging, or plugin systems where the exact message types are not known at compile time.

The empty interface shines when the data shape is truly unknown. Force it when the shape is fixed, and you will pay in complexity.

Pitfalls and runtime traps

The empty interface trades compile-time safety for flexibility. You can store anything, but you must verify the type before using it. A blind type assertion without the comma-ok idiom will panic at runtime if the types do not match. The runtime stops the program with interface conversion: any is nil, not string or interface conversion: any is int, not string. The compiler will not catch this mistake because the variable's static type is any.

Another trap is passing nil. A nil interface is different from an interface holding a nil pointer. If you assign a nil *int to an any variable, the interface is not nil. It contains a type descriptor for *int and a nil data pointer. Type assertions against it will succeed for *int but fail for string. The compiler rejects mismatched assertions with invalid type assertion: x.(string) (non-interface type int on left) if you try to assert on a concrete type, but runtime panics are the real danger with any.

Performance is the silent pitfall. Every assignment to any may allocate. Every type assertion checks the type descriptor at runtime. If you use any to bypass generics in a hot path, you will see measurable slowdowns. The compiler cannot inline or optimize through interface boundaries. It must generate dynamic dispatch code.

Convention aside: the community treats any as a last resort. If you find yourself asserting types in multiple places, you are likely fighting the type system. Generics or a specific interface usually provide a cleaner path. The encoding/json package uses any because JSON is inherently dynamic. Your business logic probably is not.

Trust the type system. Use any only when the data truly defies static typing.

When to reach for any

Use any when you are building a serialization layer like JSON or YAML where the input schema is unknown at compile time. Use any when you need a temporary holding cell for heterogeneous data that will be processed by a single type switch. Use generics when you want type safety across a collection of uniform types. Use a specific interface when you only care about a subset of methods and want to enforce behavior. Use concrete types when the data shape is fixed and known. The empty interface is a tool for heterogeneity, not a replacement for proper typing.

Pick the constraint that matches your data. Less flexibility means fewer runtime surprises.

Where to go next