DEV Community

Shrijith Venkatramana
Shrijith Venkatramana

Posted on

5 2 2 2 3

Making the Best of Pointers in Go

Pointers in Go can feel like a puzzle for developers coming from languages like Python or JavaScript. They’re powerful, but they can trip you up if you don’t get how they work. Go’s approach to pointers is straightforward, yet it demands a clear understanding to use them effectively. This guide dives deep into how pointers work in Go, with practical examples, tables, and tips to make them your ally. Let’s break it down step by step.

Why Pointers Matter in Go

Pointers let you work directly with memory addresses, which can optimize performance and allow precise control over data. In Go, they’re a core feature for passing data efficiently and modifying values in functions. Unlike C, Go simplifies pointer usage—no pointer arithmetic, no dangling pointers—but you still need to know when and why to use them.

Here’s the deal: Go uses pass-by-value by default. When you pass a variable to a function, Go copies it. Want to modify the original? That’s where pointers shine. They let you pass a reference to the data instead of a copy, saving memory and enabling changes to persist.

Official Go documentation on pointers gives a quick overview if you want the formal take.

Key Points

  • Pointers reference memory addresses, not the data itself.
  • Go’s pointers are safe—no manual memory management.
  • Use pointers for efficiency or to modify original data.

Declaring and Using Pointers: The Basics

A pointer in Go is declared with the * operator, and you get a variable’s address with &. The syntax is simple but takes practice to feel natural.

Here’s a basic example:

package main

import "fmt"

func main() {
    x := 42
    var p *int = &x // p is a pointer to x
    fmt.Println("Value of x:", x)          // Output: Value of x: 42
    fmt.Println("Address of x:", &x)       // Output: Address of x: 0x...
    fmt.Println("Value at pointer:", *p)   // Output: Value at pointer: 42
    *p = 100                               // Change x via the pointer
    fmt.Println("New value of x:", x)      // Output: New value of x: 100
}
Enter fullscreen mode Exit fullscreen mode

In this code:

  • x is an integer.
  • p is a pointer to x’s memory address (&x).
  • *p dereferences the pointer to access or modify x’s value.

Key takeaway: The * operator is your gateway to the value at a pointer’s address. Without it, you’re just messing with the address itself.

When to Use Pointers vs. Values

Deciding between pointers and values depends on your use case. Here’s a table to clarify:

Scenario Use Pointers Use Values
Modifying original data Yes No
Large structs Yes (avoid copying) No
Small primitives (int, bool) Rarely Yes
Slices, maps, channels No (they’re already references) Yes
Nil checks needed Yes No

For small data like integers, passing by value is fine—copying is cheap. For big structs, pointers save memory by avoiding copies. Slices and maps? They’re already reference types, so pointers are often unnecessary.

Example of modifying a struct with a pointer:

package main

import "fmt"

type Person struct {
    Name string
    Age  int
}

func updateAge(p *Person, newAge int) {
    p.Age = newAge // Modifies the original struct
}

func main() {
    person := Person{Name: "Alice", Age: 30}
    fmt.Println("Before:", person) // Output: Before: {Alice 30}
    updateAge(&person, 31)
    fmt.Println("After:", person)  // Output: After: {Alice 31}
}
Enter fullscreen mode Exit fullscreen mode

Go’s blog on slices explains why slices don’t need pointers.

Pointers and Structs: A Perfect Match

Structs are where pointers really shine. Copying a large struct can be expensive, so passing a pointer is often smarter. Plus, if you want a function to update a struct’s fields, you need a pointer.

Here’s an example with a more complex struct:

package main

import "fmt"

type Employee struct {
    ID        int
    Name      string
    Salary    float64
    IsActive  bool
}

func giveRaise(emp *Employee, amount float64) {
    emp.Salary += amount
}

func main() {
    emp := Employee{ID: 1, Name: "Bob", Salary: 50000, IsActive: true}
    fmt.Println("Before raise:", emp.Salary) // Output: Before raise: 50000
    giveRaise(&emp, 5000)
    fmt.Println("After raise:", emp.Salary)  // Output: After raise: 55000
}
Enter fullscreen mode Exit fullscreen mode

Key tip: Always use pointers when modifying struct fields in functions. Without them, you’re just changing a copy.

Nil Pointers: Avoiding the Panic

A common gotcha is the nil pointer dereference, which crashes your program. A pointer that’s declared but not initialized points to nil. Dereferencing it? Boom, panic.

Here’s an example of what not to do:

package main

import "fmt"

func main() {
    var p *int
    fmt.Println("Pointer:", p) // Output: Pointer: <nil>
    *p = 42                   // Panic: runtime error: invalid memory address or nil pointer dereference
    fmt.Println(*p)
}
Enter fullscreen mode Exit fullscreen mode

To avoid this:

  • Always initialize pointers before dereferencing.
  • Check for nil if you’re unsure.

Safe example:

package main

import "fmt"

func increment(p *int) {
    if p == nil {
        fmt.Println("Cannot increment: pointer is nil")
        return
    }
    *p++
}

func main() {
    var x int = 10
    increment(&x)
    fmt.Println("Value:", x) // Output: Value: 11

    var p *int
    increment(p) // Output: Cannot increment: pointer is nil
}
Enter fullscreen mode Exit fullscreen mode

Pro tip: Add nil checks in functions that accept pointers to prevent crashes.

Pointers with Methods: Receiver Types

In Go, methods can have pointer or value receivers. A pointer receiver lets a method modify the original struct, while a value receiver works on a copy.

Here’s an example contrasting both:

package main

import "fmt"

type Counter struct {
    Value int
}

// Value receiver: doesn't modify original
func (c Counter) IncrementValue() {
    c.Value++
}

// Pointer receiver: modifies original
func (c *Counter) IncrementPointer() {
    c.Value++
}

func main() {
    c := Counter{Value: 10}
    c.IncrementValue()
    fmt.Println("After value receiver:", c.Value) // Output: After value receiver: 10
    c.IncrementPointer()
    fmt.Println("After pointer receiver:", c.Value) // Output: After pointer receiver: 11
}
Enter fullscreen mode Exit fullscreen mode

When to use pointer receivers:

  • To modify the receiver’s state.
  • For large structs to avoid copying.
  • For consistency if other methods on the type use pointers.

Effective Go has more on receiver types.

Pointers and Performance: When They Save the Day

Pointers can boost performance by reducing memory usage. Copying large structs or arrays is costly, but passing a pointer is just passing an address (8 bytes on 64-bit systems). Here’s an example showing the difference:

package main

import "fmt"

type BigStruct struct {
    Data [1000]int
}

func processValue(s BigStruct) {
    s.Data[0] = 999
}

func processPointer(s *BigStruct) {
    s.Data[0] = 999
}

func main() {
    s := BigStruct{}
    processValue(s)
    fmt.Println("After value:", s.Data[0]) // Output: After value: 0
    processPointer(&s)
    fmt.Println("After pointer:", s.Data[0]) // Output: After pointer: 999
}
Enter fullscreen mode Exit fullscreen mode

Key insight: For large data, pointers avoid expensive copies and enable modifications. For small data, the overhead of dereferencing might outweigh the benefits—test it!

Common Pitfalls and How to Avoid Them

Pointers are powerful but tricky. Here are common mistakes and fixes:

Mistake Fix
Dereferencing nil pointers Check for nil before dereferencing
Overusing pointers for small types Use values for small primitives
Forgetting & when passing to pointer params Double-check function signatures
Modifying slices thinking they’re pointers Understand slices are reference types

Example of a slice gotcha:

package main

import "fmt"

func modifySlice(s []int) {
    s[0] = 100
}

func main() {
    s := []int{1, 2, 3}
    modifySlice(s)
    fmt.Println("Modified slice:", s) // Output: Modified slice: [100 2 3]
}
Enter fullscreen mode Exit fullscreen mode

Slices are references, so you don’t need pointers here. But appending to a slice might not work as expected if the underlying array’s capacity changes—another topic for another day.

Putting Pointers to Work: Practical Tips

Pointers are a tool, not a mystery. Here’s how to use them effectively:

  • Use pointers for mutability: If a function needs to change a variable or struct, pass a pointer.
  • Optimize for large data: Pass pointers to big structs or arrays to avoid copying.
  • Keep it simple: Don’t overuse pointers for small types like int or bool—values are often fine.
  • Test for nil: Always check pointers in functions to avoid panics.
  • Leverage pointer receivers: Use them for methods that modify structs or for performance with large types.

Here’s a final example combining everything:

package main

import "fmt"

type Product struct {
    Name  string
    Price float64
}

func applyDiscount(prod *Product, discount float64) {
    if prod == nil {
        fmt.Println("Error: Product is nil")
        return
    }
    prod.Price -= discount
}

func (p *Product) String() string {
    return fmt.Sprintf("%s: $%.2f", p.Name, p.Price)
}

func main() {
    laptop := &Product{Name: "Laptop", Price: 1000}
    applyDiscount(laptop, 100)
    fmt.Println(laptop.String()) // Output: Laptop: $900.00

    var nilProd *Product
    applyDiscount(nilProd, 50) // Output: Error: Product is nil
}
Enter fullscreen mode Exit fullscreen mode

This code shows a pointer receiver for a method, a nil check, and practical pointer usage. Run it, and it’s rock-solid.

Pointers in Go aren’t scary once you get the hang of them. They’re about control and efficiency. Practice with small examples, lean on nil checks, and use them where they make sense—your Go code will thank you.

Warp.dev image

Warp is the highest-rated coding agent—proven by benchmarks.

Warp outperforms every other coding agent on the market, and gives you full control over which model you use. Get started now for free, or upgrade and unlock 2.5x AI credits on Warp's paid plans.

Download Warp

Top comments (1)

Collapse
 
aabhassao profile image
Aabhas Sao

Great Read ✨

Gen AI apps are built with MongoDB Atlas

Gen AI apps are built with MongoDB Atlas

MongoDB Atlas is the developer-friendly database for building, scaling, and running gen AI & LLM apps—no separate vector DB needed. Enjoy native vector search, 115+ regions, and flexible document modeling. Build AI faster, all in one place.

Start Free

👋 Kindness is contagious

Discover this thought-provoking article in the thriving DEV Community. Developers of every background are encouraged to jump in, share expertise, and uplift our collective knowledge.

A simple "thank you" can make someone's day—drop your kudos in the comments!

On DEV, spreading insights lights the path forward and bonds us. If you appreciated this write-up, a brief note of appreciation to the author speaks volumes.

Get Started