Interfaces
What is an interface?
Section titled “What is an interface?”An interface is a named collection of method signatures. Any type that has all of those methods satisfies the interface — without saying so explicitly.
type Shape interface { Area() float64 Perimeter() float64}Any type with an Area() float64 method and a Perimeter() float64 method automatically satisfies Shape.
Implicit satisfaction — no implements
Section titled “Implicit satisfaction — no implements”This is the defining feature of Go interfaces. Compare:
| Language | How you declare satisfaction |
|---|---|
| Java / C# | class Circle implements Shape |
| Go | Just have the right methods |
The result is decoupled code. A type in package geometry can satisfy an interface in package renderer without either package knowing about the other. This makes Go interfaces composable across package boundaries without any import dependency on the interface definition.
Small interfaces
Section titled “Small interfaces”Go’s standard library is built on tiny, focused interfaces:
type Reader interface { Read(p []byte) (n int, err error) }type Writer interface { Write(p []byte) (n int, err error) }type Stringer interface { String() string }The Go proverb: “The bigger the interface, the weaker the abstraction.” A two-method interface is satisfied by far more types than a ten-method interface, making it dramatically more reusable.
The empty interface — any
Section titled “The empty interface — any”An interface with no methods is satisfied by every type. Since Go 1.18 it has the alias any:
var v any = 42v = "hello"v = []int{1, 2, 3}Use any sparingly — it surrenders type safety. The typical use cases are generic containers (before generics were added), JSON unmarshalling into unknown shapes, and variadic logging functions.
Accept interfaces, return structs
Section titled “Accept interfaces, return structs”The idiomatic Go guideline:
- Function parameters should be interfaces — accept the broadest type that covers what you actually call.
- Return values should be concrete structs — callers get the full API of the real type; they can narrow it themselves.
// Good: accepts an interface, returns a concrete typefunc NewReader(rd io.Reader) *bufio.Reader { ... }package main
import ( "fmt" "math")
type Shape interface { Area() float64 Perimeter() float64}
type Circle struct { Radius float64}
func (c Circle) Area() float64 { return math.Pi * c.Radius * c.Radius}
func (c Circle) Perimeter() float64 { return 2 * math.Pi * c.Radius}
type Rect struct { W, H float64}
func (r Rect) Area() float64 { return r.W * r.H }func (r Rect) Perimeter() float64 { return 2 * (r.W + r.H) }
func describe(s Shape) { fmt.Printf("area=%.2f perimeter=%.2f\n", s.Area(), s.Perimeter())}
// MyErr demonstrates the typed-nil-in-interface trap.type MyErr struct{ msg string }
func (e *MyErr) Error() string { return e.msg }
func mayFail(fail bool) error { var p *MyErr // nil, but typed *MyErr if fail { p = &MyErr{msg: "boom"} } return p // always a non-nil error interface, even when p is nil}
func main() { shapes := []Shape{ Circle{Radius: 5}, Rect{W: 4, H: 3}, } for _, s := range shapes { describe(s) }
err := mayFail(false) fmt.Println(err == nil) // false — the interface holds a typed nil *MyErr, not a true nil} Runtime Behavior
Section titled “Runtime Behavior”An interface value is two words: a pointer to an itab (type + method table) and a pointer to the data.
flowchart LR
subgraph Iface["interface value (2 words)"]
T["*itab"]
D["data"]
end
T --> ITAB["itab
type: Circle
methods: [Area, Perimeter]"]
D --> VAL["Circle{Radius: 5}"]
ITAB -.dynamic dispatch.-> CALL["itab.fun[0](data) → Circle.Area(data)"] You can check that a type satisfies an interface at compile time, without ever creating a value, using a blank assignment:
var _ Shape = (*Circle)(nil)This line only compiles if *Circle has every method Shape requires. It costs nothing at runtime and catches a missing or misspelled method the moment you write it, instead of at the first call site.
A nil pointer inside an interface is not nil
Section titled “A nil pointer inside an interface is not nil”An interface value is (type, data). It is only == nil when both words are nil. A function that declares a *T result but returns it through an error-typed return boxes a non-nil type descriptor around a nil data pointer — the interface itself is never nil, even though the pointer inside it is:
type MyErr struct{ msg string }
func (e *MyErr) Error() string { return e.msg }
func f(fail bool) error { var p *MyErr // nil, but typed *MyErr if fail { p = &MyErr{msg: "boom"} } return p // always returns a non-nil error interface, even when p is nil}Running this on Go 1.26.5:
<nil>false*main.MyErrfmt.Println(err) prints <nil> — printing dereferences enough to show the pointer is nil — but err == nil is false, and %T confirms the dynamic type is *main.MyErr. The interface variable carries a type descriptor the moment you assign a typed nil pointer to it, and that type descriptor alone is enough to make the comparison fail. The fix is to return a literal nil when there is no error, never a typed nil pointer through an interface-typed return.
Boxing and allocation
Section titled “Boxing and allocation”Storing a concrete value in an interface variable is called boxing. Whether it allocates on the heap depends on what the compiler can prove. Compiling the shapes example above with -gcflags=-m:
./main.go:11:6: can inline Circle.Area./main.go:16:20: devirtualizing s.Area to Circle./main.go:16:20: inlining call to Circle.Area./main.go:15:16: c does not escape./main.go:16:20: ~r0 escapes to heapWhen only one concrete type ever flows into s Shape in that function, the compiler devirtualizes the call — it knows statically that s.Area means Circle.Area, inlines it, and c itself never escapes. But storing two different concrete types into the same interface-typed slice defeats that:
./main.go:19:20: c escapes to heap./main.go:19:27: Rect{...} escapes to heapOnce a []Shape can hold either a Circle or a Rect, the compiler can no longer know which Area to call without going through the itab at runtime, so both values are boxed onto the heap. This is the real cost behind “interfaces aren’t free” — not the interface value itself (still two words on the stack), but the boxed data it points to when static devirtualization is not possible.
Interface comparability can panic
Section titled “Interface comparability can panic”Two interface values are comparable with == only if their dynamic types are comparable. The compiler cannot know the dynamic type ahead of time when both sides are any, so it always allows the ==, and defers the real check to runtime:
var a, b anya = []int{1, 2}b = []int{1, 2}fmt.Println(a == b)panic: runtime error: comparing uncomparable type []intSlices, maps, and functions are not comparable — storing one inside an interface does not change that. Comparing two interface values whose dynamic type is a struct containing a slice or map field panics the same way. This is a common source of a crash deep inside test-assertion helpers or map[any]any keys that looked fine until a slice-typed field showed up.
Define interfaces at the consumer, not the producer
Section titled “Define interfaces at the consumer, not the producer”A common anti-pattern imported from Java/C# codebases is a producer package that defines a large interface next to its one concrete implementation — type UserServiceInterface interface { ... } alongside type UserService struct{ ... }, with every method of UserService copied onto the interface “for testability.” This is interface pollution: the interface adds a layer of indirection without decoupling anything, since there is still only one implementation, and every time UserService grows a method the interface must grow too.
The idiomatic Go shape is the opposite: the package that uses a dependency declares the smallest interface it needs, right where it uses it. The producer package exports only a concrete type. This is exactly what io.Copy does — its signature is func Copy(dst Writer, src Reader) (int64, error), defined in io itself, not by every type that happens to be copyable. A caller passing *os.File, *bytes.Buffer, or net.Conn never needs those types to “implement” anything on purpose; they satisfy io.Reader/io.Writer because they already have the right methods.
Two symptoms mean an interface should move to the consumer or disappear entirely:
- The interface has exactly one implementation in the entire codebase, and no test ever swaps in a fake.
- The interface’s method set is just “every exported method of the one struct that implements it” — a mechanical copy, not a deliberately small contract.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| Implicit satisfaction | Decoupled, no import dependency | Harder to discover which type implements which interface |
| Small interface (1–2 methods) | Composable, easy to implement | May need several interfaces together |
any / empty interface | Accepts any type | Gives up type safety entirely |
| Dynamic dispatch | Flexibility | Real cost: an indirect call (~1–3 ns), lost inlining, and the value may escape to the heap when boxed |
Common Misconceptions
Section titled “Common Misconceptions”- Interfaces are free. An interface adds one layer of indirection and a dynamic-dispatch cost.
- A nil interface and an interface holding a nil pointer are the same thing. A nil interface is
(nil, nil); an interface holding a nil pointer is(*T, nil)— the two are not equal. - The more methods an interface has, the better. The Go proverb says the opposite: “the bigger the interface, the weaker the abstraction.”
- Implicit satisfaction means duck typing. Go checks interface satisfaction at compile time, not at runtime.
💡 Real-world example
io.Reader(one method):http.Request.Body,os.File,bytes.Buffer, andstrings.Readerall implement it — composable across the ecosystem with zero import dependency between them.Kubernetes’
runtime.Objectinterface (k8s.io/apimachinery/pkg/runtime) lets a single generic controller work with every Kubernetes API resource — decoupling through a small interface.
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated function returns an error (or any interface type), check for these before trusting it:
- Does every return path use a bare
nil, or does one path return a typed pointer variable that could itself be nil (var p *MyErr; ...; return p)? The second form makeserr != niltrue even on the “no error” path. - Is
==used to compare twoany-typed or interface-typed values? If either side’s dynamic type could be a slice, map, or func, this compiles fine and panics at runtime. - Does a package define an interface right next to its one and only implementation, with the interface’s methods mechanically mirroring the struct’s? That is interface pollution — check whether any other type or test actually needs the indirection.
- Does the code assert or check for extra capabilities (
Flusher,Hijacker,WriterTo) instead of assuming the concrete type? Skipping that check silently drops functionality wrapped types provide. - Is a function parameter typed as a concrete struct when only one or two methods are actually called on it? That is a missed opportunity for a small, consumer-defined interface — not a bug, but a design smell worth flagging.
🐛 Spot the bug
type MyErr struct{ msg string }
func (e *MyErr) Error() string { return e.msg }
func validate(input string) error { var problem *MyErr if input == "" { problem = &MyErr{msg: "empty input"} } return problem}
func main() { err := validate("ok") if err != nil { panic("validation failed: " + err.Error()) }}Show the answer
validate returns problem, a *MyErr variable, through an error-typed return — even on the success path where problem is nil. The returned error interface then holds (type: *MyErr, data: nil), which is never == nil, so err != nil is true even though input was valid. main then calls err.Error() on that nil *MyErr receiver to read msg, which dereferences a nil pointer — the real panic is panic: runtime error: invalid memory address or nil pointer dereference, not the message text passed to panic(). The fix is to return a literal nil on the success path: if input == "" { return &MyErr{msg: "empty input"} }; return nil.