Blog

Structs, Methods and Pointers in Go

Go structs are values, so every assignment and every call copies them. Pointers let you share one struct instead, and pointer receivers are how methods change the value they’re called on.

A struct groups related values under one name, and methods give that group behaviour. Most real Go types are structs, so this is where your own types start.

The part people trip on isn’t the syntax. It’s that a struct is a value, and Go copies values freely. This post shows where the copies happen, how pointers avoid them, and how that decides which kind of method to write. Every program below was run on Go 1.26, and its output is pasted from the run.

Defining a struct

A struct type lists named fields, each with its own type. You build a value of it with a composite literal:

package main

import "fmt"

type User struct {
	Name  string
	Email string
	Age   int
}

func main() {
	var zero User
	ada := User{Name: "Ada", Age: 36}
	bob := User{"Bob", "bob@example.com", 41}
	fmt.Printf("%+v\n", zero)
	fmt.Printf("%+v\n", ada)
	fmt.Printf("%+v\n", bob)

	ada.Email = "ada@example.com"
	fmt.Println(ada.Name, ada.Email)
}

It prints:

{Name: Email: Age:0}
{Name:Ada Email: Age:36}
{Name:Bob Email:bob@example.com Age:41}
Ada ada@example.com

var zero User gives a struct with every field at its zero value: two empty strings and 0. You never get a half-built struct with garbage in it.

ada uses named fields. Any field you leave out gets its zero value, so Email is "". bob uses positional fields: the values go in the order the fields are declared, and you must give all of them.

Prefer named fields. With positional ones, adding a field to User breaks every literal, and swapping two string fields by mistake compiles without a word. go vet even warns about positional literals of types from other packages. You read and write fields with a dot, as in ada.Email.

Anonymous structs

A struct type doesn’t need a name. For a one-off shape, you can declare and fill it in one go:

package main

import "fmt"

func main() {
	point := struct {
		X, Y int
	}{X: 3, Y: 4}
	fmt.Printf("%+v\n", point)
}

It prints:

{X:3 Y:4}

You’ll see these in tests, as a table of cases, and for decoding a bit of JSON you only need once. Anything you pass around deserves a named type.

Structs are values, and values get copied

Assigning a struct copies every field, and so does passing one to a function:

package main

import "fmt"

type User struct {
	Name string
	Age  int
}

func birthday(u User) {
	u.Age++
	fmt.Println("inside:", u.Age)
}

func main() {
	ada := User{Name: "Ada", Age: 36}
	copyOfAda := ada
	copyOfAda.Name = "Not Ada"

	birthday(ada)
	fmt.Println(ada.Name, ada.Age)
	fmt.Println(copyOfAda.Name)
}

It prints:

inside: 37
Ada 36
Not Ada

copyOfAda is a second, separate User. Renaming it left ada alone. birthday got its own copy too, so it added a year to the copy, printed 37, and threw the copy away when it returned. The caller’s ada is still 36.

This is the same rule you saw for arrays in the part on slices. It isn’t a special case for structs: in Go, every assignment and every argument is a copy. What differs is what gets copied. For a struct, that’s all of its fields.

The same copy hides in a range loop, where it causes a quiet bug:

package main

import "fmt"

type Player struct {
	Name  string
	Score int
}

func main() {
	team := []Player{{Name: "Ada", Score: 10}, {Name: "Bob", Score: 20}}

	for _, p := range team {
		p.Score += 5 // changes a copy
	}
	fmt.Println(team)

	for i := range team {
		team[i].Score += 5 // changes the element
	}
	fmt.Println(team)
}

It prints:

[{Ada 10} {Bob 20}]
[{Ada 15} {Bob 25}]

In the first loop, p is a copy of each element, so the scores don’t move. The second loop indexes into the slice and changes the real elements.

Pointers: sharing instead of copying

A pointer holds the address of a value, which lets two parts of a program work on the same struct. &x gives you the address of x, and *p gives you the value that p points at:

package main

import "fmt"

type User struct {
	Name string
	Age  int
}

func birthday(u *User) {
	u.Age++
}

func main() {
	ada := User{Name: "Ada", Age: 36}
	p := &ada
	fmt.Println((*p).Age, p.Age)

	birthday(p)
	birthday(&ada)
	fmt.Println(ada.Age)

	q := p
	q.Name = "Ada L."
	fmt.Println(ada.Name, p == q)
}

It prints:

36 36
38
Ada L. true

The type *User means “pointer to a User“. p := &ada stores the address of ada in p.

(*p).Age follows the pointer and reads the field. Nobody writes it that way, because Go does it for you: p.Age on a pointer to a struct means exactly the same thing.

birthday now takes a *User. It still gets a copy of its argument, but the copy is an address, and both addresses lead to ada. Two calls, two birthdays, and ada.Age is 38.

q := p copies the pointer, not the struct. Setting q.Name changed ada, and p == q is true because the two pointers hold the same address.

Explain it like I’m ten

A struct is a house. A pointer is a slip of paper with the house’s address written on it.

If you photocopy the slip, you get two slips. You don’t get two houses. Both slips lead to the same front door, so if a friend follows their slip and paints the door red, you’ll find a red door when you follow yours.

Passing a struct without a pointer is different. It’s like building a copy of the whole house, brick by brick, and handing that over. Your friend can paint that door any colour they like. Your house doesn’t change.

The precise version

A pointer value is a memory address, typed by what it points to. A *User is the same size, one machine word, however big User is. Copying a pointer copies that word, and afterwards both copies refer to the same variable.

& takes the address of an addressable value: a variable, a field of one, or a slice element. A composite literal like &User{} is allowed too, as a special case. * dereferences. For field access and method calls, Go dereferences a pointer to a struct automatically.

Where the analogy breaks: real addresses can be worked out. The house next door is one number up. Go has no pointer arithmetic, so you can’t go from one address to the next; you can only follow the pointer you were given. And a real house can be knocked down while people still hold its address. In Go, the garbage collector keeps the value alive as long as any pointer to it exists. That’s why a function can safely return a pointer to one of its own local variables. The part on memory explains where such values live.

new, and a Go 1.26 addition

new(T) allocates a zero value of type T and returns a pointer to it. As of Go 1.26 it also accepts an expression, and gives you a pointer to a new variable holding that value:

package main

import "fmt"

type Config struct {
	Retries int
	Debug   *bool
}

func main() {
	n := new(int)
	fmt.Println(*n)
	*n = 7
	fmt.Println(*n)

	cfg := Config{Retries: 3, Debug: new(true)}
	fmt.Println(cfg.Retries, *cfg.Debug)
}

It prints:

0
7
3 true

new(int) points at a fresh 0. new(true) is the new form. Before 1.26 you couldn’t write &true, so you needed a temporary variable or a small helper function just to get a *bool. Optional fields like Debug come up a lot in configuration and JSON, where a pointer tells “not set” (nil) apart from false.

For structs, you’ll still mostly write &User{Name: "Ada"}. It does the same job as new and fills in the fields at the same time.

nil pointers

The zero value of a pointer is nil, which means it points at nothing. Following a nil pointer stops the program:

package main

import "fmt"

type User struct {
	Name string
}

func main() {
	var p *User
	fmt.Println(p == nil)
	fmt.Println(p.Name)
}

It prints the first line, then stops:

true
panic: runtime error: invalid memory address or nil pointer dereference

p.Name is really (*p).Name, and there is no *p to read. Go doesn’t return a zero value here, and it doesn’t read some random memory. It panics.

This is the most common panic in Go programs. When a function can return a nil pointer, check it before you use it. The usual way is the value, err pattern: a function that returns a nil pointer also returns a non-nil error, and you check the error first.

Methods

A method is a function with a receiver, the value it’s called on. The receiver goes in brackets before the method name, and it can be a value or a pointer. That choice decides whether the method can change anything:

package main

import "fmt"

type Counter struct {
	count int
}

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

func (c *Counter) IncPtr() {
	c.count++
}

func main() {
	var c Counter

	c.IncValue()
	fmt.Println("after IncValue:", c.count)

	c.IncPtr()
	fmt.Println("after IncPtr:", c.count)
}

It prints:

after IncValue: 0
after IncPtr: 1

Both methods contain the same line, c.count++. Only one of them worked. A receiver is just a parameter, so it’s copied like any other. Watch what each method is actually given.

c, in main count 0 count 1 a copy, inside IncValue count 0 count 1 a pointer, inside IncPtr points at c output after IncValue: 0 after IncPtr: 1 var c Counter: c starts with count 0 c.IncValue() gets a copy of c, and count++ changes the copy to 1 the method returns, the copy is thrown away, and c still has 0 c.IncPtr() gets a pointer to c, and count++ changes c itself the method returns, the pointer is gone, and c keeps count 1

The Counter program above. IncValue has a value receiver, so it works on a copy of c: the copy’s count goes to 1 and is thrown away, and the program prints 0. IncPtr has a pointer receiver, so count++ goes through the pointer to c itself, and the program prints 1.

Here are those steps in words, in case the animation doesn’t play for you:

  1. var c Counter makes a counter in main with count 0.
  2. c.IncValue() copies c into the receiver. count++ changes the copy, so the copy’s count is 1.
  3. The method returns and the copy is gone. c still has count 0, and the program prints after IncValue: 0.
  4. c.IncPtr() gives the method a pointer to c. count++ follows the pointer and changes c itself.
  5. The method returns. c keeps count 1, and the program prints after IncPtr: 1.

A value receiver is a method saying “give me a photocopy”. A pointer receiver says “give me the address”. If a method needs to change its receiver, it needs a pointer receiver.

Go takes the address for you

You called c.IncPtr() on c, a plain Counter, and not on a *Counter. That worked because Go rewrites the call for you:

package main

import "fmt"

type Counter struct {
	count int
}

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

func (c Counter) Get() int {
	return c.count
}

func main() {
	c := Counter{}
	c.Inc() // Go writes (&c).Inc() for you

	p := &c
	p.Inc()
	fmt.Println(p.Get()) // and (*p).Get() here
}

It prints:

2

When c is a variable and Inc wants a pointer, Go takes &c. When p is a pointer and Get wants a value, Go uses *p. So in everyday code you call methods the same way whatever the receiver is.

The rewrite only works when there’s an address to take. A value that isn’t stored in a variable has no address:

package main

type Counter struct {
	count int
}

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

func main() {
	Counter{}.Inc()
}

The build fails with:

./main.go:12:12: cannot call pointer method Inc on Counter

Counter{} is a temporary value, not a variable, so there’s nothing for Inc to point at. Store it in a variable first, or write (&Counter{}).Inc(). The same limit is why you can’t call a pointer method on a map element directly. It will matter again in the part on interfaces, where it decides which types satisfy an interface.

When to use a pointer receiver

The rule of thumb is short. Use a pointer receiver when any of these is true:

  • The method changes the receiver. This is the one you can’t argue with. A value receiver changes a copy.
  • The struct is large. A value receiver copies every field on every call. A pointer is one word. “Large” has no exact cutoff; a few small fields is fine as a value.
  • The type can’t be copied safely. A struct holding a sync.Mutex, for example, must not be copied. go vet reports copies of those. The part on sync covers why.
  • Other methods on the type already use pointer receivers. Keep the whole type consistent, either all pointer receivers or all value receivers.

Value receivers suit small types that behave like values, such as a Point or a Money amount, where every method only reads and a copy is cheap. If you’re unsure, use a pointer receiver. That’s the choice most Go code makes for structs.

Embedding: fields and methods from another struct

Go has no inheritance. Instead, a struct can embed another type by listing it without a field name, and the embedded type’s fields and methods are promoted to the outer struct:

package main

import "fmt"

type Address struct {
	City string
}

func (a Address) Label() string {
	return "lives in " + a.City
}

type Customer struct {
	Name string
	Address
}

func main() {
	c := Customer{
		Name:    "Ada",
		Address: Address{City: "London"},
	}
	fmt.Println(c.City)
	fmt.Println(c.Label())
	fmt.Println(c.Address.City)
}

It prints:

London
lives in London
London

c.City and c.Label() work as if Customer declared them itself. Underneath, the embedded value is still an ordinary field named after its type, Address, which is why c.Address.City works too.

This is composition, not inheritance. A Customer contains an Address; it isn’t one. You can’t pass a Customer where a function wants an Address. And when Label runs, its receiver is the inner Address. It knows nothing about the Customer around it. If Customer declares its own Label, that one wins, and the embedded one is still there as c.Address.Label().

Comparing structs, and struct tags

You can compare two structs with == when every field is comparable, and the comparison is field by field:

package main

import "fmt"

type Point struct {
	X, Y int
}

func main() {
	a := Point{X: 1, Y: 2}
	b := Point{X: 1, Y: 2}
	pa, pb := &a, &b
	fmt.Println(a == b)
	fmt.Println(pa == pb, *pa == *pb)

	seen := map[Point]bool{a: true}
	fmt.Println(seen[Point{1, 2}])
}

It prints:

true
false true
true

a == b is true because both fields match. Pointers compare addresses, though. pa and pb point at two different variables, so pa == pb is false even though the values they point at are equal. Comparable structs also work as map keys, which is handy for coordinates and composite keys.

Add a field that can’t be compared, like a slice, and == stops compiling:

package main

import "fmt"

type Tagged struct {
	Name string
	Tags []string
}

func main() {
	a := Tagged{Name: "x"}
	b := Tagged{Name: "x"}
	fmt.Println(a == b)
}

The build fails with:

./main.go:13:14: invalid operation: a == b (struct containing []string cannot be compared)

Slices, maps and functions can’t be compared with ==, so neither can a struct that holds one. You’d compare those fields yourself, for example with slices.Equal.

Fields can also carry a tag, a string after the type, as in Email string `json:"email"`. The language ignores tags. Packages read them, and encoding/json uses them to name fields in JSON. The parts on building a REST API use them heavily.

Constructors are just functions called NewX

Go has no constructors, so by convention a function named NewX builds an X and returns a pointer to it:

package main

import (
	"errors"
	"fmt"
)

type Account struct {
	Owner   string
	balance int
}

func NewAccount(owner string) (*Account, error) {
	if owner == "" {
		return nil, errors.New("owner is required")
	}
	return &Account{Owner: owner}, nil
}

func (a *Account) Deposit(amount int) {
	a.balance += amount
}

func (a *Account) Balance() int {
	return a.balance
}

func main() {
	acc, err := NewAccount("Ada")
	if err != nil {
		fmt.Println(err)
		return
	}
	acc.Deposit(50)
	acc.Deposit(25)
	fmt.Println(acc.Owner, acc.Balance())

	_, err = NewAccount("")
	fmt.Println(err)
}

It prints:

Ada 75
owner is required

NewAccount checks its input before anything exists, and returns an error instead of a broken account. It returns &Account{...}, the address of a value it just built. That’s safe: as the house analogy said, the value stays alive while a pointer to it exists.

Notice that balance starts with a lowercase letter. Lowercase names are private to the package, so code in other packages must go through Deposit and Balance. The part on packages covers that rule. Every method here has a pointer receiver, including Balance, which only reads, because the type’s other methods need one.

Write a NewX when a struct needs validation or setup. When the zero value is already useful, like Counter above, skip it and let people write var c Counter.

What to remember

  • A struct is a value. Assigning it, passing it and ranging over a slice of it all make copies.
  • Prefer named fields in struct literals. Missing fields get their zero values.
  • A pointer holds an address. Copying it gives you two pointers to one value, and p.Field follows it for you.
  • A nil pointer points at nothing, and following it panics.
  • A value receiver works on a copy. If a method changes its receiver, it needs a pointer receiver, and the type’s other methods should usually match.
  • Embedding promotes fields and methods. It’s composition, not inheritance.
  • A NewX function that returns *X is Go’s constructor, by convention only.

A value receiver gets a copy; a pointer receiver gets the real thing.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.