Interfaces Explained

An interface in Go is a type that lists a set of method signatures — it describes what a value can do, not what it is. Any concrete type whose methods match an interface’s method set automatically satisfies that interface, with no implements keyword and no explicit declaration required anywhere. Interfaces are the backbone of polymorphism in Go: they let you write functions that work with any type sharing common behavior, from your own custom shapes to standard-library staples like io.Reader and error. Understanding how interface values are represented internally — and where they trip up beginners — is essential to writing correct, idiomatic Go.

Overview / How Interfaces Work

In languages like Java or C#, a class must explicitly declare implements SomeInterface. Go takes a different, structural approach: satisfaction is implicit. If a type has methods with the exact names, parameters, and return types that an interface requires, it satisfies that interface automatically — even if the type’s author never heard of the interface. This decouples interface definitions from implementations: you can define a small interface in your own package and have it satisfied by types from a completely unrelated package, including ones you don’t control.

Under the hood, an interface value is not just a pointer to an object the way it might be in other languages. It is a two-word pair: a dynamic type and a dynamic value. When you assign a Rectangle to a variable of type Shape, the interface value stores both “this holds a Rectangle” and the actual Rectangle data (or a pointer to it, for larger types). Calling a method on the interface looks up the concrete type’s method in a small dispatch table (Go calls this an itable) and calls it — this is how a single line of code like s.Area() can run different code depending on what’s actually stored inside s at runtime. This lookup happens at runtime, but which methods a type provides, and whether it satisfies a given interface, is fully checked at compile time — you cannot compile code that passes a type missing a required method where that interface is expected.

Go also has the empty interface, written any (an alias for interface{} since Go 1.18). Because it requires zero methods, every single value in Go — ints, strings, structs, other interfaces — satisfies any. This makes it useful for functions like fmt.Println that must accept literally anything, but it throws away all compile-time type safety, so it should be used sparingly and narrowed back down with a type assertion or type switch as soon as possible.

Method sets and pointer receivers

Whether a type satisfies an interface depends on its method set. A value of type T has access to all methods declared with a value receiver (func (t T) ...). A value of type *T has access to both value-receiver and pointer-receiver methods. This means that if a type has even one pointer-receiver method, only *T — not plain T — satisfies interfaces requiring that method. This trips up beginners constantly: a struct with a pointer-receiver Save() method must be passed as &myStruct to satisfy an interface requiring Save(), not as myStruct.

Interfaces can also be composed by embedding other interfaces. The standard library’s io.ReadWriter, for example, is simply an interface that embeds both io.Reader and io.Writer, requiring a type to implement both Read and Write. This lets you build precise, minimal interfaces out of smaller reusable pieces — a hallmark of idiomatic Go design (“the bigger the interface, the weaker the abstraction”).

Syntax

An interface type lists method signatures inside an interface { ... } block:

type InterfaceName interface {
	MethodOne(param Type) ReturnType
	MethodTwo() error
}
Part Meaning
type InterfaceName interface Declares a new named interface type.
MethodOne(param Type) ReturnType A method signature — name, parameter types, and return type(s) — with no body.
(no implements) Any type with matching methods satisfies the interface automatically.
any The empty interface (zero methods); satisfied by every type.

Examples

Example 1: A Shape interface with two implementations

package main

import (
	"fmt"
	"math"
)

type Shape interface {
	Area() float64
	Perimeter() float64
}

type Rectangle struct {
	Width, Height float64
}

func (r Rectangle) Area() float64 {
	return r.Width * r.Height
}

func (r Rectangle) Perimeter() float64 {
	return 2 * (r.Width + r.Height)
}

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
}

func describe(s Shape) {
	fmt.Printf("Area: %.2f, Perimeter: %.2f\n", s.Area(), s.Perimeter())
}

func main() {
	shapes := []Shape{
		Rectangle{Width: 3, Height: 4},
		Circle{Radius: 5},
	}
	for _, s := range shapes {
		describe(s)
	}
}

Output:

Area: 12.00, Perimeter: 14.00
Area: 78.54, Perimeter: 31.42

Neither Rectangle nor Circle mentions Shape anywhere in its own code. Because both types happen to have Area() and Perimeter() methods with the right signatures, both satisfy Shape, and describe can operate on either one through a single, uniform interface variable.

Example 2: Satisfying fmt.Stringer

package main

import "fmt"

type Temperature float64

func (t Temperature) String() string {
	return fmt.Sprintf("%.1f\u00b0C", float64(t))
}

func main() {
	t := Temperature(23.456)
	fmt.Println(t)
	fmt.Printf("Current temperature: %v\n", t)
}

Output:

23.5°C
Current temperature: 23.5°C

The standard library defines fmt.Stringer as an interface with a single method, String() string. Any type that implements String() automatically controls how fmt.Println, %v, and friends print it — again, purely because the method exists, with no registration step.

Example 3: any and a type switch

package main

import "fmt"

func describe(v any) string {
	switch val := v.(type) {
	case int:
		return fmt.Sprintf("int: %d", val)
	case string:
		return fmt.Sprintf("string: %q", val)
	case bool:
		return fmt.Sprintf("bool: %t", val)
	default:
		return fmt.Sprintf("unknown type: %T", val)
	}
}

func main() {
	values := []any{42, "hello", true, 3.14}
	for _, v := range values {
		fmt.Println(describe(v))
	}
}

Output:

int: 42
string: "hello"
bool: true
unknown type: float64

A type switch (v.(type)) lets you branch on the dynamic type stored inside an interface value. Each case gives val the corresponding concrete type inside that branch, so val is a real int in the first case and a real string in the second — no further conversion needed. The 3.14 falls through to default because float64 isn’t one of the listed cases.

How It Works Step by Step

Consider the call describe(s) from Example 1 when s holds a Circle:

  • The compiler already verified, when the slice literal was built, that Circle has both Area() and Perimeter() methods, so assigning it into a []Shape is legal.
  • At runtime, the interface variable s stores two words: the dynamic type descriptor for Circle, and the actual Circle{Radius: 5} value.
  • Inside describe, the call s.Area() uses the dynamic type descriptor to find Circle‘s Area method and invokes it with the stored value as the receiver.
  • The same describe function, unchanged, does the equivalent lookup for Rectangle on the next loop iteration — this indirection through the interface’s internal dispatch table is what makes polymorphism work without inheritance.

Common Mistakes

Mistake 1: A nil pointer inside an interface is not a nil interface

This is the single most common interface gotcha in Go. Returning a typed nil pointer through an error-typed return value produces a non-nil interface, because the interface still carries a concrete type (*MyError), even though the pointer itself is nil.

package main

import "fmt"

type MyError struct{}

func (e *MyError) Error() string {
	return "something went wrong"
}

func mayFail(fail bool) error {
	var err *MyError
	if fail {
		err = &MyError{}
	}
	return err
}

func main() {
	err := mayFail(false)
	if err != nil {
		fmt.Println("got an error:", err)
	} else {
		fmt.Println("no error")
	}
}

Output:

got an error: something went wrong

Even though fail is false and err is never assigned, the function returns a non-nil *MyError-typed nil wrapped in an error interface, so err != nil is true. The fix is to only ever return the interface’s bare nil, never a nil value of a concrete pointer type:

package main

import "fmt"

type MyError struct{}

func (e *MyError) Error() string {
	return "something went wrong"
}

func mayFail(fail bool) error {
	if fail {
		return &MyError{}
	}
	return nil
}

func main() {
	err := mayFail(false)
	if err != nil {
		fmt.Println("got an error:", err)
	} else {
		fmt.Println("no error")
	}
}

Output:

no error

Mistake 2: A bare type assertion panics on mismatch

Writing i.(int) without checking works only when you are certain of the dynamic type. If the assertion is wrong, the program panics immediately instead of returning an error:

var i any = "hello"
n := i.(int)
fmt.Println(n)

This panics with interface conversion: interface {} is string, not int before n is ever printed. Always use the two-value “comma, ok” form when the type isn’t guaranteed:

package main

import "fmt"

func main() {
	var i any = "hello"
	n, ok := i.(int)
	if !ok {
		fmt.Println("i is not an int")
	} else {
		fmt.Println(n)
	}
}

Output:

i is not an int

Best Practices

  • Keep interfaces small — one or two methods is common in idiomatic Go; large interfaces are hard to satisfy and hard to mock.
  • Define interfaces at the point of use (the consumer’s package), not alongside the concrete type — Go’s implicit satisfaction makes this natural and keeps packages decoupled.
  • Prefer accepting interfaces as function parameters but returning concrete types, so callers get full information while your function stays flexible about its inputs.
  • Never return a nil concrete pointer through an interface-typed return value; return the interface’s bare nil instead.
  • Use the two-value form of a type assertion (v, ok := i.(T)) whenever the dynamic type isn’t guaranteed, to avoid a runtime panic.
  • Add a compile-time check like var _ Shape = Rectangle{} in a package to catch “forgot a method” mistakes immediately at build time instead of at first use.
  • Remember that a type with any pointer-receiver method only satisfies interfaces through *T, not T — pass a pointer if a value doesn’t seem to satisfy an interface you expect it to.

Practice Exercises

  • Define an interface Animal with a method Sound() string. Implement it for Dog and Cat structs, then write a function that takes a []Animal and prints each one’s sound.
  • Write a function sumAny(values []any) (float64, error) that uses a type switch to accept int and float64 values, summing them, and returns an error (not a panic) if any element is a different type.
  • Reproduce the nil-interface gotcha from Common Mistakes with your own error type, then fix it — verify with fmt.Println that your fixed version correctly reports “no error” when nothing went wrong.

Summary

  • Interfaces list method signatures; a type satisfies an interface implicitly, just by having matching methods — no implements keyword.
  • An interface value is internally a (dynamic type, dynamic value) pair, which is why calling an interface method dispatches to the right concrete implementation at runtime.
  • any is the empty interface, satisfied by every type, and should be narrowed back to a concrete type with a type assertion or type switch as soon as possible.
  • A type with pointer-receiver methods satisfies interfaces requiring those methods only through *T, not through a plain value of T.
  • Returning a nil concrete pointer through an interface-typed return produces a non-nil interface — always return a bare nil instead.
  • Use the comma-ok form of a type assertion to avoid runtime panics when the dynamic type isn’t certain.