Table of Contents
When it comes to struct values in Go, one of the people’s common points of tension is whether to pass those around via pointers or just copy the value. Because pointers come with some overhead, the natural reaction is to avoid them at all costs and resort to passing struct value copies wherever possible.
I am here to challenge that thought a little. Too much of the discourse around whether to use a certain Go construct becomes deeply technical too quickly. This may be justified when developing low-level system components with zero friction or massively scalable Web services.
Neither is the case for most people. Suffice it to say, the chances of you giving up on your idea or your business going bankrupt are way higher than you ever need to worry about the performance of your Go app. When building regular software applications, I think it is far more important to focus on growing a codebase that is easy to build upon and maintain by multiple people over time. Therefore, rather than performance, the two reasons I would usually choose using pointer structs are identity and consistency.
Consistency #
One of my first clashes with reality as a software engineer was when I came to terms with the fact that real software engineering has much more to do with making the code easy to follow and modify than simply making the computer happy. Every feature a language offers comes with a cognitive burden and has trade-offs. Even in the case of a language as simple as Go, there are many places where a programming decision must be justified:
- When and why do we use an
if-else
or aswitch-case
? - When do we create a struct method vs a package-level function?
- When and why do we choose a
channel
over aWaitGroup
and someMutex
. - When is it OK to
panic
? - When and why do we use an interface over referring to the type directly?
- When and why do we pass a struct by pointer rather than copy the struct’s value?
The naive answer to the last question would be to decide based on the concrete situation. Is your struct small, copy its values around. Do you need mutable access to one of its values, use a pointer? Has your struct grown too much, use a pointer. Wait, what do you mean by you copied its value around in a thousand places and now need to refactor them all?
I hope that with this simple anecdote, I was able to make my point that the current situation is not something set in stone but changes all the time. Sometimes, multiple times a day even. At some point, it may become prohibitively expensive to go with the flow.
For my projects, I would rather go with semantics. I look at what a struct type is supposed to represent and decide up-front if I am going to use pointer or value-copy semantic for it. Once I decide on that, the decision almost always sticks for the duration of the project.
To draw the line between pointer and value-copy semantics, I have chosen criteria partially inspired by domain-driven design (DDD) and partially by this Ardan Labs post. It all has to do with the type’s identity.
Identity #
DDD distinguishes entities from value objects. The simplest possible way to explain the difference between the two is to ask myself the question:
If all the attributes of two values of the same struct type are equal, are the two values identical?
The same question, modified slightly.
If I change one of the attributes’ values, does it break the identity of the struct value?
And last but not least, this one:
Is the zero value of that struct type useful?
Value Objects #
If the answer to all questions is Yes, you have a classical value object and can freely use value-copy semantics. Why? Well, value objects are meant to be immutable. If you change one of the attributes’ values, you are essentially creating an entirely new value. The best example from the Go standard library is time.Time
. The time.Time
struct represents an instant in time with nanosecond precision. Essentially, a time.Time
value is a wrapper over two uint64
s. If you change any of those two values, we get a completely different time.Time
value. In other words, its identity is a product of its attributes’ equality. Also, an empty time.Time
is its logical zero value:
var t, tt time.Time
fmt.Println(t == tt) // true
fmt.Println(t.IsZero()) // true
Can you use time.Time
using a pointer semantic? Of course, you can - a pointer is simply a reference to a known value in memory. You can address any value, regardless of its type. But in this case, you will get no real benefit, which is why most of the time, time instances will be passed around as value copies. Someone might object that the use of *time.Time
might be useful in situations where the value may not exist at all (optionality). A good example is a struct with a DeletedAt
attribute that only gets set, if the data has been deleted (e.g., from a database):
type DBRow {
// Other attrs ..
DeletedAt *time.Time
}
I’d argue that this can be solved one level above, by introducing a separate struct type where DeletedAt
is always valid:
type DBRow {
// Other attrs ..
}
type DeletedDBRow {
DBRow // nicely brings in all the attributes of the row
DeletedAt time.Time // we assume that the value is always valid
}
Entities #
Naturally, this brings us to the second type, namely entities. Using the above three questions as criteria, we can define any struct type as an entity if it either has no reasonable zero value or the change of any of its attributes does not imply a new value. In the case of time.Time
, an instance of time (e.g., Jan 1st, 2024) is always the same, no matter how many identical time.Time
copies of it we might have. The identity of a time instance is inferred from its internal state.
With more complex types, we may not be in a position to say that because even if they may have the same internal state, those are different copies that will evolve differently over time, and that will bring undesired consequences. Think of a file reference or a database connection. If you have more than one copy referring to the same resource, you run the risk of reading an mutating a file from two different locations, or not closing a database transaction properly (or worse, exhausting your maximum available DB connections), for example.
If we cannot reasonably determine the identity of an object, we need a form of reference to it that says, “regardless of how many copies of this reference we have, they all point to the same source.” This role is played perfectly by pointers.
Let’s illustrate that with a less obvious custom-type example. Suppose we are building a project management system, and Project
along with Person
are two of the core data models in it. A Project
must have a title, and an associated Owner
of type Person
.
For a second let’s use the value-copy semantic when working with Project
. A few interesting questions will pop up right away. First, what exactly is the zero value of a Project
- is a project without an owner, and with an empty title a useful zero value? What about a project that has an owner but no title? Or, the other way around? Perhaps, a generic nil
value is the better option here?
Even if we disagree on that, how common is it that we may end up having multiple projects with identical titles owned by the same person? It is definitely legal and possible. If we follow the value-copy semantic, how can we be sure that Project A is different from Project B if they have the same owner and the same title?
me := Person{Name: "John"}
a := Project{Title: "ToDos", Owner: me}
b := Project{Title: "ToDos", Owner: me}
fmt.Println(a == b) // true, which is logically wrong
One would say that this is what IDs are for. Fine, we would add unique IDs to each, so we can distinguish on the basis of those being different:
me := Person{Name: "John"}
a := Project{ID: 1, Title: "ToDos", Owner: me}
b := Project{ID: 2, Title: "ToDos", Owner: me}
fmt.Println(a == b) // false
But then again, what if those IDs are both zeroes, or someone forgets to set them? Then, the two values will be equal again, which is logically wrong. But there is something else, too. If we now take project A
, pass a copy of it to the function, and modify it inside the function, we’ll now have two copies referring to the same ID
but with different titles. According to the postulates above, the only reasonable way to identify a project’s identity is to use pointer semantics for that type. The same applies for the type Person
, btw.
me := Person{Name: "John"}
a := Project{Title: "ToDos", Owner: &me}
b := Project{Title: "ToDos", Owner: &me}
fmt.Println(&a == &b) // false, as it should be
Wrapping Things Up #
If you’ve made it this far, well done! I appreciate your patience as a reader. I hope my explanations have been clear enough to show you the value of pointer semantics. In reality pointers are far more versatile than copying values around. In fact, I am not afraid to go with pointer struct types in most situations, resorting to value copies where it really makes sense, and I can guarantee that I won’t have to pay the price of changing the semantic at a later point. Pointers are not bad, they don’t bite, they are not a code smell, and really, they are a normal part of programming. It is far more important to have a consistent codebase where every types is referred to using sound reasoning, than optimizing for the case that may (and will likely) never happen.
Have something to say? Join the discussion below 👇
Want to explore instead? Fly with the time capsule 🛸
You may also find these interesting
User or *User - Do We Need Struct Pointers Everywhere?
A bit of up-front thinking can help make our Go code cleaner and more performant.
Interfaces Are Not Meant for That
It’s time to ask ourselves how much abstraction in our Go code really makes sense.