Structs, methods & receivers: behavior attached to data
A struct is a value with a known layout (Lesson 02); a method is a function with one privileged argument. The whole lesson turns on a single decision Go forces you to make in the open — does this method take its receiver by value or by pointer? In most languages that question is hidden; in Go it is in the signature, and the right answer falls straight out of "who owns this data, and how does it move?" Get it right and you have data with behavior, no copy surprises, and no class hierarchy. Get it wrong and you write to a copy that vanishes — the most common beginner bug in the language.
this is shared or copied. Go makes you write the choice — (c Counter) or (c *Counter) — so the reader can see, at the method's first line, whether calling it can mutate your value and what the call copies. Complexity must be earned: rather than a feature that papers over the value/pointer distinction, Go keeps the distinction visible and asks one small question of you. The cost is a decision per method; the payoff is that the next engineer never has to guess.New capability: define methods on your own types, choose value vs pointer receivers from first principles, predict exactly what a call copies and whether it can mutate, and explain method sets — which sets up interface satisfaction in Lesson 05.
1 · A method is a function with one privileged argument
Start from the engineering problem. You have data — a point, a counter, a buffer — and operations that belong with it. In a class-based language you'd reach for a class: bundle the fields and the methods, then (almost inevitably) a base class, an override, a hierarchy. Lesson 00 named the cost of that hierarchy: coupling that turns "change here" into "break there" three levels away. Go's bet is that you can attach behavior to data without the class machinery, because the only thing you actually needed was a function that knows which value it operates on.
So a Go method is exactly that: an ordinary function with one extra parameter, the receiver, written before the name. There is no class, no this keyword you didn't declare — you name the receiver yourself, like any parameter.
type Point struct {
X, Y float64
}
// Method: the receiver (p Point) is the privileged first argument.
func (p Point) DistanceTo(q Point) float64 {
dx, dy := p.X - q.X, p.Y - q.Y
return math.Sqrt(dx*dx + dy*dy)
}
a := Point{0, 0}
b := Point{3, 4}
d := a.DistanceTo(b) // d == 5; identical to DistanceTo(a, b) conceptually
Two facts make this more general than it looks. First, you can define a method on any named type you declare, not just structs — a named slice, a named integer, a function type:
type Celsius float64
type Fahrenheit float64
func (c Celsius) ToFahrenheit() Fahrenheit { return Fahrenheit(c*9/5 + 32) }
type IntSet []int
func (s IntSet) Contains(n int) bool { /* ... */ return false }
The one rule: the type must be defined in your package. You cannot bolt a method onto int or onto a type from another package — that would let any package secretly change a type's behavior, exactly the action-at-a-distance Go is built to prevent. Second, because a method is just a function, calling a.DistanceTo(b) is the same machine work as a plain function call: the receiver is passed like any argument. There is no vtable lookup, no dynamic dispatch — the compiler knows the concrete type at compile time and emits a direct call. (Dynamic dispatch enters in Lesson 05, and only through interfaces.)
2 · The one real decision: value receiver or pointer receiver
Here is the choice Go puts in your hands and most languages hide. Recall Lesson 02's iron rule: passing a value copies it. A value receiver is no exception — (p Point) means the method gets a fresh copy of the Point, and any change it makes dies when the method returns. A pointer receiver — (p *Point) — means the method gets the address of your Point, so it can change the original.
type Counter struct{ n int }
func (c Counter) IncValue() { c.n++ } // mutates a COPY — caller sees nothing
func (c *Counter) IncPtr() { c.n++ } // mutates the ORIGINAL through its address
var c Counter
c.IncValue(); c.IncValue() // c.n is still 0 — the classic beginner bug
c.IncPtr(); c.IncPtr() // c.n is now 2
(Go lets you write c.IncPtr() on an addressable value c — it silently takes (&c) for you. The reverse also works: a pointer can call a value-receiver method. The sugar is convenient but the semantics are set by the receiver type, not the call site, so read the receiver to know what happens.)
Three forces decide the answer, in priority order:
Point is 16 bytes — copying is free. A struct with a dozen fields, or one embedding a 1 KB array, copies all of it every call; a pointer receiver copies one 8-byte word instead. Past a few words, the pointer is cheaper.sync.Mutex, a sync.WaitGroup, or anything that breaks when duplicated, copying it is a bug. go vet flags it. Such types take pointer receivers by rule — copying the lock would protect nothing.Notice what is not on this list: "small and read-only." For a small value the method only reads, a value receiver is the honest default — it advertises in the signature "calling this cannot change your value," which is real information for the reader, and the copy is a few bytes the compiler often keeps in registers and never spills to memory. The decision tree:
| Situation | Receiver | Why — what the machine/reader gets |
|---|---|---|
| Method modifies the receiver's fields | *T | Only a pointer reaches the original; a value receiver edits a discarded copy. |
| Struct is large (many fields / big array) | *T | Copy cost is one 8-byte word, not the whole struct, per call. |
| Contains a Mutex / WaitGroup / must-not-copy | *T | Copying the lock duplicates and de-syncs it — go vet errors. |
| Small, immutable-feeling value (Point, time.Time) | T | Signature promises "no mutation"; copy is a few bytes, often register-only. |
| Type is a basic kind (Celsius, a small enum) | T | Cheap to copy and naturally treated as a value, like an int. |
This is the same trade-off literacy the C++ track builds from the machine up — "what does this copy cost?" — but Go forces the answer into the one place the reader will look: the receiver. The cost claim is concrete: a value receiver copies sizeof(T) bytes on entry; a pointer receiver copies 8 bytes (a 64-bit address) and adds one indirection per field access. Below roughly 3–4 machine words the copy usually wins or ties; above it the pointer wins. When in doubt and the type is mutated or might be, default to pointer — but make it a decision, not a reflex.
3 · Be consistent, and meet the pointer-receiver method-set rule
Two rules turn the per-method choice into a per-type discipline.
Consistency. If any method on a type needs a pointer receiver, give all its methods pointer receivers — even the read-only ones. Mixing them is legal but it muddies the type: the reader can no longer tell at a glance "this type is used by value" or "by pointer," and the method set (below) splits in a way that surprises people. The standard library follows this: bytes.Buffer, sync.Mutex, strings.Builder are pointer-receiver types through and through; time.Time is a value-receiver type through and through.
The method-set rule — the piece that everything in Lesson 05 hangs on. A type's method set is the set of methods you can call through a value of that type. The rule:
Read it carefully: the method set of *T includes the value-receiver methods, but the method set of T does not include the pointer-receiver methods. The reason is addressability. To call a pointer-receiver method, Go needs the value's address. A *T already is an address. A plain T value sometimes isn't addressable — a map element, a value returned from a function, a value in an interface — and you cannot take the address of something that has no stable home. So Go cannot guarantee a (*T) method is callable on every T, and therefore excludes it from T's method set entirely.
For direct calls on an addressable variable this is invisible — Go auto-takes the address (§2). It bites the moment an interface is involved:
type Stringer interface{ String() string }
type Big struct{ /* ... */ }
func (b *Big) String() string { return "big" } // POINTER receiver
var b Big
var s Stringer = b // COMPILE ERROR: Big does not implement Stringer
// (String has pointer receiver)
var s Stringer = &b // OK: *Big's method set includes String
The fix is almost always "use a pointer," and the error message tells you so. But you have to know the method-set rule to read the message — which is exactly why this lesson lands before interfaces. The mechanism, not a vibe: the value b stored in an interface has no addressable home, so the pointer method literally cannot be invoked, so it isn't in Big's method set.
IncValue), or a pointer-receiver type that "doesn't implement" an interface when you assign the value instead of &value (above). Both are the same root cause surfacing in two places: a value receiver edits a copy, and a value's method set excludes pointer methods. Internalize "pointer receiver ⇒ use a pointer, including when satisfying an interface" and both vanish.4 · What it costs, and why this is the hinge into interfaces
Tally the bill in the series' terms. Go bought you "data with behavior" for almost nothing at runtime: a method call is a direct, statically-dispatched function call — no vtable, no boxing, identical cost to calling a free function with one more argument. There is no per-object overhead either; a struct with methods has the same layout and size as the same struct without them, because the methods aren't stored in the value (Lesson 02's layout still holds exactly). What you pay is one design decision per method — value or pointer — and the cognitive load of the method-set rule. Go judged that price worth it: the alternative (a hidden, uniform "everything is a reference" or "everything is a copy") removes the decision but also removes the reader's ability to see, from the signature, whether a call can mutate their value or how much it copies. Half (1) of the bargain in miniature — keep the distinction visible because the reader pays more for hiding it than for seeing it.
And note the escape-analysis tie to Lesson 03: a pointer receiver does not force a heap allocation. If the compiler can prove the receiver's lifetime stays within the call (it doesn't escape), the value lives on the stack and the *T is just a stack address — zero GC cost. Choosing a pointer receiver is a semantic choice about mutation and copy cost, not an allocation choice; the compiler still decides where the data lives.
The method set is the load-bearing concept here, because Lesson 05 is built directly on top of it. An interface is "a set of method signatures"; a type satisfies an interface when its method set includes all of them — implicitly, with no implements keyword. Everything you just learned about which methods are in T's set versus *T's set is precisely what decides whether T or only *T satisfies a given interface. You cannot reason about interface satisfaction without the method-set rule, which is why we earned it first.
Checkpoint exercise
type Account struct{ balance int } with two methods: Balance() int (returns the balance) and Deposit(amount int) (adds to it). (1) Write Deposit first with a value receiver, call it twice with 100, then print Balance() — watch it stay 0, and explain why using §2. (2) Fix it with a pointer receiver and confirm 200. (3) Now apply the consistency rule from §3: should Balance() be a value or pointer receiver, and why? (4) Declare type Stringer interface{ String() string }, add a String() method to Account with a pointer receiver, then try var s Stringer = someAccount and var s Stringer = &someAccount — predict which compiles before you run it, and name the method-set rule (§3) that explains the result. You have now reproduced both halves of the one bug everyone hits.Where this points next
You can now attach behavior to data and you know precisely what lives in a type's method set and why. Lesson 05 spends that knowledge: an interface names a behavior — "anything with a Read([]byte) (int, error) method" — and a type satisfies it implicitly, simply by having those methods in its method set. The value/pointer-receiver choice you just learned is exactly what decides whether T or only *T qualifies, and the pointer-receiver method-set rule from §3 is the mechanism behind the most common interface compile error. Interfaces are how Go answers question #4 — "depend on a behavior, not a concretion" — and they are impossible to reason about without the method set you just built.
this, and no hierarchy — and a method call is a plain, statically-dispatched call with zero per-object overhead. The lesson's whole weight is on one decision Go refuses to hide: value receiver or pointer receiver. Choose a pointer when the method must mutate the receiver, when the struct is larger than a few machine words (copying one 8-byte address beats copying the whole value), or when it holds a lock that must not be copied; choose a value for small, read-only types where the signature's promise "this cannot change your value" is worth more than the few bytes copied. Be consistent across a type, and remember the method-set rule — *T can call both value and pointer methods, but T can call only value methods, because a plain value isn't always addressable. That single rule is the source of both the "my change disappeared" bug and the "doesn't implement the interface" bug, and it is the exact hinge into Lesson 05. The transferable habit: make the cost of a copy and the possibility of mutation visible in the signature, so the next reader never has to guess.Interview prompts
- What is a Go method, mechanically, and how does it differ from a class method? (§1 — an ordinary function with one extra named parameter, the receiver; no class, no implicit
this, defined on any named type in your own package; the call is statically dispatched with no vtable.) - You call a method twice and the receiver's field doesn't change. What happened? (§2 — it has a value receiver, so each call mutated a fresh copy that was discarded on return; you need a pointer receiver to mutate the original.)
- List the three reasons to pick a pointer receiver, in priority order. (§2 — (1) the method must mutate the receiver; (2) the struct is large, so copying one 8-byte address beats copying the whole value each call; (3) it contains a Mutex/WaitGroup or otherwise must not be copied —
go vetflags the copy.) - State the method-set rule and the reason behind it. (§3 —
*T's set is value-receiver methods ∪ pointer-receiver methods;T's set is only the value-receiver methods, because a plainTisn't always addressable — e.g. a map element or a value in an interface — and a pointer method needs an address.) - Why does
var s Stringer = bfail whenString()has a pointer receiver, butvar s Stringer = &bcompiles? (§3 — the valuebin an interface has no addressable home, soString()isn't inBig's method set;*Big's method set does include it.) - Does choosing a pointer receiver force a heap allocation? (§4 — no; escape analysis (Lesson 03) still decides where the data lives. If the receiver doesn't escape the call it stays on the stack and the pointer is a stack address with zero GC cost; the receiver choice is about mutation and copy cost, not allocation.)
- Why does Go make you write the receiver kind instead of deciding it for you? (§4 — half (1) of the bargain: the value/pointer distinction is real information the reader needs (can this call mutate my value? what does it copy?), so Go keeps it visible in the signature rather than hiding it behind a uniform reference or copy semantics.)