DEV Community

Pavel Sanikovich
Pavel Sanikovich

Posted on

Go From Zero to Depth — Part 4: Pointers in Go (Not Scary, Just Misunderstood)

Pointers are where many beginners quietly lose confidence. Even developers with years of experience in other languages often treat pointers in Go with suspicion. They either avoid them entirely or overuse them without understanding the consequences. Both approaches miss the point. In Go, pointers are neither dangerous nor advanced. They are simply the language’s way of expressing ownership and lifetime.

The confusion usually comes from baggage. In C or C++, pointers are a source of power and catastrophe at the same time. They allow arithmetic, manual memory management, and undefined behavior. In Go, all of that is gone. You cannot move a pointer arbitrarily. You cannot free memory manually. You cannot corrupt the heap through pointer tricks. What remains is a safe, constrained tool with a very specific purpose.

A pointer in Go does exactly one thing: it allows multiple parts of a program to refer to the same value. That’s it. No magic. No danger. Just shared access.

Consider the most basic example:

func increment(x int) {
    x++
}

func incrementPtr(x *int) {
    (*x)++
}
Enter fullscreen mode Exit fullscreen mode

Calling the first function changes nothing outside its scope. Calling the second modifies the caller’s value. The difference is not about mutability; it’s about ownership. The value version receives a copy. The pointer version receives access to the original.

Once you see pointers as a question of ownership, many rules that felt arbitrary suddenly make sense.

This is why Go encourages passing small structs by value. When you pass a value, you make ownership explicit. The function receives its own copy and cannot accidentally modify shared state. This leads to code that is easier to reason about, easier to test, and easier to parallelize.

Now compare these two functions:

func process(u User) {
    u.Name = "Alice"
}
Enter fullscreen mode Exit fullscreen mode

and

func processPtr(u *User) {
    u.Name = "Alice"
}
Enter fullscreen mode Exit fullscreen mode

The first function modifies only its local copy. The second modifies shared state. Neither is wrong. But the second carries more responsibility. Any function that accepts a pointer is making a promise: “I might change this.” In Go, that promise is visible in the function signature. That visibility is intentional.

Pointers also interact directly with memory lifetimes, which you saw in Part 3. Returning a pointer means extending lifetime. Capturing a pointer in a closure means sharing state. Passing pointers between goroutines means introducing synchronization concerns. None of these are flaws; they are explicit tradeoffs.

A common beginner question is whether pointers are faster. The honest answer is: sometimes, and often not in the way you expect. Copying a small struct is cheap. Copying a pointer may force a heap allocation. The real cost is not the pointer itself, but the lifetime extension it causes.

This is why Go APIs often look “odd” to newcomers. For example, methods on slices and maps rarely use pointers to slices or maps. That’s because slices and maps already contain internal pointers. Passing them by value still shares underlying data. A pointer to a slice is usually unnecessary and often harmful.

Consider this:

func add(nums []int) {
    nums[0] = 42
}
Enter fullscreen mode Exit fullscreen mode

Even though nums is passed by value, the underlying array is shared. The pointer semantics are already there, hidden inside the slice header. Adding an extra pointer layer does not give you more power; it only complicates lifetimes.

Another area where pointers confuse beginners is method receivers. Go allows both value and pointer receivers, and many people assume one is “more correct” than the other. In reality, the choice expresses intent.

A value receiver means the method does not modify the receiver’s logical state. A pointer receiver means it might. The compiler will automatically take the address or dereference as needed, so this is not about convenience. It’s about communication.

This becomes especially important in concurrent programs. Shared mutable state is the hardest problem in software. Go’s pointer rules make sharing explicit. When you pass a pointer into a goroutine, you are saying: “This value will be accessed concurrently.” That statement alone should make you think about synchronization, ownership, and boundaries.

One of the reasons Go feels simple is that it avoids cleverness. Pointers are not overloaded with meaning. They do not unlock hidden capabilities. They simply make sharing visible. And visibility is the foundation of maintainable systems.

Beginners often try to avoid pointers entirely, hoping to stay in a “safe zone.” Ironically, this leads to worse code. The goal is not to avoid pointers but to understand them well enough to use them intentionally. When you do, pointers stop being scary and start being descriptive.

By now, you should see a clear chain forming. Go’s memory model is based on lifetimes. Escape analysis enforces those lifetimes. Pointers express shared ownership and extended lifetimes. Together, they form a coherent system. Nothing is accidental.

In the next part, we’ll step into concurrency. We’ll see how goroutines interact with memory, why some race conditions feel invisible, and how Go’s concurrency model builds on everything you’ve learned so far. Concurrency in Go looks easy — until you understand what’s actually happening.

And that’s where the real depth begins.

Want to go further?

This series focuses on understanding Go, not just using it.

If you want to continue in the same mindset, Educative is a great next step.

It’s a single subscription that gives you access to hundreds of in-depth, text-based courses — from Go internals and concurrency to system design and distributed systems. No videos, no per-course purchases, just structured learning you can move through at your own pace.

👉 Explore the full Educative library here

Top comments (0)