How to Use Method Sets in Go

Use the GODEBUG environment variable or go.mod directives to control Go runtime behavior and manage backwards compatibility.

The missing method that actually exists

You write a struct with a Write method. You define an interface that requires Write. You pass the struct to a function expecting that interface. The compiler rejects the program. You stare at the code, verify the method exists, check the signature, and still get an error. The method is right there. The interface is right there. Go refuses to connect them.

This happens because Go does not match interfaces by looking at every method a type happens to have. It matches interfaces using a strict compile-time bookkeeping rule called the method set. The method set is not a runtime feature. It is not a slice of function pointers. It is a deterministic list the compiler builds for every type to decide which methods are visible to the type system.

Understanding method sets removes the guesswork from interface satisfaction. It explains why a pointer works where a value fails, why some methods are invisible to interfaces, and how to design APIs that compile on the first try.

What a method set really is

Go tracks two separate method sets for every named type. One set belongs to the type itself. The other belongs to the pointer to that type. The compiler uses these sets exclusively when checking whether a type implements an interface.

Think of it like a building access system. The type itself carries a keycard that only opens certain doors. The pointer to the type carries a master keycard that opens those same doors plus a few restricted ones. When the security system (the compiler) checks if you can enter a room (satisfy an interface), it only looks at the keycard you are actually holding. If you hand it the basic keycard, it only checks the basic doors. If you hand it the master keycard, it checks everything.

The rule is simple. A value receiver method belongs to both sets. A pointer receiver method belongs only to the pointer set. The compiler never promotes a pointer receiver method into the value set. This design keeps memory semantics predictable. It prevents the compiler from silently copying large structs or mutating data through value parameters.

The two sets Go tracks

Here is the smallest example that shows the split.

package main

import "fmt"

// Shape defines a basic drawing contract.
type Shape interface {
	Area() float64
}

// Circle holds geometric data.
type Circle struct {
	Radius float64
}

// Area calculates the area using a value receiver.
// Belongs to both Circle and *Circle method sets.
func (c Circle) Area() float64 {
	return 3.14159 * c.Radius * c.Radius
}

// Scale mutates the radius using a pointer receiver.
// Belongs only to the *Circle method set.
func (c *Circle) Scale(factor float64) {
	c.Radius *= factor
}

func main() {
	c := Circle{Radius: 5}
	// c satisfies Shape because Area has a value receiver.
	var s Shape = c
	fmt.Println(s.Area())
}

The compiler builds the method set for Circle first. It scans all methods with a Circle receiver. It finds Area. It adds Area to the Circle method set. It then builds the method set for *Circle. It copies everything from the Circle set, then adds methods with a *Circle receiver. It finds Scale. It adds Scale to the *Circle set.

When you assign c to var s Shape, the compiler checks the Circle method set. It sees Area. The interface is satisfied. When you try to assign c to a variable expecting an interface that requires Scale, the compiler checks the Circle set again. It does not find Scale. The assignment fails. If you pass &c instead, the compiler checks the *Circle set, finds both methods, and accepts it.

Method sets are resolved at compile time. The runtime never stores them. Interface values at runtime hold a type descriptor and a pointer to the data, but the method set itself is gone. The compiler already verified the contract and baked the method addresses into the interface table.

How interfaces actually match

Real code rarely uses single-method interfaces. You usually chain multiple methods, pass structs through layers, and rely on implicit satisfaction. The method set rule still applies exactly the same way.

Here is a realistic logging setup that trips up beginners.

package main

import "fmt"

// Logger defines the output contract.
type Logger interface {
	Log(msg string)
	Flush()
}

// FileLogger writes to a file descriptor.
type FileLogger struct {
	path string
}

// Log writes a message using a value receiver.
// Safe to call on copies.
func (f FileLogger) Log(msg string) {
	fmt.Println(f.path + ": " + msg)
}

// Flush syncs the buffer using a pointer receiver.
// Mutates internal state, requires addressability.
func (f *FileLogger) Flush() {
	fmt.Println(f.path + ": buffer synced")
}

func process(logger Logger) {
	logger.Log("starting")
	logger.Flush()
}

func main() {
	fl := FileLogger{path: "app.log"}
	// This line fails to compile.
	// process(fl)
	// This line works.
	process(&fl)
}

The Logger interface requires Log and Flush. The FileLogger type has both methods, but they use different receivers. The FileLogger method set only contains Log. The *FileLogger method set contains both. Passing fl to process fails because the compiler checks the value set and cannot find Flush. Passing &fl works because the pointer set contains everything the interface demands.

The compiler does not care that Log exists on the value. It only cares about the set attached to the exact type you pass. Implicit interface satisfaction is strict. It is not fuzzy matching. It is set inclusion.

When the compiler says no

You will see this error repeatedly until the rule becomes muscle memory.

cannot use fl (variable of type FileLogger) as Logger value in argument: FileLogger does not implement Logger (method Flush has pointer receiver)

The error message tells you exactly which method is missing from the value set and why. It does not say the method does not exist. It says the method has a pointer receiver, which means it lives in the pointer set, not the value set.

The fix is usually one of two paths. Pass a pointer to the function. Or change the method receiver to a value receiver if the method does not actually mutate state. If Flush only reads configuration and does not modify the struct, changing it to (f FileLogger) Flush() moves it into both sets. Now fl and &fl both satisfy Logger.

The reverse trap also exists. You can pass a pointer to a function expecting a value type, but the function receives a copy of the pointer, not a copy of the struct. Mutations inside the function affect the original data. This is usually fine, but it breaks the mental model of value semantics. The compiler allows it because pointers are cheap to copy. The data they point to is not copied.

Another common mistake involves embedded types. When you embed a struct, its methods are promoted to the outer struct. The promotion follows the same receiver rules. If the embedded type has a pointer receiver method, the outer type only gets it in its pointer method set. The outer value type does not inherit it. This keeps the rules consistent across composition.

Go does not use keywords like implements or extends. Interface satisfaction is implicit by design. The method set rule is the only mechanism that keeps implicit satisfaction predictable. Trust the compiler's set math. It never guesses.

Choosing receivers: the practical rules

Receiver choice dictates which method set a method belongs to. It also dictates memory behavior and API ergonomics. The community follows a few stable patterns.

Use a value receiver when the method only reads fields and does not mutate state. Value receivers make the method available to both the type and its pointer. They also allow the compiler to pass the receiver by value, which is safe for small structs and guarantees the original data cannot change unexpectedly.

Use a pointer receiver when the method modifies fields, allocates resources, or operates on a large struct that would be expensive to copy. Pointer receivers restrict the method to the pointer set. This is intentional. It forces callers to acknowledge that the underlying data might change.

Use a pointer receiver when the type contains uncopyable fields like mutexes, open file descriptors, or network connections. Copying a mutex corrupts its internal state. The pointer set restriction prevents accidental copies that would panic or deadlock.

Use a value receiver for pure functions that compute results from input parameters. Keep the receiver immutable. This makes the method testable and safe to call from concurrent code without external locks.

Use the type name's first letter as the receiver variable name. Write (c Circle) or (f *FileLogger). Do not use this or self. The convention keeps signatures short and matches the standard library.

Use interfaces to define behavior, not to store data. Accept interfaces at function boundaries. Return concrete structs. This keeps your API flexible while keeping implementation details hidden.

Method sets are a compile-time filter. They do not change at runtime. They do not add overhead. They exist to make implicit interface satisfaction deterministic. Pick receivers based on mutation and size. Let the method sets fall into place.

Where to go next