1. The basic model: interface, method set, and automatic & insertion
When you are just starting to write Go, it feels like methods are simply “functions with a dot”: for example, t.MarkDone(). And often the syntax really is friendly: the compiler can even “guess” that you meant to call a pointer method and insert &.
The problems start where Go becomes “grown-up”: when we begin putting values into interfaces and want “any type with method X” to satisfy a common contract. For example, “any object that can be completed” — a conditional Completer. And then suddenly: the call t.MarkDone() compiles, but the assignment var c Completer = t does not.
To understand this, you need to get familiar with the term method set: “the set of methods that a type exposes to the world.” And for T and *T, that set is different.
An interface is a set of methods
An interface in Go is a type that describes which methods a value must have so that it can be used in a particular place. For example, the built-in interface error requires the method Error() string — therefore, any type that has such a method can be an error.
For this lecture, one idea is enough: a type matches an interface if it has the required methods. But then a key question appears: which methods does a type “have” — with a receiver of T or *T? This is where the method set begins.
The method set rule for T and *T
A method set is the list of methods that the compiler considers to belong to a type in the context of interface checking and some other rules.
The most important rule:
| Value type | Which methods are included in the method set |
|---|---|
|
methods declared with receiver (t T) |
|
methods declared with receiver (t T) and (t *T) |
In short: *T “sees” more methods than T.
This rule often surprises beginners because in dot calls the compiler sometimes “fills in” & or *. But for interfaces, strict math applies: it matches or it does not — no guessing and no “well, it could take the address.”
Why t.M() may work, but the interface still “won’t accept” t
Go has a convenience feature: if you have an addressable variable t, and the type has a pointer method func (t *T) M(), then the call t.M() may compile. In that case the compiler effectively does something like: (&t).M().
But this automatic insertion works only for dot calls and only when the value is addressable. It does not mean that T gained this method in its method set. And interface compatibility is checked exactly by the method set — strictly.
You can visualize it schematically like this:
flowchart TD
A[We have a value t of type T] --> B{"Do we want to call t.M()?"}
B -->|Yes, t is addressable| C[The compiler may insert &t]
B -->|Yes, t is not addressable| D[Compilation error]
A --> E{Do we want to assign t to an interface?}
E --> F[Check the method set of T]
F -->|Missing the required method| G[Compilation error]
F -->|Has the required method| H[OK]
2. Practice and pitfalls
Task, MarkDone, and the Completer interface
Let’s continue a learning mini-project (a simple “task tracker”). We have a Task, and we want to be able to mark it as done.
Let’s define the type and the method:
package main
type Task struct {
ID int
Title string
Done bool
}
func (t *Task) MarkDone() {
t.Done = true
}
The logic is simple: the method changes state, so the receiver is *Task.
Now let’s create the “can be completed” interface and a function that works with that interface:
package main
import "fmt"
type Completer interface {
MarkDone()
}
func Complete(c Completer) {
c.MarkDone()
}
func main() {
t := Task{ID: 1, Title: "Learn method sets"}
// Complete(t) // does not compile
Complete(&t)
fmt.Println(t.Done) // true
}
The key point: Task does not satisfy the Completer interface, because its method set (for Task) contains only methods with receiver Task, while MarkDone is declared on *Task. But *Task does satisfy it, because the method set of *Task includes both methods of Task and methods of *Task.
And here a common thought appears: “But I can call t.MarkDone() directly!”. Yes, you can — because t is addressable, and the compiler will insert &t. But it will not do that for interface assignment.
Why Go does not insert & when assigning to an interface
Imagine Go allowed:
var c Completer = t // suppose this was allowed
Then the interface c would hold a copy of t (because “everything is passed by value”). And when we called c.MarkDone(), the method would work with a *Task — meaning the address of the copy inside the interface, not the original variable t.
And then we’d get the classic kind of chaos: “why doesn’t my Done flag change?” and “why does it sometimes work and sometimes not?” So Go chooses a strict and predictable model: if a method is potentially mutating (pointer receiver), then T is not considered to implement an interface that requires that method. This protects you from a huge number of “silent” bugs.
Addressability and method set are different things
It is easy to mix up two topics here:
First — the method set: which methods belong to T and *T, and what satisfies an interface.
Second — addressability (addressable): whether you can take the address of a value, i.e., apply &.
Sometimes an error looks like “the type has the method, but you can’t call it,” and a beginner thinks “it’s method set.” But in reality it is addressability.
For example, if we had a function that creates a task:
package main
type Task struct {
ID int
Done bool
}
func (t *Task) MarkDone() { t.Done = true }
func NewTask(id int) Task {
return Task{ID: id}
}
func main() {
// NewTask(1).MarkDone() // does not compile: the function result is not addressable
t := NewTask(1)
t.MarkDone() // ok: t is addressable
}
Here the reason is not interfaces. The reason is that NewTask(1) returns a “temporary value,” and you cannot take its address. But a pointer method requires an address.
Understanding the difference matters: the method set answers “does the type satisfy the interface,” while addressability answers “can we call a pointer method via dot at this particular place.”
A common bug: range gives you a copy, and a pointer method mutates the copy
We have a list of tasks:
package main
import "fmt"
type Task struct {
ID int
Done bool
}
func (t *Task) MarkDone() { t.Done = true }
func main() {
tasks := []Task{{ID: 1}, {ID: 2}}
for _, t := range tasks {
t.MarkDone() // compiles, but mutates a COPY of t
}
fmt.Println(tasks[0].Done, tasks[1].Done) // false false
}
Why? Because for _, t := range tasks creates a variable t that is a copy of the element. Yes, the variable t is addressable, so t.MarkDone() compiles (the compiler takes &t). But that is the address of the copy, not the address of the element in the array/slice.
The correct approach is to iterate by index and take the address of the real element:
package main
import "fmt"
type Task struct {
ID int
Done bool
}
func (t *Task) MarkDone() { t.Done = true }
func main() {
tasks := []Task{{ID: 1}, {ID: 2}}
for i := range tasks {
tasks[i].MarkDone()
}
fmt.Println(tasks[0].Done, tasks[1].Done) // true true
}
This is not “about method set” in the pure sense, but it is an excellent illustration of how pointer methods, addressability, and “copies” from range combine into one storyline.
A receiver selection strategy with method set in mind
When you design a type for your application, it’s better to choose receivers not “by vibe,” but by a simple rule: you want your type to be convenient and predictable both on its own and when used through interfaces.
If a method mutates the state of a struct (Done, Balance, Count, “internal cache” — anything), a pointer receiver is almost inevitable. And then you decide up front: in interfaces and functions, I will pass *Task, not Task.
If a method only reads data and returns a derived result (for example, IsDone() bool or Title()), then a value receiver is usually more convenient, because it will automatically be available on both Task and *Task (since the method set of *T includes methods of T).
Let’s show it with a small snippet:
package main
import "fmt"
type Task struct {
Title string
Done bool
}
func (t Task) IsDone() bool { return t.Done }
func main() {
t := Task{Title: "Read", Done: true}
fmt.Println(t.IsDone()) // true
fmt.Println((&t).IsDone()) // true
}
Here IsDone() is declared on Task, so it is included both in the method set of Task and in the method set of *Task. That makes the type more flexible.
3. Common mistakes
Mistake #1: confusing “dot call works” with “the type implements the interface”.
If t.MarkDone() compiles, that does not mean that Task satisfies the Completer interface. A dot call may include automatic insertion of &t, but interface compatibility is checked strictly by the method set. A reliable mental check: “method on *T — so I pass *T into the interface.”
Mistake #2: declaring a mutating method on a value receiver and expecting the original object to change.
When a method is declared as func (t Task) MarkDone(), it works with a copy. The code “looks nice,” but the state does not change. If the method mutates fields — you almost always need *Task.
Mistake #3: trying to fix everything with “let’s use *T everywhere”.
This solves some problems, but creates others: inconvenience with non-addressable temporary values, extra &, and sometimes a murkier API. A good style is to keep a consistent policy on the type: mutating methods on *T, read-only methods often on T, and understand how this affects the method set.
Mistake #4: mutating slice elements through for _, v := range plus a pointer method.
This bug is very sneaky: the code compiles, the method is called, but the changes “don’t stick.” The reason is that v is a copy. If you need to mutate elements — iterate by index (for i := range) and call the method on tasks[i].
Mistake #5: forgetting that an interface stores a value, not a “reference to a variable”.
An interface is not a “magic pointer.” If you want methods to mutate the original state, the interface must hold a pointer (*T), and the method set must allow it.
GO TO FULL VERSION