How do interfaces work in Go

Go interfaces define method sets that types implicitly satisfy, enabling flexible and type-safe polymorphism.

How do interfaces work in Go

You write a function to parse a configuration file. It reads bytes, decodes them, and returns a struct. Two days later, you need to parse the same configuration format, but the data comes from an HTTP request body. A week later, you need to parse it from a test buffer in memory. Copy-pasting the function three times creates a maintenance nightmare. Creating a base class with a virtual read method adds inheritance complexity that Go deliberately avoids.

Go offers a third path. You define what the function needs to do with the data, not where the data comes from. The caller decides which type provides the data. This is structural typing. You describe the behavior, and any type that matches that behavior satisfies the contract. The compiler enforces the match. The runtime handles the dispatch. This mechanism is the interface.

Concept in plain words

An interface in Go is a list of method signatures. It describes what a type can do, not what the type is. If a type has all the methods listed in the interface, it satisfies that interface. There is no implements keyword. There is no declaration of intent. The satisfaction is implicit.

Think of an interface as a checklist of capabilities. A function asks for a type that can Read and Close. It does not care if the type is a file, a network connection, or a string buffer. As long as the type has Read and Close methods with the correct signatures, the function accepts it.

This approach decouples the code that uses a type from the code that defines it. You can add new types that satisfy existing interfaces without modifying the interface or the functions that use it. This follows the open-closed principle: software entities should be open for extension but closed for modification.

Interfaces are also cheap. Defining an interface costs nothing. Satisfying an interface costs nothing until you assign a concrete value to an interface variable. At that point, the compiler performs a check and the runtime creates a small wrapper.

Interfaces are contracts. The compiler enforces them. Runtime dispatch handles the rest.

Minimal example

The following example shows how types satisfy an interface implicitly. The Describer interface requires a Describe method. Both Cat and Dog have that method, so both satisfy the interface. The PrintDescription function accepts any Describer.

package main

import "fmt"

// Describer requires a type to have a Describe method.
type Describer interface {
	Describe() string
}

// Cat implements Describer implicitly.
type Cat struct {
	name string
}

// Describe returns a description of the cat.
func (c Cat) Describe() string {
	// Value receiver is fine because Describe does not modify the struct.
	return fmt.Sprintf("a cat named %s", c.name)
}

// Dog implements Describer implicitly.
type Dog struct {
	breed string
}

// Describe returns a description of the dog.
func (d Dog) Describe() string {
	// Value receiver is fine because Describe does not modify the struct.
	return fmt.Sprintf("a dog of breed %s", d.breed)
}

// PrintDescription accepts any Describer.
func PrintDescription(d Describer) {
	// Dynamic dispatch calls the correct Describe method based on the concrete type.
	fmt.Println(d.Describe())
}

func main() {
	// Pass a Cat. It has Describe, so it works.
	PrintDescription(Cat{name: "Whiskers"})

	// Pass a Dog. It also has Describe, so it works.
	PrintDescription(Dog{breed: "Golden Retriever"})
}

Walk through what happens

When you assign a concrete type to an interface variable, the compiler creates a pair. One half of the pair holds the concrete type information. The other half holds the data. This pair is the interface value.

If the concrete type is a pointer, the data half holds the pointer. If the concrete type is a value, the data half holds a copy of the value. The compiler checks at compile time that the type satisfies the interface. If you miss a method, the build fails with an error like cannot use Cat as type Describer in argument: Cat does not implement Describer (missing method Describe).

At runtime, calling a method through an interface uses dynamic dispatch. Go looks up the method in the type's method table and jumps to the implementation. This adds a small indirection cost compared to calling a method directly on a concrete type. The cost is usually negligible. The flexibility gained is significant.

The compiler can optimize some interface calls. If the compiler knows the concrete type at the call site, it may inline the method or eliminate the interface wrapper. This is called interface inlining. You do not need to worry about it. Write clear code with interfaces where appropriate. The compiler handles the optimization.

Interfaces are cheap. Method lookups are fast. Nil checks are tricky.

Realistic example

Interfaces shine in dependency injection. A function that processes data should not depend on how the data is stored or retrieved. It should depend on an abstraction. The following example shows a ProcessAndSave function that accepts a Saver interface. The function does not know if the saver writes to a file, a database, or a mock.

package main

import (
	"fmt"
)

// Saver defines how to persist data.
type Saver interface {
	Save(data []byte) error
}

// FileSaver implements Saver by writing to a file.
type FileSaver struct {
	path string
}

// Save writes data to the file path.
func (fs FileSaver) Save(data []byte) error {
	// In real code, use os.WriteFile.
	// This simulates the operation.
	fmt.Printf("Saving %d bytes to %s\n", len(data), fs.path)
	return nil
}

// MockSaver implements Saver for testing.
type MockSaver struct {
	savedData []byte
}

// Save records data in memory without side effects.
func (ms *MockSaver) Save(data []byte) error {
	// Pointer receiver allows the mock to record state.
	ms.savedData = data
	return nil
}

// ProcessAndSave accepts a Saver, decoupling logic from storage.
func ProcessAndSave(s Saver) error {
	data := []byte("important payload")
	// Use the interface to save. The function doesn't know if it's a file or mock.
	return s.Save(data)
}

func main() {
	// Production usage.
	if err := ProcessAndSave(FileSaver{path: "/tmp/data.bin"}); err != nil {
		fmt.Println("Error:", err)
	}

	// Test usage.
	var mock MockSaver
	if err := ProcessAndSave(&mock); err != nil {
		fmt.Println("Error:", err)
	}
	fmt.Printf("Mock captured: %s\n", mock.savedData)
}

The ProcessAndSave function is easy to test. You pass a MockSaver and verify the captured data. You do not need to write to disk or set up a database. The interface enables isolation.

Convention aside: receiver names should be short, usually one or two letters matching the type. Use (fs FileSaver) or (s *Saver). Do not use (this FileSaver) or (self *Saver). Go does not have a this keyword. Short names reduce noise and are idiomatic.

Accept interfaces, return structs. This is the most common Go style mantra. Functions should accept interfaces to be flexible. They should return structs so the caller gets all the methods, not just the interface subset. If a function returns an interface, the caller cannot access methods that are not in the interface. Returning a struct gives the caller full control.

Pitfalls and common errors

Interfaces introduce a few traps. The compiler catches many mistakes, but some issues only appear at runtime.

Pointer versus value receivers

The receiver type matters. If a type has a method with a pointer receiver, only the pointer type satisfies the interface. If a type has a method with a value receiver, both the value type and the pointer type satisfy the interface.

If you define an interface and try to pass a value when the method requires a pointer, the compiler rejects the code. The error looks like cannot use T as type I in argument: T does not implement I (method M has pointer receiver). The fix is to pass a pointer.

If you define a method with a value receiver but need to modify the struct, you must change the receiver to a pointer. The interface must match. If the interface requires a pointer receiver, all implementations must use pointer receivers.

Nil interface versus nil concrete value

An interface value is nil only if both the type and the value are nil. If you assign a nil pointer to an interface, the interface is not nil. It has a type. The value is nil, but the type is present.

This causes panics. If you check if s == nil and s is a nil pointer assigned to an interface, the check fails. The interface is not nil. Calling a method on s panics with runtime error: invalid memory address or nil pointer dereference.

To check for a nil concrete value, you must use a type assertion or a helper function. The compiler cannot catch this at compile time. It is a runtime issue.

var s Saver = (*FileSaver)(nil)

// This check fails. s is not nil. It has type *FileSaver and value nil.
if s == nil {
	fmt.Println("s is nil")
}

// This check works. It asserts the type and checks the pointer.
if _, ok := s.(*FileSaver); !ok || s == nil {
	// Logic to handle nil.
}

Convention aside: error handling is verbose by design. The pattern if err != nil { return err } is standard. It makes the unhappy path visible. Do not hide errors. Do not use defer to recover from panics in normal control flow. Return errors explicitly.

Empty interface

The empty interface interface{} (or any in modern Go) has no methods. Every type satisfies it. You can assign any value to an empty interface. This is useful for generic containers, JSON decoding, or logging.

Use any instead of interface{} in new code. The meaning is the same, but any is more readable. Do not use any as a crutch. If you find yourself using any everywhere, you are likely fighting the type system. Define specific interfaces or use generics.

Don't fight the type system. Wrap the value or change the design.

Decision: when to use this versus alternatives

Use an interface when you need to decouple a function from the specific type it operates on. Use a concrete type when the function only works with one specific implementation and adding flexibility would complicate the API. Use any when you need to hold a value of unknown type, such as in a cache or configuration map. Use a function type when the abstraction is a single operation without associated state. Use a struct with embedded fields when you want to reuse behavior through composition rather than abstraction.

Interfaces are not magic. They are a tool for abstraction. Overusing them creates indirection and confusion. Underusing them creates tight coupling and rigid code. Find the balance. Start with concrete types. Extract interfaces when you need flexibility. The compiler will guide you.

Trust the compiler. It catches missing methods before you run the code.

Where to go next