JavaRush /Courses /Go SELF /Value vs pointer receiver — selection criteria

Value vs pointer receiver — selection criteria

Go SELF
Level 22, Lesson 1
Available

1. Why you need to choose a receiver at all

When you first see func (t Task) Done() bool and func (t *Task) MarkDone(), it is easy to think: “Well... an asterisk is an asterisk, what’s the difference?” Unfortunately, there is a difference — and it is practical. A receiver answers two questions: does the method work with a copy or the original, and can the method change the object’s state? Plus there are nuances around call convenience and performance, which is why “just put * everywhere” is not an ideal plan either.

So we do not discuss this in the abstract, we will continue our learning mini domain of a “task tracker”: we will have tasks (Task) with a title, a completion flag, and a list of tags.

The most important rule: in Go, everything is passed by value

This phrase sounds like zen until it becomes your reflex. In Go, when you call a function or method, the parameter is copied. If the receiver is Task, the entire Task is copied. If the receiver is *Task, what gets copied is... the pointer, i.e., a small address value. But that address points to the same object, so you can mutate the original fields through it.

Let’s build a simple model:

package main

import "fmt"

type Task struct {
	Title string
	Done  bool
}

func (t Task) MarkDoneCopy() {
	t.Done = true // modify the copy
}

func (t *Task) MarkDone() {
	t.Done = true // modify the original via the pointer
}

func main() {
	task := Task{Title: "Read Go spec", Done: false}

	task.MarkDoneCopy()
	fmt.Println(task.Done) // false

	task.MarkDone()
	fmt.Println(task.Done) // true
}

Note: MarkDoneCopy “seems to do the job”, but the result disappears, because the changes happened in the receiver’s copy.

2. Receiver selection criteria

Value receiver (T): the method gets a copy

A value receiver is good when your method “reads” data and does not change anything. It is like being handed a photocopy of a document: you can look at it, underline things, draw cats in the margins — but the original in your boss’s folder will not be affected (to your boss’s relief and, possibly, to your disappointment).

An example of a “reading” method for Task:

package main

import "fmt"

type Task struct {
	Title string
	Done  bool
}

func (t Task) IsDone() bool {
	return t.Done
}

func main() {
	task := Task{Title: "Buy milk", Done: true}
	fmt.Println(task.IsDone()) // true
}

Here, a value receiver is a great choice: the method is simple, does not mutate state, and copying a small struct is not a problem at all.

Pointer receiver (*T): the method gets an address and can change the original

A pointer receiver is needed when a method must change state. This is the main criterion, and it almost always settles the question without philosophy. If, by meaning, a method “modifies the object”, but its receiver is a value receiver, you end up with a “pretend modification”.

Let’s return to tasks and implement a proper completion method:

package main

import "fmt"

type Task struct {
	Title string
	Done  bool
}

func (t *Task) MarkDone() {
	t.Done = true
}

func main() {
	task := Task{Title: "Write tests", Done: false}
	task.MarkDone()
	fmt.Println(task.Done) // true
}

From an API design perspective, this reads naturally: task.MarkDone() really completes the task, rather than “pretending to”.

Criterion #1: the method mutates state → almost always *T

Now for a useful “real-life anti-example” that beginners make very often. Suppose you added a method to rename a task, but accidentally wrote it with a value receiver.

package main

import "fmt"

type Task struct {
	Title string
}

func (t Task) RenameBad(newTitle string) {
	t.Title = newTitle
}

func main() {
	task := Task{Title: "Old"}
	task.RenameBad("New")
	fmt.Println(task.Title) // Old
}

It feels roughly like “I clicked ‘Save’, but it did not save”. The correct version:

package main

import "fmt"

type Task struct {
	Title string
}

func (t *Task) Rename(newTitle string) {
	t.Title = newTitle
}

func main() {
	task := Task{Title: "Old"}
	task.Rename("New")
	fmt.Println(task.Title) // New
}

In short: all “setters”, “marking”, “adding”, “clearing”, “flag toggles” — these are almost always *T.

Criterion #2: “heavy” structs are better not copied → often *T

Sometimes a method does not change anything, but the struct is large. Then a value receiver is technically correct, but may be inefficient: each method call copies the entire object. This does not mean the program will instantly burst into flames, but it is like driving a truck to buy bread: you can, but it is odd.

Let’s simulate a “heavy object” (purely for demonstration):

package main

import "fmt"

type Big struct {
	Data [1024]byte
	Name string
}

func (b Big) NameLen() int {
	return len(b.Name)
}

func main() {
	x := Big{Name: "abc"}
	fmt.Println(x.NameLen()) // 3
}

Here, Big is copied in full on every NameLen(). In real life, “heaviness” is often not because of [1024]byte, but because of many fields, nested structs, etc. In that case you often choose *T even for “reading” methods — simply to avoid unnecessary copying.

But it is important not to swing to the extreme of “now all methods are always *T”. Sometimes a value receiver makes the API simpler and safer. So you should combine criteria rather than pick one as a religion.

Criterion #3: API consistency matters more than local cleverness

When, for one type, half the methods are on T and half on *T, a beginner starts living in a world of surprises: “this method worked, that one did not”, “this one changed something, that one did not”. That is why Go has a practical rule: for each type, try to keep a single receiver style unless there is a strong reason not to.

A common compromise looks like this: all methods that mutate state — use *T; methods that only read — use T, but only if the struct is small and you do not care about avoiding copies. If the struct is “serious”, many teams make almost all methods use *T, even read-only ones — for consistency and performance.

Separately (and we will cover this more carefully later): the receiver choice affects interface compatibility, because methods with a pointer receiver are not always “visible” to a value of type T. This shows up in real code fairly quickly, for example when you try to “plug a type into a contract” and get a compiler error.

Criterion #4: sometimes *T is needed because of slice fields and append

Here is a subtle point that looks like magic until you remember that a slice is a “header” (len/cap/pointer to the array). The header is very cheap to copy, but precisely because of that a value receiver can “lose” changes to the slice length.

Let’s add tags to the task:

package main

import "fmt"

type Task struct {
	Title string
	Tags  []string
}

func (t Task) AddTagBad(tag string) {
	t.Tags = append(t.Tags, tag) // modify a copy of the slice header
}

func main() {
	task := Task{Title: "Learn Go", Tags: []string{}}
	task.AddTagBad("study")
	fmt.Println(task.Tags) // []
}

The tag was not added because t is a copy, and the new value of t.Tags stayed inside the method. The correct approach is a mutating method on *Task:

package main

import "fmt"

type Task struct {
	Title string
	Tags  []string
}

func (t *Task) AddTag(tag string) {
	t.Tags = append(t.Tags, tag)
}

func main() {
	task := Task{Title: "Learn Go", Tags: []string{}}
	task.AddTag("study")
	fmt.Println(task.Tags) // [study]
}

This example often becomes an “enlightenment moment”: append changes not elements but potentially the entire header (and sometimes even moves to a new backing array), so without a pointer receiver you can easily “do the work on a copy”.

Addressability: why NewTask().MarkDone() sometimes does not compile

There is one more thing that can look like compiler capriciousness, but it actually protects you from weird situations. A pointer receiver requires the receiver to be addressable (i.e., the value must have a “real address” you can take).

Look at this example:

package main

type Task struct {
	Done bool
}

func (t *Task) MarkDone() {
	t.Done = true
}

func NewTask() Task {
	return Task{Done: false}
}

func main() {
	// NewTask().MarkDone() // does not compile: function result is not addressable
	task := NewTask()
	task.MarkDone()
}

Why? Because NewTask() returns a temporary value. The compiler is not required (and usually does not want) to invent a “hidden variable” for you whose address you can take, just so you can mutate it and then immediately throw it away. The fix is simple: store the value in a variable, as in the example.

If you want a “constructor” to work nicely with pointer methods, a common approach is: NewTask returns *Task.

package main

import "fmt"

type Task struct {
	Done bool
}

func (t *Task) MarkDone() {
	t.Done = true
}

func NewTask() *Task {
	return &Task{Done: false}
}

func main() {
	task := NewTask()
	task.MarkDone()
	fmt.Println(task.Done) // true
}

Yes, this slightly changes the usage style: now a “task” more often lives as a pointer. But that is logical if the type has many mutating methods.

4. Cheat sheet: a table and flowchart for choosing a receiver

When you write a method, try running it through a simple mental checklist table. It is not a “law of Go”, but it works great as a habit.

Question about the method If “yes” If “no”
Must the method change fields?
*T
keep going
Does the method do append to a slice field / replace a map or slice field entirely?
*T
keep going
Is the type large and copying is wasteful? more often
*T
T
is fine
Do we want a single style and a predictable API? more often
*T
for all methods
mixing is possible, but be careful

If you want something even more visual, here is a small flowchart:

flowchart TD
    A[Writing a method] --> B{Mutates state?}
    B -- yes --> P[*T]
    B -- no --> C{Uses append / replaces a slice field entirely?}
    C -- yes --> P
    C -- no --> D{Type is large and copying is wasteful?}
    D -- yes --> P
    D -- no --> V[T]

This chart does not replace common sense, but it saves you from “I put * because someone on the internet said so”.

Why even the standard library often chooses *T

Sometimes it feels like a pointer receiver is a “beginner hack”, and “real gophers do it elegantly”. In practice, pointer receivers are common, including in the standard library, especially when the method is logically tied to the object and you either do not want to copy or you need to mutate state.

For example, typed errors very often implement the Error() method on a pointer to the error struct, i.e., func (e *NotFoundError) Error() string { ... }. We are not discussing error design in detail right now, but the fact itself is useful: *T is a normal tool, not a “dirty trick”.

5. Common mistakes

Mistake #1: a mutating method is written with a value receiver, and changes “do not stick”.
This is the most common bug: by meaning the method must change the object (Rename, MarkDone, AddTag), but the receiver is T. The code compiles, the method is called, but the state stays the same. The fix is simple: as soon as the method “promises” to change the object, use *T and do not try to “save via return” without a reason.

Mistake #2: “I put *T everywhere because it’s safer”.
It seems logical: “a pointer is more powerful”. But it creates a different class of problems: you start passing pointers everywhere, you run into nil more often, and sometimes you complicate simple types that could have been values. A better rule is: *T where mutation or copy savings are needed, not “by default”.

Mistake #3: surprising behavior due to append to a slice field with a value receiver.
This bug is especially tricky because it feels like “but I added the tag!”. In reality, you added the tag to a copy of the slice header. So from the outside there are no changes. If a method changes the length/capacity of a slice stored in a struct field, you almost always need *T.

Mistake #4: trying to call a pointer method on a temporary (non-addressable) value.
Classic: NewTask().MarkDone() and the compiler complains. This is not Go being mean; it is protection from implicit magic and strange side effects. The right habit is: first store the result in a variable, or design the “constructor” so it returns *T if the type has many mutating methods.

Mistake #5: a chaotic mix of receiver types without a clear rule.
When some methods use T and some use *T, in a couple of weeks you will start forgetting which is which. And if others read the code, they will start guessing where the method mutates state and where it does not. It is better to pick a simple rule for the type and stick to it, leaving “exceptions” only where they are truly needed.

1
Task
Go SELF, level 22, lesson 1
Locked
Payment verification
Payment verification
1
Task
Go SELF, level 22, lesson 1
Locked
Birthday
Birthday
1
Task
Go SELF, level 22, lesson 1
Locked
Shopping List
Shopping List
1
Task
Go SELF, level 22, lesson 1
Locked
One-line note
One-line note
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION