Go Pointers, Properly: From Basic Syntax to Real-World Semantics
Pointers are one of those topics that sound more intimidating than they actually are.
At a syntax level, they are not hard.
You take an address with &, dereference with *, and move on.
But that is not where most confusion comes from.
The real confusion starts later, when you begin writing actual Go code and notice things like this:
- pointer receiver methods can still be called on non-pointer values
- some types seem to behave like references even without explicit pointers
- a repository mutates something in one version of the code, but not in another
- pointers sometimes help performance, and sometimes make things worse
nilcan mean “missing,” “optional,” or “bug,” depending on what you designed
That is the real pointer problem in Go.
Not syntax. Semantics.
This article is not about treating pointers like some low-level magic trick. It is about understanding what they mean in actual Go code, when they matter, when they do not, and how to make better design decisions around them.
Start with the simple part: what a pointer is
A pointer is a value that stores the address of another value.
That is it.
package main
import "fmt"
func main() {
x := 42
p := &x
fmt.Println(x) // 42
fmt.Println(p) // memory address
fmt.Println(*p) // 42
*p = 99
fmt.Println(x) // 99
}
Here:
xstores the integer value&xgives you the address ofxpstores that address*pgives you the value at that address
The last two lines matter the most:
*p = 99
fmt.Println(x) // 99
That is the moment when pointers stop being abstract. You are no longer working with a copy of the value. You are working with the original thing by following its address.
That part is simple.
The harder part is understanding when that difference should matter in a codebase.
Value semantics vs pointer semantics
This is where pointer discussions become useful.
Go is primarily a value-semantics language.
That means when you assign or pass something, you usually work with a copy.
package main
import "fmt"
type Config struct {
TimeoutSeconds int
}
func updateByValue(c Config) {
c.TimeoutSeconds = 60
}
func updateByPointer(c *Config) {
c.TimeoutSeconds = 60
}
func main() {
cfg := Config{TimeoutSeconds: 30}
updateByValue(cfg)
fmt.Println(cfg.TimeoutSeconds) // 30
updateByPointer(&cfg)
fmt.Println(cfg.TimeoutSeconds) // 60
}
updateByValue gets a copy.
updateByPointer gets access to the original object.
That sounds obvious, but it has a big design consequence:
Using a pointer is not just a syntax choice. It is a decision to work with shared state instead of copied data.
That is why pointer usage affects API design, method receivers, repository flows, and reasoning about the code itself.
The first real use of pointers: shared mutation
The most direct reason to use a pointer is this:
You want changes made in one place to remain visible somewhere else.
package main
import "fmt"
type Wallet struct {
Balance int
}
func ApplyRefund(w *Wallet, amount int) {
if amount <= 0 {
return
}
w.Balance += amount
}
func main() {
wallet := Wallet{Balance: 100}
ApplyRefund(&wallet, 25)
fmt.Println(wallet.Balance) // 125
}
This is a clean use case.
The function is supposed to modify existing state. A pointer communicates that honestly.
That part is not controversial.
Where people start getting confused is when pointer receiver methods work even if the original variable is not a pointer.
One of Go’s most misleading conveniences
Consider this:
package main
type User struct {
Name string
}
func (u *User) Rename(name string) {
u.Name = name
}
func main() {
u := User{Name: "Berkan"}
u.Rename("Ada")
}
This works.
But Rename has a pointer receiver.
So why does calling it on u, which is not a pointer, still compile?
Because Go helps you here. If the value is addressable, the compiler can effectively treat this as:
(&u).Rename("Ada")
That is convenient.
It is also one of the biggest reasons people misunderstand pointers in Go.
A lot of developers see this and internalize the wrong lesson:
“If Go already does that for me, then maybe value and pointer usage are basically interchangeable.”
They are not.
This is just method-call sugar.
It only helps with the call itself. It does not turn a value-based design into a pointer-based design.
That distinction matters a lot once the value starts moving through your system.
Method-call sugar is not pointer semantics
This is the key point many pointer explanations miss.
The question is not:
Can I call a pointer receiver method on this value?
The real question is:
Should this thing behave like a copied value, or should it live as a shared object with identity?
Those are not the same thing.
To see the difference clearly, repository-style code is a much better example than a toy setter.
A repository example makes the difference obvious
Here is the first version:
package main
import "fmt"
type User struct {
ID int
Name string
}
func (u *User) Rename(name string) {
u.Name = name
}
type UserRepo struct{}
func (r UserRepo) Save(u User) {
u.ID = 100
u.Name = "saved:" + u.Name
fmt.Println("inside repo:", u)
}
func main() {
u := User{Name: "Berkan"}
u.Rename("Ada")
repo := UserRepo{}
repo.Save(u)
fmt.Println("outside repo:", u)
}
At first glance this feels like a pointer-heavy flow because u.Rename("Ada") worked through a pointer receiver.
But repo.Save(u) still receives a copy.
That means the changes made inside the repository do not affect the original user outside it.
Now compare that to this version:
package main
import "fmt"
type User struct {
ID int
Name string
}
func (u *User) Rename(name string) {
u.Name = name
}
type UserRepo struct{}
func (r UserRepo) Save(u *User) {
u.ID = 100
u.Name = "saved:" + u.Name
fmt.Println("inside repo:", *u)
}
func main() {
u := &User{Name: "Berkan"}
u.Rename("Ada")
repo := UserRepo{}
repo.Save(u)
fmt.Println("outside repo:", *u)
}
Here, the repository and the caller operate on the same object.
That is the real meaning of choosing &User{}.
Not “I want methods to work.” They already do.
The real meaning is:
- this object will move through layers
- multiple parts of the system should observe the same state
- changes should remain visible across boundaries
- this thing has identity, not just data
That is why u := User{} and u := &User{} are not interchangeable, even if both may let you call the same method.
So when should you start with T{} vs &T{}?
This is the practical question.
The short answer is:
Start with T{}
when the thing mostly behaves like data.
Start with &T{}
when the thing mostly behaves like a shared object with identity.
That sounds abstract, so let’s make it concrete.
Value-like types: good candidates for T{}
Some types are better understood as values than as identities.
Think about:
- money
- coordinates
- filters
- date ranges
- pagination options
- small configuration snapshots
These are not really “entities.” They are pieces of data.
A good sign is this: if copying them would be harmless, value semantics are probably a good fit.
package main
import "fmt"
type SearchFilter struct {
Query string
Limit int
Offset int
}
func (f SearchFilter) WithLimit(limit int) SearchFilter {
f.Limit = limit
return f
}
func main() {
base := SearchFilter{
Query: "golang",
Limit: 20,
}
next := base.WithLimit(50)
fmt.Println(base.Limit) // 20
fmt.Println(next.Limit) // 50
}
This is a nice value-oriented flow.
basestays unchangednextis a derived value- there is no shared mutable state
- the behavior is easy to reason about
In these cases, using &T{} up front often adds more noise than value.
Entity-like types: good candidates for &T{}
Now think about types like:
UserOrderCartSessionConnection- aggregate roots in a domain model
These are not just data bags. They usually have lifecycle, mutation, persistence, and identity.
If multiple parts of the system should be talking about the same user, the same cart, or the same session, pointer semantics usually make more sense.
package main
import (
"fmt"
"strings"
)
type User struct {
ID int
Name string
Email string
}
func (u *User) Normalize() {
u.Name = strings.TrimSpace(u.Name)
u.Email = strings.TrimSpace(strings.ToLower(u.Email))
}
type UserRepo struct{}
func (r *UserRepo) Save(u *User) error {
u.ID = 42
fmt.Printf("saving user: %+v\n", *u)
return nil
}
type EventBus struct{}
func (e *EventBus) PublishUserCreated(id int) {
fmt.Println("event published for user id:", id)
}
type UserService struct {
repo *UserRepo
bus *EventBus
}
func (s *UserService) Register(u *User) error {
if u == nil {
return fmt.Errorf("user is nil")
}
u.Normalize()
if err := s.repo.Save(u); err != nil {
return err
}
s.bus.PublishUserCreated(u.ID)
return nil
}
func main() {
service := &UserService{
repo: &UserRepo{},
bus: &EventBus{},
}
u := &User{
Name: " Ada ",
Email: " ADA@EXAMPLE.COM ",
}
if err := service.Register(u); err != nil {
panic(err)
}
}
This kind of flow is pointer-friendly for a reason:
- the service normalizes the user
- the repository enriches it with an ID
- the event publisher uses that same ID
- the same object evolves across layers
That is not just mutation. That is shared identity across boundaries.
The pointer is not always about mutation
This is worth stating clearly.
People often compress the whole topic into:
“Use pointers when you need mutation.”
That is incomplete.
Sometimes the more important thing is not mutation itself, but shared identity.
A repository flow is a good example. A session object is another. So is in-memory aggregate state.
package main
import "fmt"
type Session struct {
UserID int64
RequestCount int
}
func (s *Session) Touch() {
s.RequestCount++
}
func middleware(s *Session) {
s.Touch()
}
func handler(s *Session) {
s.Touch()
}
func main() {
s := &Session{UserID: 42}
middleware(s)
handler(s)
fmt.Println(s.RequestCount) // 2
}
Here the important part is not just “something changed.”
The important part is that middleware and handler are intentionally operating on the same session object.
That is the real semantic force behind many pointers in real systems.
Pointer receiver does not mean “always initialize with a pointer”
This is another place where subtlety matters.
You can absolutely do this:
u := User{Name: "Berkan"}
u.Rename("Ada")
repo.Save(&u)
That is valid.
So the choice is not always binary between:
- only
T{} - only
&T{}
Sometimes starting with a value and taking its address later is perfectly fine, especially if the object is local at first.
The real difference is one of intent.
This:
u := User{}
often says:
- this starts life as a local value
- I may take its address later if needed
- I am not necessarily designing around shared identity yet
This:
u := &User{}
often says:
- this object is meant to live as a shared instance
- I already know it will move through layers as the same thing
- its identity matters from the start
That difference is small in one function. It becomes much more meaningful in a large codebase.
A better mental model: value object vs entity
If you want a practical way to decide, this distinction is far more useful than “small struct vs big struct.”
Value object mindset
- mostly data
- copying is harmless
- immutable-style usage is often good
- identity does not matter much
Examples:
MoneyDateRangeCoordinatesSearchFilter
Entity mindset
- lifecycle matters
- identity matters
- multiple layers may touch the same thing
- mutations should remain visible
Examples:
UserOrderCartSession
A single system can contain both.
That is exactly how many real systems should look.
Here is a simple example:
package main
import "fmt"
type Money struct {
Amount int64
Currency string
}
func (m Money) Add(other Money) Money {
if m.Currency != other.Currency {
panic("currency mismatch")
}
m.Amount += other.Amount
return m
}
type Order struct {
ID string
Total Money
Paid bool
}
func (o *Order) MarkPaid() {
o.Paid = true
}
type OrderRepository struct{}
func (r *OrderRepository) Save(o *Order) error {
fmt.Printf("saved order: %+v\n", *o)
return nil
}
func main() {
base := Money{Amount: 1000, Currency: "EUR"}
fee := Money{Amount: 100, Currency: "EUR"}
total := base.Add(fee) // value object
order := &Order{
ID: "ORD-1",
Total: total,
}
order.MarkPaid() // entity
repo := &OrderRepository{}
_ = repo.Save(order)
}
Money behaves like a value.
Order behaves like an entity.
That is a much healthier model than trying to force everything into one rule.
Pointer receivers and method sets
This is where the “Go already handles it” intuition breaks down more sharply.
Method-call sugar helps in calls like:
u.Rename("Ada")
But it does not erase differences in method sets.
Consider this:
package main
type Runner interface {
Run()
}
type Task struct{}
func (t *Task) Run() {}
func main() {
var r Runner
var t Task
// r = t // does not compile
r = &t // works
_ = r
}
Even though Go may let you call a pointer receiver method on an addressable value, interface implementation still cares about the actual method set of T versus *T.
That matters in real code.
If a type implements an interface only through pointer receivers, then the pointer form is not optional in those places. You need it.
So yes, the compiler can help with method calls.
No, it does not flatten the semantic distinction between T and *T.
Nil pointers: real use case, but not the main story
Nil pointers matter, but they should not be the center of your pointer mental model.
A pointer can be nil. A value cannot.
That sometimes makes pointers useful for optional presence.
A common example is request DTOs for PATCH-like APIs:
type PatchUserRequest struct {
Name *string `json:"name,omitempty"`
Phone *string `json:"phone,omitempty"`
}
Why use pointers here?
Because for a PATCH request, these are different states:
- field was not provided
- field was provided as an empty string
A plain string cannot distinguish those by itself.
A *string can.
nilmeans “not provided”&""means “provided, explicitly empty”
That is a real and useful use case.
But it is important not to overlearn the wrong lesson.
Pointer usage is not mainly about nil.
nil is one secondary reason among others:
- shared mutation
- shared identity across boundaries
- optional presence or absence
If you treat nil as the main story, you will end up with awkward examples and over-pointered models.
Not every shared-looking behavior comes from explicit pointers
This is one of the biggest sources of confusion in Go.
People learn pointers and then start assuming:
“If something mutates across a boundary, that must mean there is a pointer involved.”
Not necessarily.
Some built-in types already behave in reference-like ways.
Map example
package main
import "fmt"
func Increment(m map[string]int, key string) {
m[key]++
}
func main() {
counts := map[string]int{}
Increment(counts, "go")
fmt.Println(counts["go"]) // 1
}
There is no *map[string]int here.
And yet the change is visible outside the function.
Slice example
Slices are also tricky, because the slice header is copied, but it still points to an underlying array. That means the mental model is not “plain value” in the naive sense.
package main
import "fmt"
func UpdateFirst(items []string) {
if len(items) > 0 {
items[0] = "updated"
}
}
func main() {
items := []string{"a", "b", "c"}
UpdateFirst(items)
fmt.Println(items) // [updated b c]
}
Again, no explicit pointer in the function signature. Still shared-looking behavior.
Channel example
Channels also behave like reference-like handles to shared state and coordination.
So the right takeaway is:
Not every shared behavior in Go comes from explicit pointer syntax.
That matters because it explains why “use pointers when mutating” is too shallow a rule.
Do not add pointers to slices, maps, and channels by reflex
Because of the behavior above, people sometimes overcompensate and start writing things like:
*[]T*map[K]V*chan T
Those are not automatically wrong, but they should not be your default.
For example, this is often more natural:
func AddItem(items []string, item string) []string {
return append(items, item)
}
than this:
func AddItem(items *[]string, item string) {
*items = append(*items, item)
}
The second version can be useful sometimes, but it also increases complexity.
As a general rule, if you feel tempted to add a pointer to a slice, map, or channel, it is worth pausing and asking:
Am I solving a real problem, or am I just doubling down on a pointer-shaped mental model?
One trap worth knowing: pointers to slice elements
Here is a subtle one.
users := []User{
{Name: "Ada"},
{Name: "Linus"},
}
p := &users[0]
users = append(users, User{Name: "Grace"})
That append may allocate a new backing array.
If it does, p still points to the old storage.
This is the kind of thing that can quietly create bugs if you treat pointers to slice elements as long-lived stable references while the slice may still grow.
Pointers are not dangerous because they are mysterious. They are dangerous because they let you make assumptions about identity and stability that may not actually hold.
Another trap: range loops and pointers
This one has bitten many Go developers.
When taking pointers to elements during iteration, you want to be careful about what you are actually taking the address of.
The safer pattern is usually this:
for i := range users {
ptrs = append(ptrs, &users[i])
}
rather than taking the address of a loop variable that may not represent what you think it does.
Loop variable semantics have improved over time, but the higher-level lesson remains useful:
When taking addresses in loops, be explicit about whether you are pointing at the original element or a temporary iteration variable.
That habit is more important than memorizing one historical footgun.
Pointer-heavy code is not automatically faster
This myth survives much longer than it should.
Yes, copying large structs can be expensive. Yes, pointers can help avoid some copying.
But the performance story is not:
- values are slow
- pointers are fast
That is not how it works in Go.
Pointer-heavy code can also mean:
- more heap allocations
- more escape to the heap
- more garbage collection pressure
- worse locality
- more indirect access
- harder reasoning
In some cases, pointer usage is the right performance move. In others, it is just premature optimization in a lower-level costume.
If performance matters, measure it.
And if you are curious whether something escapes to the heap, go build -gcflags="-m" is worth knowing. It can reveal a lot about how your choices affect allocation behavior.
The important thing is not to turn pointer usage into a performance religion.
Method receiver consistency matters
Once you decide a type is mostly pointer-oriented, it is usually a good idea to keep receiver choice consistent.
Mixing value and pointer receivers is legal, but it can make a type harder to reason about, especially in larger codebases.
This is not automatically bad:
type User struct {
Name string
}
func (u User) DisplayName() string {
return u.Name
}
func (u *User) Rename(name string) {
u.Name = name
}
But it does raise the question:
Is this type mostly a value, or mostly a shared object?
If you already know the type is entity-like and pointer-oriented, keeping receivers consistently pointer-based often makes the design easier to read.
Consistency is not a law. It is a readability tool.
A good practical checklist
When deciding between value and pointer usage, these questions are usually more helpful than memorizing rules.
Lean value-first when:
- the type is mostly data
- copying it is harmless
- immutable-style usage improves reasoning
- the object is local and short-lived
- identity does not matter much
Lean pointer-first when:
- the type has lifecycle or identity
- multiple layers should observe the same object
- mutations should remain visible across boundaries
- the type is optional and nil is meaningful
- interface expectations depend on pointer receivers
- the object is clearly entity-like
And if you are not sure, this is still a good default:
Start with values when the type behaves like data. Use pointers deliberately when the type behaves like a shared object.
That is a far stronger rule than “always use pointers for mutation” or “always use values unless performance matters.”
Best practices that actually hold up
Here is the version I would trust in a real codebase.
- Do not confuse method-call sugar with pointer semantics
u.Rename() compiling does not mean u is now “basically a pointer.”
- Use pointers when identity and shared state actually matter
Not just because a method mutates, but because the same object should remain visible across layers.
- Keep value objects value-like
Types such as Money, DateRange, or SearchFilter often get worse when turned into shared mutable objects.
- Do not use pointers as a default performance hack
Measure first.
- Treat nil as a semantic decision, not an accident
If nil is meaningful, design around it clearly. If it is a bug, do not silently tolerate it.
- Be careful with slices, maps, and channels
Their behavior is already more reference-like than many people expect.
- Be explicit when taking addresses in loops or collections
Especially when those references may outlive the moment they were created.
- Prefer consistency per type
A type that mostly behaves like an entity should usually look like one across its method set.
Final thought
Pointers are not hard because & and * are hard.
Pointers are hard because they force you to answer a design question:
Is this thing just data, or is it a shared object with identity?
That is the real divide.
Once you see that clearly, most pointer decisions stop feeling magical.
A value is something you can copy without changing the meaning. A pointer is usually a decision that meaning now depends on sharing.
And that is why u := User{} and u := &User{} may both compile, may both let you call the same method, and may still represent very different designs.
That is the part worth understanding.
Not the operator. The semantics.