Methods: Behavior, and the Receiver You Pick Every Time

Methods in Go attach to any named type, not just structs, and the one decision you make on every method — value receiver or pointer receiver — controls whether mutations stick. The mechanics, the consistency rule, and the auto-address convenience that makes it all feel seamless. Compiled and run against Go 1.26.5.

A method is a function with a receiver: an extra parameter, written before the method name, that says which type the method is attached to. That small syntactic move is all that separates a method from a plain function in Go. There is no class holding the method; it lives at package level like any function, with a receiver that binds it to a type. From that one design choice flow two things worth understanding well: methods can attach to any named type, not only structs, and every method forces one decision — value receiver or pointer receiver — that decides whether the method can change the thing it was called on.

Declaring a method

The receiver goes in parentheses before the method name. Here it is on a struct:

func (g Greeter) Hello() string {
	return "Hello, " + g.Name
}

(g Greeter) is the receiver: inside the method, g is the value the method was called on, of type Greeter. You call it with dot syntax, g.Hello(), exactly as you would expect. That is the entire syntax. What makes methods interesting is everything the receiver lets you do.

Methods attach to any named type

A method’s receiver does not have to be a struct. It can be any named type you define in the same package, including one whose underlying type is a primitive. This is how Go gives plain values behavior without wrapping them in an object. Define a named type over float64 and you can hang methods on it:

package main

import "fmt"

type Celsius float64

func (c Celsius) Fahrenheit() float64 {
	return float64(c)*9/5 + 32
}

func (c Celsius) String() string {
	return fmt.Sprintf("%.1f°C", float64(c))
}

func main() {
	t := Celsius(100)
	fmt.Printf("%s is %.1f°F\n", t, t.Fahrenheit())

	body := Celsius(37)
	fmt.Printf("%s is %.1f°F\n", body, body.Fahrenheit())
}
$ go run .
100.0°C is 212.0°F
37.0°C is 98.6°F

Celsius is just a float64 with a name, and now it has a Fahrenheit method and a String method. That String method is quietly special: any type with a String() string method satisfies the fmt.Stringer interface, and fmt calls it automatically when formatting the value — which is why printing t produces 100.0°C and not 100. The restriction is that you can only define methods on a type declared in your package; you cannot add a method to int or to a type from another package. If you want to extend such a type, you define a new named type over it, exactly as Celsius does over float64.

Value receivers versus pointer receivers

Now the decision you make on every method. A receiver is a parameter, and Go passes parameters by value — it copies them. So a value receiver gets a copy of the value the method was called on. Any change the method makes is a change to that copy, and it vanishes when the method returns. A pointer receiver, written *T, gets a pointer to the original, so changes it makes reach through to the real value and stick.

package main

import "fmt"

type Counter struct {
	n int
}

func (c Counter) IncValue() {
	c.n++
}

func (c *Counter) IncPointer() {
	c.n++
}

func main() {
	c := Counter{}

	c.IncValue()
	fmt.Printf("after IncValue():   n = %d\n", c.n)

	c.IncPointer()
	fmt.Printf("after IncPointer(): n = %d\n", c.n)
}
$ go run .
after IncValue():   n = 0
after IncPointer(): n = 1

There it is, in the output. IncValue has a value receiver, so it incremented a copy and the original c.n stayed 0. IncPointer has a pointer receiver, so it incremented the real c and the change stuck at 1. If a method needs to modify its receiver, it must use a pointer receiver. This is not a style preference; it is the difference between a method that works and one that silently does nothing, and it is one of the most common early mistakes in Go.

The auto-address convenience

Look again at c.IncPointer(). c is a Counter, a plain value, not a pointer, yet we called a pointer-receiver method on it directly without writing (&c).IncPointer(). Go did that for us. When you call a pointer-receiver method on an addressable value, the compiler automatically takes its address. Symmetrically, when you call a value-receiver method through a pointer, Go automatically dereferences. The two forms read the same at the call site.

package main

import "fmt"

type Counter struct {
	n int
}

func (c *Counter) Inc() { c.n++ }

func main() {
	c := Counter{}
	c.Inc()
	c.Inc()
	fmt.Printf("value call:   n = %d\n", c.n)

	p := &Counter{}
	p.Inc()
	fmt.Printf("pointer call: n = %d\n", p.n)
}
$ go run .
value call:   n = 2
pointer call: n = 1

Both c.Inc() (on a value) and p.Inc() (on a pointer) call the same pointer-receiver method and both mutate correctly, because Go inserts the & or * as needed. The word doing the work is addressable. A local variable is addressable, so c.Inc() is fine. But a value with no address — a map element, or a value returned straight from a function call — is not addressable, and calling a pointer-receiver method on it will not compile. In practice you hold values in variables before mutating them, so this rarely bites, but when the compiler complains that it “cannot take the address” of something, this is why.

The consistency rule

Given the choice, the strong convention is: do not mix receiver kinds on a single type. If any method on a type needs a pointer receiver — because it mutates, or because you want to avoid copying a large struct — then give all of that type’s methods pointer receivers, even the ones that could get away with a value receiver. The reason is uniformity for callers and, more concretely, interface satisfaction: the set of methods a value carries versus the set a pointer carries can differ, and mixing receivers is the way to end up with a type that satisfies an interface as a *T but not as a T. Picking one receiver kind per type sidesteps the whole class of confusion.

So when should that one kind be a pointer? Three cases cover almost everything:

  • The method mutates the receiver. A pointer is mandatory, as the counter showed.
  • The struct is large. A value receiver copies the whole struct on every call. For anything beyond a few small fields, a pointer avoids the copy.
  • Consistency. If some methods already need pointers, the rest take pointers too.

When none of those apply — a small, immutable value type like Celsius or Point — value receivers are clean and idiomatic. A useful default when you are unsure: reach for pointer receivers, since most types eventually grow a method that mutates, and consistency then pulls the rest along.

Method values and method expressions

Two smaller features round this out, both occasionally useful. A method value is a method accessed on a specific value but not called — you get a function bound to that receiver, which you can pass around and call later with no receiver argument. A method expression goes the other way: accessed on the type, it gives you an unbound function that takes the receiver as its explicit first argument.

package main

import "fmt"

type Greeter struct {
	Name string
}

func (g Greeter) Hello() string {
	return "Hello, " + g.Name
}

func main() {
	g := Greeter{Name: "Ada"}

	f := g.Hello
	fmt.Println(f())

	h := Greeter.Hello
	fmt.Println(h(Greeter{Name: "Grace"}))
}
$ go run .
Hello, Ada
Hello, Grace

f := g.Hello captured g as the receiver, so f() greets Ada with no argument. h := Greeter.Hello captured nothing, so h is a func(Greeter) string and you hand it the receiver explicitly. You will not write these every day, but method values are genuinely handy when you need to pass a bound method as a callback, and it is worth recognizing the syntax when you see it.

What to carry forward

A method is a function with a receiver, and the receiver can be any named type in your package, not just a struct. The decision you make on every method is value or pointer: a value receiver works on a copy so mutations do not stick, while a pointer receiver reaches the original. Follow the consistency rule and use one receiver kind per type, leaning toward pointers whenever a type mutates or grows large. Go’s automatic addressing makes the call sites read the same either way, so the receiver choice stays a matter of behavior rather than syntax.

Next: pointers — the address-of and dereference operators we have been leaning on, made explicit, and why Go has pointers but no pointer arithmetic.

Comments