When to Use Interfaces vs Concrete Types in Go

Use interfaces for flexibility and decoupling, and concrete types for performance and fixed implementations.

The concrete vs interface choice

You write a function that reads a configuration file. You pass it a *os.File. It works perfectly. Two weeks later, you need to test the function without touching the disk. You try passing a bytes.Buffer. The compiler rejects it. You realize the function is locked to one specific type. You could rewrite it to accept io.Reader, but then you wonder if you are adding unnecessary complexity. Go forces this choice early. Every function signature asks whether you want the speed and predictability of a concrete type, or the flexibility of an interface.

The answer depends on what your code actually needs to do. Concrete types are fast and explicit. Interfaces are flexible and decoupled. Go does not hide the trade-off. It makes you pick the boundary where your abstraction lives.

What an interface actually is

An interface in Go is not a contract you sign. It is a description of behavior. You define the methods you need, and any type that provides those methods satisfies the interface automatically. There is no implements keyword. There is no registration step. The compiler checks the method signatures and decides at compile time whether the fit is valid.

Think of a USB port. The port does not care whether the device is a keyboard, a flash drive, or a monitor. It only cares that the device speaks the USB protocol. The port is the interface. The keyboard is the concrete type. Your code plugs into the port and calls the methods it expects. The underlying device handles the rest.

Go interfaces are also values. They can be passed around, stored in slices, and returned from functions. An empty interface, written as any (or interface{} in older code), accepts literally any value. That power comes with a cost. When you erase the type information, the compiler loses the ability to optimize. You trade compile-time safety for runtime flexibility.

Interfaces describe behavior. Structs hold state.

A minimal example

Here is the simplest way to see the difference in action. The first function accepts an interface. The second accepts a concrete type. Both do roughly the same work, but their boundaries are completely different.

package main

import (
	"bytes"
	"fmt"
	"io"
)

// ReadAllBytes reads from any type that implements io.Reader.
// It works with files, network connections, or in-memory buffers.
func ReadAllBytes(r io.Reader) ([]byte, error) {
	// io.ReadAll handles the chunking and allocation internally.
	// The caller never needs to know the underlying data source.
	data, err := io.ReadAll(r)
	return data, err
}

// ReadFixedBytes only accepts a byte slice.
// The compiler knows the exact memory layout at compile time.
func ReadFixedBytes(b []byte) ([]byte, error) {
	// Slicing a slice is a pointer copy. No allocation happens.
	// The compiler can inline this entire function.
	return b[:], nil
}

func main() {
	// bytes.Buffer implements io.Reader implicitly.
	buf := bytes.NewBufferString("hello")
	data1, _ := ReadAllBytes(buf)
	fmt.Println(data1)

	// Passing a slice directly avoids interface boxing.
	data2, _ := ReadFixedBytes([]byte("world"))
	fmt.Println(data2)
}

The first function accepts io.Reader. bytes.Buffer, *os.File, and net.Conn all satisfy it without any extra work. The second function accepts []byte. It is faster because the compiler knows exactly what it is dealing with. It can inline the slice operation and skip the dynamic dispatch step entirely.

The compiler trades a few nanoseconds of dispatch for the freedom to swap implementations.

How the compiler handles it

When you pass a concrete value to an interface parameter, Go boxes it. The interface value itself is two machine words. The first word points to an interface table that describes the concrete type and its methods. The second word points to the actual data. If you pass a pointer, the data word holds the pointer. If you pass a value, Go copies the value onto the heap so the interface can hold a pointer to it.

This boxing is invisible in your code, but it happens at runtime. Every method call through an interface requires a lookup in the interface table. The compiler cannot inline the call because it does not know which concrete type will arrive. It generates a jump table instead.

Modern Go compilers are aggressive about devirtualization. If the compiler can prove that only one concrete type will ever satisfy an interface at a given call site, it replaces the dynamic dispatch with a direct call. The performance gap shrinks dramatically in real programs. You rarely see interface overhead dominate a benchmark unless you are in a tight loop processing millions of items.

The real cost is not CPU cycles. It is allocation pressure and cache locality. Boxing values onto the heap creates garbage collection work. Pointers scattered across heap allocations hurt CPU cache performance. Concrete types stay on the stack, keep data contiguous, and let the processor prefetch efficiently.

Design the interface at the place of use, not the place of implementation.

Real-world shape

In production code, interfaces live at the boundaries where your code talks to the outside world. Database drivers, HTTP clients, file systems, and message queues all expose interfaces. Your business logic depends on those interfaces, not on the specific libraries that implement them.

Here is how that looks in a typical service layer. The handler depends on a storage interface. The implementation lives in a separate package. Testing becomes trivial because you can swap the real database for an in-memory mock without changing the handler code.

package service

// UserStore defines the operations the service needs.
// It lives in the service package, not the database package.
type UserStore interface {
	GetByID(ctx context.Context, id string) (User, error)
	Save(ctx context.Context, u User) error
}

// UserService depends on the interface, not the implementation.
// This keeps the service decoupled from database drivers.
type UserService struct {
	store UserStore
}

// NewUserService wires the dependency.
// The caller decides which concrete store to inject.
func NewUserService(s UserStore) *UserService {
	return &UserService{store: s}
}

// GetUser delegates to the store.
// The service only knows about the interface methods.
func (s *UserService) GetUser(ctx context.Context, id string) (User, error) {
	return s.store.GetByID(ctx, id)
}

Notice the convention at work. The interface is defined in the package that uses it, not the package that implements it. This is the "accept interfaces, return structs" rule in practice. The service accepts a UserStore interface. The database package returns a concrete PostgresStore struct. The boundary stays clean. The implementation package does not need to import the service package. Circular dependencies disappear.

Context always goes first. The receiver name is short. Errors are returned explicitly. These small conventions keep the codebase readable across teams.

An empty interface is a type eraser. Use it only when you truly mean any type.

Where things break

Interfaces introduce a new class of mistakes. The most common is passing a type that does not satisfy the interface. The compiler catches this immediately, but the error message can be dense if the type is large.

If you pass a User struct to a function expecting io.Reader, the compiler rejects it with cannot use user (variable of struct type User) as io.Reader value in argument: User does not implement io.Reader (missing Read method). The fix is usually straightforward. Add the missing method, or change the function signature to accept the concrete type.

Another trap is over-engineering. Beginners sometimes create an interface for every struct, even when only one implementation will ever exist. This adds indirection without buying flexibility. It makes the code harder to follow and prevents the compiler from inlining. You pay the boxing cost for zero gain.

Value vs pointer receivers also cause subtle bugs. If an interface expects a pointer receiver method, you must pass a pointer to satisfy it. Passing a value will fail compilation. The reverse is not true. A type with value receivers satisfies both value and pointer interface parameters. This asymmetry trips up developers coming from languages where & or * is explicit in the signature.

Goroutine leaks happen when a background task waits on a channel that never closes. Interfaces do not cause leaks, but they hide them. If you wrap a channel or a database connection in an interface, the cancellation path becomes harder to trace. Always keep your cancellation tokens visible.

The worst goroutine bug is the one that never logs.

When to pick which

Use a concrete type when the implementation is fixed and performance matters. Use a concrete type when you want the compiler to inline methods and keep data on the stack. Use a concrete type when testing does not require swapping the underlying behavior.

Use an interface when multiple implementations exist or will exist. Use an interface when you need to decouple your code from external libraries. Use an interface when you are building a public API that third parties will extend. Use an interface when testing requires a mock or stub.

Use a generic constraint when you need compile-time type safety without boxing. Use a generic constraint when the algorithm works across many types but does not need dynamic dispatch. Use a generic constraint when you want to preserve value semantics and avoid heap allocation.

Use plain sequential code when you do not need abstraction. Use the simplest signature that satisfies the current requirement. Add indirection only when the code forces you to.

Concrete types are fast. Interfaces are flexible. Pick the constraint your code actually needs.

Where to go next