Value vs pointer
The previous chapter introduced pointers with primitives. Structs are where the value-versus-pointer choice starts paying rent. Almost every function signature in a real Go codebase makes this decision; getting it right is one of the handful of choices that separates code that compiles from code that behaves the way the author intended.
Passing a struct by value copies every field
When you pass a struct to a function by value, the function receives a full copy of the struct. Changes inside the function do not touch the caller's original:
type User struct {
Name string
Email string
}
func rename(u User, to string) {
u.Name = to
}
func main() {
alice := User{Name: "Alice", Email: "alice@example.com"}
rename(alice, "Alice Smith")
fmt.Println(alice.Name) // Alice (unchanged!)
}
Same rule you met with primitive ints in the previous chapter, now with a struct on board. The function's u is a local copy; writing to u.Name only changes the copy.
Pass a pointer to mutate the caller's struct
If you want the function to modify the caller's struct, take *User and write through the pointer:
func rename(u *User, to string) {
u.Name = to
}
func main() {
alice := User{Name: "Alice", Email: "alice@example.com"}
rename(&alice, "Alice Smith")
fmt.Println(alice.Name) // Alice Smith
}
Three changes from the broken version:
- The parameter is
*Userinstead ofUser. - The call site passes
&alice. - The body uses
u.Name = to. Note there is no*uin sight, even thoughuis a pointer.
Automatic dereference through .
That no-*u rule is worth stating directly. For struct fields, the dot operator automatically dereferences a pointer. u.Name and (*u).Name mean the same thing:
u := &User{Name: "Alice"}
fmt.Println(u.Name) // Alice, same as (*u).Name
u.Name = "Bob" // writes through the pointer
You will see u.Name everywhere in Go code; you will almost never see (*u).Name. The language designers wanted struct access to read the same whether you have a value or a pointer, so the . does the work for you.
Constructing a pointer to a struct
The Pointers chapter introduced three ways to get a pointer. For structs, only one matters in practice: the composite-literal address, &T{...}:
u := &User{
Name: "Alice",
Email: "alice@example.com",
}
&User{...} builds the struct and returns a pointer to it in one expression. This is the shape you will see in every real Go codebase. new(User) would give you a pointer to a zero-valued User with no fields set; useful only when you genuinely want the zero value.
Deciding between value and pointer for a struct type
There are two reasons to take a pointer, spelled out in the Pointers chapter: to let a function modify the caller's value, and to avoid the cost of copying a large value. Structs are where both reasons show up concretely. Some guidance that holds up in real code:
-
Types that represent shared identity take a pointer. A
User, anOrder, aConnection, anhttp.Request, a*sql.DB: each one logically has "the one that matters" in a given context, and every function that handles one should see changes through it. Pointers make that sharing explicit and cheap. -
Small value-flavoured types take a value. A
Point, atime.Time, anRGBcolour, aMoneyamount: fine to copy, often immutable in spirit, and do not benefit from shared identity. Passing by value keeps functions easy to reason about. -
Big types (rough rule: more than six or eight fields, or containing arrays) usually take a pointer even for read-only work, just to avoid the copy cost on every call. You can see the size difference directly:
import "unsafe" type BigRecord struct { ID int64 Name string Email string Phone string Address string City string Country string CreatedAt int64 UpdatedAt int64 Fingerprint [32]byte } var b BigRecord fmt.Println(unsafe.Sizeof(b)) // ~192 bytes (exact number depends on alignment) fmt.Println(unsafe.Sizeof(&b)) // 8 bytes on a 64-bit machineEvery call that passes
bby value copies all those bytes into the callee's frame. Passing&bcopies one machine word, regardless of how much data the struct carries. The ratio matters less for small types (copying a 16-bytePointis effectively free) and a lot for large ones inside hot loops. -
Be consistent within a single type. If one function on
Usertakes*User, every function onUsershould, even the read-only ones. Mixing pointer and value signatures on the same type leads to subtle bugs (more on this when you meet methods next chapter).
When in doubt, take *T. Most real Go code uses pointers for its domain types (*User, *Order, *Request) and plain values only for small, arithmetic-flavoured types (Point, time.Duration, Money). Start with the pointer form and only peel back to a value when you have a specific reason: immutability, cheap copy semantics, or using the type as a map key (covered in the Comparing structs lesson).
How this shapes a real codebase
In most Go services, the big domain types live in their own package, are created with a constructor (NewUser(...) that returns *User), and travel through the system as pointers. HTTP handlers take *http.Request and return responses through http.ResponseWriter; database rows are scanned into *User; business logic functions take *Order and mutate it. You hardly ever see bare User values for these types, because changes to them need to be visible to every caller.
Small value types (a Range, a Duration, a Money) travel by value. They are built with literals and compared with == and do not have a single "canonical" instance.
This split will feel natural after a few weeks of writing Go. For now, the pattern worth memorising is: big-deal domain types → *T; small value-flavoured types → T.
The starter defines:
type Counter struct {
Name string
Value int
}
Write two functions:
inc(c *Counter)that incrementsc.Valueby one through the pointer. It must modify the caller's counter.snapshot(c Counter) Counterthat takes a counter by value and returns a new counter with the sameNamebutValuedoubled. It must not modify the caller's counter.
In main, the starter already:
- Builds
hits := &Counter{Name: "hits", Value: 5}. - Calls
inc(hits). - Calls
snap := snapshot(*hits). - Prints
hits.Value, thensnap.Value.
With your implementation, the expected output is:
6
12
hits.Value went from 5 to 6 because inc modified it through the pointer. snap.Value is 12 because snapshot received a copy of the counter (at Value: 6) and doubled it. The copy's doubling did not leak back into hits.
6 12