I am tired of reading Go code where some struct type gets treated as a value, only to be shoved in a pointer later, to satisfy some mutation corner case.
type Order struct { ID string; Status string; ... }
orders := []Order{{ID: "1", Status: "pending"}, ...}
for _, o := range orders {
o.Status = "shipped" // does nothing!
}
// Later: oh, we need mutation
for i := range orders {
orders[i].Status = "shipped" // awkward indexing
validateOrder(&orders[i]) // taking addresses everywhere
}
Or worse, seeing a bunch of model-like structs, where some are used as values and some as pointers, with no rhyme or reason.
Every Go developer eventually stumbles upon “the pointer debate.”
Should a type be a value or a pointer? Should I use []T or []*T? Should my function take T or *T?
Most people would say you should decide on a case-by-case basis, and that’s probably reasonable for high-performance code. But most of us don’t write software that needs that level of optimization. We deal with business logic, CRUD apps, HTTP handlers, and so on. And no, the argument that Go isn’t the right tool for that job doesn’t stick, either. Go is absolutely fantastic for non-performance-critical applications, and being fast and simple out of the box just adds more motivation to use it everywhere. In fact, outside our Java systems, I’ve been pushing Go for most new services in my org.
But back to the original question - should a type T be a value or a pointer? It’s that decision fatigue that’s killing all the fun.
So, here is a simple set of rules that I use in all my Go projects to eliminate the debate altogether. You can use it, or not use it - I don’t care.
Is it a primitive? Use value semantics. Always! #
But what about expressing optionality?
You have zero values for that, and they are honestly OK for most situations. If you ever need to express “not set” explicitly, it’s a sign that you might be better off shoving that primitive into a struct together with other fields, where now the zero value of that struct tells you whether something is set or not.
type RetryConfig struct {
MaxRetries int // 0 can be a legitimate choice: "don't retry"
BackoffMillis int // 0 can be legitimate: "retry immediately"
}
Each field can be zero, but together they express a meaningful configuration. The zero value of RetryConfig means “no retries.”
Is it a struct? Pointer-first (but not pointer-always). #
My experience has shown that most of the struct types in my apps grow to a point that they either cross the boundary of what’s considered a small struct, or that they have fields that are non-primitives (e.g., slices, maps, other structs). Many of them are also used in places where mutation is expected, so why bother? For anything that feels like what I would call a “model” or “entity” in a typical business app, I just go with pointer semantics everywhere. Period.
But the performance!
Trust me, unless you are at Google-scale or working on a system-level software, performance won’t ever be an issue. Ever! Believe me, you’d be happy if performance actually was the bottleneck of your application, and not the business.
Most other languages like Java, C#, Python, Ruby, and so on, don’t even give you the choice. Everything is a reference. You don’t have to think about it. You don’t have to debate it in code reviews. You just write your code and ship it.
Go made it explicit. Now you have to decide. Every single time you write a function signature. Every single type.
But the fact that Go gave you a choice doesn’t mean you have to try and wrap your head around every single case. You don’t. Just pick a convention and stick to it. It’s not like you are breaking the language or going against Go’s philosophy.
Pointer-First != Pointer-Always. #
Note that I said “pointer-first,” not “pointer-always.” There are still cases where small structs with only primitive fields can be used as values. For example, a Point struct with just X and Y coordinates can be a value. Go’s time.Time is another great example of a struct that is small enough to be used as a value. Your codebase may have such types too. Anything that is 2-3 primitive fields and it feels like a throwaway read value can be a value.
That’s really all there is to it.
Is this dogmatic? Yes.
Is it easier than debating every type? Also yes.
The moment your struct grows beyond a couple of primitives, default to pointers. Slices become []*T. Functions take *T. Methods use *T receivers. If you see User anywhere in the codebase, you know it’s pointer-based. No checking. No guessing.
Like I said, you can choose to ignore this advice. It may not even apply to you. But if you only take one thing away from this post, let it be this:
It’s not the performance. It’s the mental overhead.
Have something to say? Join the discussion below 👇
Want to explore instead? Fly with the time capsule 🛸
You may also find these interesting
Introducing gomjml: MJML for Go Developers
gomjml is a native Go implementation of the MJML email framework, making responsive email design faster and easier for Go developers.
Why I Made Peace With Go’s Date Formatting
If we’re all going to google it anyway, we might as well google something that makes sense.
The Two Reasons I Prefer Passing Struct Pointers Around
Choosing consistency over performance.


