What Are Interfaces in Go and How Do They Work

Go interfaces define method sets that concrete types implement to enable polymorphism and decoupled code design.

The contract, not the type

You're writing a function to process data. It needs to read bytes. Sometimes the data comes from a file on disk. Sometimes it arrives over a network socket. Sometimes it's just a string sitting in memory. You could write three separate functions. You could pass a generic interface{} and type-switch inside. Or you could define a contract that says "I don't care where the data lives, as long as I can ask for the next chunk of bytes."

That contract is an interface.

An interface in Go is a list of method signatures. It describes behavior, not structure. If a type has the methods listed in the interface, it satisfies the interface. There is no implements keyword. There is no registration. The relationship is implicit. You define the interface, you build the struct, and the compiler checks if the pieces fit.

Think of a USB port. The port doesn't care if you plug in a mouse, a keyboard, or a hard drive. It only cares that the device speaks the USB protocol. The port defines the interface; the devices implement it. In Go, the interface is the port. The struct is the device. You don't tell the struct "I am a USB device." You just build the connector that fits.

How the compiler checks the fit

The compiler verifies satisfaction by comparing method sets. A method set includes all methods with a specific receiver type. If the interface requires Speak() string, the compiler looks for a method named Speak that takes no arguments and returns a string.

Here's the simplest interface: one method, one struct that matches it.

package main

import "fmt"

// Speaker defines the ability to make a sound.
type Speaker interface {
    Speak() string
}

// Dog has a Speak method that matches the signature.
// Receiver name d is short and matches the type.
type Dog struct {
    Name string
}

// Speak returns the sound a dog makes.
func (d Dog) Speak() string {
    return d.Name + " says woof"
}

// Cat also satisfies Speaker implicitly.
type Cat struct {
    Name string
}

// Speak returns the sound a cat makes.
func (c Cat) Speak() string {
    return c.Name + " says meow"
}

// Greet accepts any Speaker.
// The function doesn't know about Dog or Cat.
func Greet(s Speaker) {
    fmt.Println(s.Speak())
}

func main() {
    // Dog satisfies Speaker, so this compiles.
    Greet(Dog{Name: "Rex"})
    // Cat satisfies Speaker, so this also compiles.
    Greet(Cat{Name: "Whiskers"})
}

Dog and Cat never mention Speaker. They just have a Speak method. When you call Greet(Dog{...}), the compiler sees that Dog has the required method and allows the call. This implicit satisfaction is a core Go design choice. It lets packages define interfaces that other packages can satisfy without importing the interface definition. This reduces coupling.

The receiver name follows convention: one or two letters matching the type. Use (d Dog), not (this Dog) or (self Dog). This keeps code concise and consistent across the ecosystem.

What lives inside an interface

An interface value is not just a pointer. It's a pair of words: a type and a value. The type word holds the concrete type information. The value word holds a pointer to the actual data.

When you assign a Dog to a Speaker variable, the interface stores *Dog (or Dog depending on the assignment) and the data. At runtime, calling s.Speak() uses the type information to dispatch to the correct method. This is dynamic dispatch. It has a small cost compared to calling a method on a concrete type, but the cost is negligible for most applications.

The two-word structure explains a common confusion. An interface 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 holds a type with a nil value.

package main

import "fmt"

// DoWork returns an error interface.
func DoWork() error {
    var err *MyError = nil
    // err is a nil pointer, but it has a concrete type *MyError.
    return err
}

type MyError struct{}

func (e *MyError) Error() string {
    return "my error"
}

func main() {
    err := DoWork()
    // err is NOT nil here. It holds type *MyError with value nil.
    if err != nil {
        fmt.Println("Got error:", err)
    }
}

This code prints "Got error: ". The check err != nil passes because the interface has a type. This is a classic bug. Always return nil directly, not a typed nil pointer. The convention if err != nil { return err } works because errors are usually returned as nil or a concrete error value, never a typed nil pointer.

Real-world: io.Reader

The standard library uses interfaces everywhere. io.Reader is the most famous one. It defines a single method: Read(p []byte) (n int, err error). Any type that can provide bytes implements this interface. Files, network connections, strings, and buffers all satisfy io.Reader.

This lets you write functions that work with any data source. Here's a function that counts bytes from any reader.

package main

import (
    "fmt"
    "io"
    "strings"
)

// CountBytes reads from any Reader and counts total bytes.
func CountBytes(r io.Reader) (int, error) {
    buf := make([]byte, 1024)
    total := 0
    for {
        // Read fills the buffer. Returns 0, io.EOF when done.
        n, err := r.Read(buf)
        total += n
        if err != nil {
            // io.EOF is expected at the end. Other errors propagate.
            if err == io.EOF {
                return total, nil
            }
            return total, err
        }
    }
}

func main() {
    // strings.NewReader implements io.Reader.
    data := strings.NewReader("Hello, Go interfaces!")
    count, err := CountBytes(data)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Println("Bytes:", count)
}

strings.NewReader returns a type that implements io.Reader. CountBytes doesn't know that. It just calls Read. You could swap data for a file or a network connection without changing CountBytes. This is the power of interfaces.

The community mantra is "Accept interfaces, return structs." Functions should accept interfaces to allow flexibility. Functions should return structs so callers get the concrete type. Returning an interface exposes implementation details and makes it harder to add methods later without breaking callers. If a function returns io.Reader, the caller can't access methods specific to the underlying type. If it returns *os.File, the caller can call Close or Stat.

The nil interface trap

The nil interface trap is the most common runtime panic related to interfaces. It happens when you assign a nil pointer to an interface and then check if the interface is nil.

package main

import "fmt"

type Service interface {
    Do()
}

type Impl struct{}

func (i *Impl) Do() {}

func GetService() Service {
    var s *Impl = nil
    // s is a nil pointer of type *Impl.
    return s
}

func main() {
    svc := GetService()
    // svc is NOT nil. It holds type *Impl with value nil.
    if svc != nil {
        // This panics: runtime error: invalid memory address or nil pointer dereference.
        svc.Do()
    }
}

The compiler rejects this with cannot use s (variable of type *Impl) as Service value in return argument: *Impl does not implement Service (Do method has pointer receiver) if you try to return a value type where a pointer is needed. But the nil trap slips through because the type matches. The fix is to return nil directly, not a typed nil pointer.

func GetService() Service {
    // Return nil directly. The interface is truly nil.
    return nil
}

This pattern appears often with errors. If a function returns error, make sure it returns nil, not (*MyError)(nil). The compiler doesn't catch this. You have to be careful.

When to use interfaces

Interfaces are powerful, but they add indirection. Use them when the abstraction buys you something. Don't use them just because you can.

Use an interface when you need to abstract behavior across multiple types, like reading from different sources or writing to different outputs. Use a concrete struct when the function only works with one specific type and you want to keep the signature simple. Use a type switch when you need to handle distinct behaviors for unrelated types that don't share a common interface. Use generics with constraints when you need to enforce type compatibility at compile time without the overhead of dynamic dispatch. Use any only when you must store heterogeneous data in a collection, like a JSON parser or a configuration map.

Small interfaces are easier to test and reuse. An interface with one or two methods is flexible. An interface with ten methods is hard to satisfy and hard to mock. Keep interfaces focused on a single capability.

Interfaces define what you can do, not what you are.

Where to go next