Should you use slices of pointers to structs?

Avatar of the author Willem Schots
20 Aug, 2023
~10 min.
RSS

While writing Go, you might might run into the following situation: You want to collect the results of a function in a slice. However, the function returns a pointer.

You might then ask yourself:

What kind of slice should I use? A slice of values or a slice of pointers?

Let’s make this a bit more concrete.

Suppose the following Album struct and ProduceAlbum function exist:

type Album struct {
	Title  string
	Artist string
}

// ProduceAlbum produces a random album. The details
// don't really matter in this article, in real code this might
// do an expensive calculation or retrieve data from a database.
func ProduceAlbum() *Album {
	vol := rand.Intn(100) + 1
	return &Album{
		Title:  fmt.Sprintf("Groovy vol. %d", vol),
		Artist: "👨‍🎤",
	}
}

Every time ProduceAlbum is called, it returns a pointer to a randomly titled Album.

Let’s say our goal is to collect 10 albums in a slice. We can do this in a loop like this:

// define a slice
for i := 0; i < 10; i++ {
	// call ProduceAlbum.
	// append the result to the slice.
}

Now the question is, what type of slice should we use to collect the results?

Do we define a slice of values: []Album?

Or, do we define a slice of pointers: []*Album?

main.go
package main

import "fmt"

func main() {
	// slice of values
	v := make([]Album, 0, 10)
	for i := 0; i < 10; i++ {
		a := ProduceAlbum()
		v = append(v, *a)
	}

	fmt.Println(v)

	// slice of pointers
	p := make([]*Album, 0, 10)
	for i := 0; i < 10; i++ {
		p = append(p, ProduceAlbum())
	}

	fmt.Println(p)
}
album.go
package main

import (
	"fmt"
	"math/rand"
)

type Album struct {
	Title  string
	Artist string
}

// ProduceAlbum produces a random album. The details
// don't really matter in this article, in real code this might
// do an expensive calculation or retrieve data from a database.
func ProduceAlbum() *Album {
	vol := rand.Intn(100) + 1
	return &Album{
		Title:  fmt.Sprintf("Vol. %d", vol),
		Artist: "👨‍🎤",
	}
}

In this article we will examine the differences between the two options and go over reasons why you might choose one or the other.

Are you or your team struggling with aspects of Go?

Book a 30 minute check-in with me.

I can help with questions like:

  • How should we structure our Go web app?
  • Is this package a good choice for us?
  • How do I get my team up to speed quickly?
  • Can I pick your brain on {subject}?
Learn more

Looking forward to meeting you.

- Willem

Slice of pointers vs slice of values

Let’s ignore the ProduceAlbum function for now, and see how slices of values and slices of pointers compare structurally.

We begin with a slice of values called v:

// v is a slice of values.
v := []Album{
  {"Val. 1", "👨‍🎤"},
  {"Val. 2", "🧑‍🎤"},
  {"Val. 3", "👩‍🎤"},
}

If we diagram v, it looks like this:

a slice of values with its backing array

As you can see, the Album values are not stored directly in v, but in a backing array of type [3]Album. v only has a reference to this array.

If this backing array is new or unclear to you, be sure to check my article on slices and arrays.

It explains exactly how arrays and slices are related.

Now let’s look at a slice of pointers p:

a1 := Album{"Ptr. 1", "👨‍🎤"}
a2 := Album{"Ptr. 2", "🧑‍🎤"}
a3 := Album{"Ptr. 3", "👩‍🎤"}
p := []*Album{&a1, &a2, &a3}

p will also have a backing array. But instead of storing Album values, it contains pointers to Album variables. In other words, its elements will be of type *Album.

When we diagram p, you can see that the pointers add an “extra layer” of references.

p has a backing array containing pointers.
Those pointers point to values stored in variables a1, a2 and a3.

Accessing the values requires us to cross this “extra layer”.

Accessing values

To access elements in a slice you can use an index expression.

If we access an element on v, we get an Album value.

fmt.Println(v[1]) // prints "{Val. 2 🧑‍🎤}"

However, if we do the same for p, we will print a pointer.

fmt.Println(p[1]) // prints "&{Ptr. 2 🧑‍🎤}"

You can see this prints a pointer because the output begins with the & symbol. The fmt package does some extra work for pointers to structs and will try to print values as well.

If you use fmt.Printf("%p\n", p[1]) you will print the actual value of the pointer itself.

To access the value the pointer points to, we need to dereference the pointer using the * operator.

fmt.Println(*p[1]) // prints "{Ptr. 2 🧑‍🎤}"

Try it yourself below.

main.go
package main

import "fmt"

func main() {
	// v is a slice of values.
	v := []Album{
		{"Val. 1", "👨‍🎤"},
		{"Val. 2", "🧑‍🎤"},
		{"Val. 3", "👩‍🎤"},
	}

	fmt.Println(v[1])

	// p is a slice of pointers.
	a1 := Album{"Ptr. 1", "👨‍🎤"}
	a2 := Album{"Ptr. 2", "🧑‍🎤"}
	a3 := Album{"Ptr. 3", "👩‍🎤"}
	p := []*Album{&a1, &a2, &a3}

	fmt.Println(p[1])
	fmt.Printf("%p\n", p[1])
}
album.go
package main

type Album struct {
	Title  string
	Artist string
}

When you access fields or methods on a variable, Go will automatically dereference or create a pointer as necessary.

For example, printing v[1].Title in the above code works for both slices.

Nil pointers

It’s possible for a pointer not to point to a variable, it’s value will then be nil.

nil is the zero value for pointer types: if you don’t assign a value to a pointer type it will be nil.

Dereferencing a nil pointer will lead to a panic:

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

To prevent such panics, you will need to check if a pointer does not equal nil before you dereference it.

In the example below ProduceAlbum has been modified to return nil every second call. Remove the nil check to verify that the program panics.

main.go
package main

import "fmt"

func main() {
	// slice of values
	v := make([]Album, 0, 10)
	for i := 0; i < 10; i++ {
		a := ProduceAlbum()
		if a != nil {
			v = append(v, *a)
		}
	}

	fmt.Println(v)
}
album.go
package main

import (
	"fmt"
	"math/rand"
)

type Album struct {
	Title  string
	Artist string
}

var calls = 0

// ProduceAlbum produces a random album or returns nil.
func ProduceAlbum() *Album {
	calls++
	if calls%2 == 0 {
		return nil
	}
	vol := rand.Intn(100) + 1
	return &Album{
		Title:  fmt.Sprintf("Vol. %d", vol),
		Artist: "👨‍🎤",
	}
}

If you know for sure a variable will never contain nil you will not need nil checks.

Be careful though, code evolves over time and nil can easily sneak in. The Go type system will not protect you here.

Range and modify

To iterate over slices you can use a for loop with a range clause.

When you iterate over a slice of values, the iteration variables will be copies of those values. Any modifications you make to the iteration variables won’t be reflected outside of the loop.

For example, if we range over v and modify the title of the iteration variable a:

for _, a := range v {
	// a is of type Album
	a.Title = "Unknown album"
}
fmt.Println(v[0].Title) // prints "Groovy vol. X"

The titles of all elements in v will remain untouched, because we only modified the iteration variable a.

To modify the elements in v, we need to assign a to an element:

for i, a := range v {
	// a is of type Album
	a.Title = "Unknown album"
	v[i] = a // assign a to an element in v.
}
fmt.Println(v[0].Title) // prints "Unknown album"

Or, alternatively, modify the elements of v directly:

for i := range v {
	v[i].Title = "Unknown album"
}
fmt.Println(v[0].Title) // prints "Unknown album"

With a slice of pointers, the iteration variables will be copies of the pointers. These copies will still point to the same values.

Changes to these values will be visible outside of the loop.

for _, a := range p {
	// a is of type *Album
	a.Title = "Unknown album"
}
fmt.Println(v[0].Title) // prints "Unknown album"

Appending

If we go back to our original situation, ProduceAlbum returns a pointer.

When we append to a slice of values, we will need to dereference the pointer before we can append it.

Since we wrote it, we know that the ProduceAlbum function will always return a non-nil pointer. However, as we noted earlier, in real code you can’t always be so sure.

To prevent a “nil pointer dereference” panic you would again need a nil check:

v := make([]Album, 0)
for i := 0; i < 10; i++ {
	ptr := ProduceAlbum()
	if ptr != nil {
		v = append(v, *ptr)
	}
}

Such a panic will not happen with a slice of pointers, because it can store nil pointers just fine.

p := make([]*Album, 0)
for i := 0; i < 10; i++ {
	p = append(p, ProduceAlbum())
}

Depending on your situation you might still want to filter out nil values though.

Choosing a type

This section contains my thoughts on how to choose between slices of values and slices of pointers.

These are not hard and fast rules. If you’re in doubt, whip up a prototype with both options and play around for a bit. See what is best for your situation.

Default to slices of values

I default to using slices of values.

With slices of pointers, the Go type system will allow nil values in the slice. This does not add any benefit unless my program requires elements to have “no value”.

Slices already consist of a reference to an array. Slices of pointers add an extra “layer of indirection”, making it more work to reason about what is going on.

Should the function return a pointer?

This also raises the question, why does ProduceAlbum return a pointer to begin with? If possible I would probably change it to return an Album.

However, that isn’t always possible:

  • The function might be part of a third party package.
  • The function might be called in a lot of places and require extensive refactoring to change its return value type.
  • The Album struct might be very large and returning a pointer is a performance optimization.

Functionality

Sometimes you explicitly require pointers for your desired functionality.

For example, is ProduceAlbum returning an album as a “final result”? Then Album is a more suitable data type.

Or, is ProduceAlbum returning an album that will automatically get refreshed by new data? Then *Album is likely a more suitable data type.

Representing no value

If we need to differentiate between a zero value and no value in a slice, a slice of pointers can be helpful. nil can represent an element having no value.

Consistency in receivers

If the element type of the slice has methods defined on it, you might want to match the type of the receiver.

For example, suppose we had a method defined on *Album like this:

func (a *Album) MakeItMetal() {
	a.Title = strings.ToUpper(a.Title)
	a.Artist = "🧛‍♂️"
}

You might want to be consistent and match the a *Album receiver and use slices of pointers ([]*Album).

I generally don’t let this hold to much sway though.

As we saw earlier, Go provides some help with methods and fields. Calling MakeItMetal on a variable of type Album will work even though it is defined with an *Album receiver.

Performance

When I first started out in Go I thought “pointers are faster” so assumed that slices of pointers were always a good idea.

This is not true. Values referenced by pointers often require more work by the Go runtime to access, allocate and clean up.

If you’re “just” passing around a slice, your program you will generally have better performance by using a slice of values.

However, it can make sense to use a slice of pointers if you’re working with a lot of large structs and pass them around individually as well.

Always use benchmarks to make an informed choice when you’re doing anything performance sensitive.

That’s it

I hope this article gives helps you choose between slices of pointers and values in your code.

If you have any questions or comments feel free to reach out to me :)

P.S. I would probably use a slice of values in the situation in the intro of this article.

🎓

Subscribe to my Newsletter and Keep Learning.

Gain access to more content and get notified of the latest articles:

I send emails every 1-2 weeks and will keep your data safe. You can unsubscribe at any time.

Hello! I'm the Willem behind willem.dev

I created this website to help new Go developers, I hope it brings you some value! :)

You can follow me on Twitter/X, LinkedIn or Mastodon.

Thanks for reading!