In our startup, we use Go for almost everything — from small microservices to batch jobs and internal tools. The language gives us simplicity, predictability, and speed. But sometimes, that simplicity hides subtle traps.
A few weeks ago, one of our developers was debugging a strange issue. A struct that represented user session data was being modified inside a function, but the change didn’t persist after the call. They were sure Go passed it by reference. “It’s an object, right? So it should behave like Java or JavaScript.”
Except, it doesn’t.
That bug turned into a small teaching moment for the team — a reminder that in Go, everything is passed by value. Even when it looks like it isn’t.
The Illusion of “By Reference”
Here’s a minimal version of what happened:
type Session struct {
UserID string
Active bool
}
func deactivate(s Session) {
s.Active = false
}
func main() {
s := Session{UserID: "u123", Active: true}
deactivate(s)
fmt.Println(s.Active) // still true
}
To a developer coming from Python, Java, or JavaScript, this looks wrong. We called a function that modifies a field, so why didn’t it change?
Because Go doesn’t pass the struct itself — it passes a copy of the struct. The deactivate
function gets its own version of Session
. Changing it has no effect on the original.
This is where many Go beginners (and sometimes mid-level engineers) get tricked. The code looks like reference semantics, but under the hood, it’s pure value semantics.
But Wait… Maps and Slices Behave Differently
This confusion grows when developers notice that some types do seem to behave by reference.
Take this example:
func update(m map[string]int) {
m["x"] = 42
}
func main() {
m := map[string]int{"x": 0}
update(m)
fmt.Println(m["x"]) // prints 42
}
Now it works as expected — the change persists.
So what’s happening here? Did Go suddenly switch to passing by reference?
Nope. Go still passes by value, but the value of a map
(or slice
or channel
) is itself a small header structure containing pointers to underlying data. When you copy that header, both copies still point to the same data in memory.
That’s why modifying a map or slice inside a function appears to modify it “by reference.” But if you reassign the map or slice variable itself inside the function, the outer one won’t change — because the header copy doesn’t propagate back.
The Trap in Real Code
In our case, the issue wasn’t obvious. The struct was stored inside a map. The developer assumed updating the struct value through the map would work like mutating an object in other languages.
Here’s what the problematic pattern looked like (simplified):
sessions := map[string]Session{
"user1": {UserID: "user1", Active: true},
}
s := sessions["user1"]
s.Active = false
fmt.Println(sessions["user1"].Active) // still true!
The reason?
sessions["user1"]
returns a copy of the Session
value stored in the map. When you modify s
, you’re modifying that copy, not the one inside the map.
The fix is to explicitly write the modified value back:
s := sessions["user1"]
s.Active = false
sessions["user1"] = s
Or, better yet, store a pointer to Session
in the map from the start:
sessions := map[string]*Session{
"user1": {UserID: "user1", Active: true},
}
sessions["user1"].Active = false
fmt.Println(sessions["user1"].Active) // now false
The “Ah-Ha” Moment
When I showed this to the team, the reactions were mixed.
Half of them said, “That makes sense now.”
The other half said, “Wait, so when do I need a pointer again?”
That’s the tricky part — Go doesn’t force you to think in terms of “reference vs. value,” but it requires you to understand when copying happens.
In small programs, copying structs is fine. But in large systems, subtle copies can lead to:
- updates silently not persisting,
- performance issues from large value copies,
- concurrency bugs when copies are shared between goroutines.
Understanding that every assignment or parameter passing copies a value — and knowing what that value contains — is essential Go literacy.
How to Avoid These Gotchas
I tell our team a simple rule of thumb:
In Go, ask yourself, what exactly am I copying?
If it’s a small struct — maybe fine.
If it’s a map, slice, or channel — remember you’re copying a header that still points to shared data.
If it’s a large struct or something you expect to mutate — use a pointer.
Explicitly using *T
or &T
makes your intent clear, avoids confusion, and prevents silent bugs caused by unintentional copies.
Even better, use immutable patterns when possible. Treat structs as values that you replace, not mutate. This keeps code predictable and safe across goroutines.
Why This Matters in Startups
In big enterprises, a bug like this might just get buried in a ticket. In a startup, it stops your feature from shipping. We don’t have the luxury of slow debugging cycles or mysterious state issues.
Go gives us simplicity, but it’s a simplicity that demands understanding the details. Once the team internalized how value semantics really work, we stopped seeing these subtle bugs — and we got faster at reasoning about code.
It’s one of those lessons that every Go developer learns eventually. The sooner you learn it, the fewer headaches you’ll have.
Go never passes by reference.
It always passes by value.
But sometimes that value contains a pointer.
Know the difference — your future self (and your startup) will thank you.
Stop Wasting Hours: Docker Skills Every Front-End Engineer Needs
Level up your Go skills with interactive courses on Educative.io
Top comments (0)