Skip to content
ioob.dev
Go back

Go Part 8 — Pointers

· 4 min read
Go Series (8/12)
  1. Go Basics Part 1 — Variables, Constants, and Types
  2. Go Basics Part 2 — Conditionals and Loops
  3. Go Basics Part 3 — Functions
  4. Go Basics Part 4 — Error Handling
  5. Go Basics Part 5 — Arrays, Slices, and Maps
  6. Go Basics Part 6 — Structs and Methods
  7. Go Part 7 — Interfaces
  8. Go Part 8 — Pointers
  9. Go Part 9 — Goroutines and Channels
  10. Go Part 10 — Concurrency Patterns
  11. Go Part 11 — Packages and Modules
  12. Go Part 12 — Generics and Practical Patterns
Table of contents

Table of contents

Why Deal with Addresses

Go passes values by copy by default. When you pass a variable to a function, a copy is made, and no matter how much the function changes it, the original is unaffected.

package main

import "fmt"

func tryChange(x int) {
    x = 100
}

func main() {
    n := 42
    tryChange(n)
    fmt.Println(n) // 42 — unchanged
}

The x that tryChange receives is a copy of n. Setting it to 100 inside the function has no effect on the original n. For a small integer this is fine, but copying a large struct every time can be wasteful in terms of memory and performance. There are also cases where you need to modify the original directly.

This is where pointers come in.

& — Getting the Address

The & operator gets the memory address of a variable.

package main

import "fmt"

func main() {
    x := 42
    p := &x // p holds the address of x

    fmt.Println(x)  // 42
    fmt.Println(p)  // 0xc0000b6010 (memory address, varies per run)
    fmt.Printf("type: %T\n", p) // *int
}

The type of p is *int. It means “a pointer to an int” — it holds the location where the value is stored, not the value itself.

* — The Value at an Address

The * operator accesses the actual value that a pointer points to. This is called dereferencing.

package main

import "fmt"

func main() {
    x := 42
    p := &x

    fmt.Println(*p) // 42 — the value p points to
    *p = 100        // Write 100 to the location p points to
    fmt.Println(x)  // 100 — the original changed!
}

*p = 100 writes 100 directly to the memory location that p points to. Since p and x look at the same memory, x’s value changes too. The principle is the same as C pointers, but Go is much safer since there’s no pointer arithmetic.

Here’s a visualization of how & and * connect to memory.

flowchart LR
    subgraph Stack[Stack / variable space]
        X["x: int<br/>value = 42"]
        P["p: *int<br/>value = &x"]
    end
    P -->|reference| X
    Op1["&x (get address)"] -.-> P
    Op2["*p (dereference)"] -.-> X

Modifying the Original from a Function via Pointer

Let’s redo the failed tryChange with a pointer version.

package main

import "fmt"

func changeByPointer(p *int) {
    *p = 100
}

func main() {
    n := 42
    changeByPointer(&n)
    fmt.Println(n) // 100 — it changed this time
}

We pass the address with &n, and inside the function, dereference with *p to modify the value. This is a clear illustration of the difference between pass-by-value and pass-by-pointer.

Here’s a visual comparison of the two approaches from the caller’s perspective.

flowchart TB
    subgraph ValCall[Pass by value]
        N1["main: n = 42"] -->|copy| X1["tryChange: x = 42"]
        X1 --> X2["x = 100<br/>(only the copy changes)"]
        N1 -.no change.- End1["n is still 42"]
    end
    subgraph PtrCall[Pass by pointer]
        N2["main: n = 42"] -->|"&n"| P1["changeByPointer: p = &n"]
        P1 --> P2["*p = 100<br/>(write to original location)"]
        N2 -.shared reference.- End2["n = 100"]
    end

In summary:

Passing methodWhat’s copiedCan modify original
By value (func f(x int))The value itselfNo
By pointer (func f(p *int))The address (8 bytes)Yes

new — Creating a Pointer

The new function allocates the zero value of a specified type and returns a pointer to it.

package main

import "fmt"

func main() {
    p := new(int)    // *int, initialized to 0
    fmt.Println(*p)  // 0

    *p = 42
    fmt.Println(*p)  // 42

    s := new(string) // *string, initialized to ""
    fmt.Println(*s)  // (empty string)
}

new(int) allocates memory for an int, initializes it to 0, and returns its address. In practice, the &T{} pattern is used more often than new because you can set initial values right when creating the struct.

// Using new
p := new(Config)
p.Port = 8080

// Using &T{} — more idiomatic
p := &Config{Port: 8080}

Struct Pointers

Structs bundle multiple fields, so they can be large. Copying the entire struct every time you pass it to a function is inefficient, which is why structs are often handled via pointers.

package main

import "fmt"

type User struct {
    Name string
    Age  int
}

func birthday(u *User) {
    u.Age++ // Same as (*u).Age++, but Go auto-dereferences
}

func main() {
    u := &User{Name: "Gopher", Age: 10}
    birthday(u)
    fmt.Printf("%s: %d years old\n", u.Name, u.Age) // Gopher: 11 years old
}

In Go, when accessing fields through a struct pointer, you can write u.Age instead of (*u).Age. The compiler handles the dereferencing automatically. It’s as if C’s -> operator is unified into . in Go, making the syntax cleaner.

Pointers are also commonly used in method receivers.

func (u *User) SetName(name string) {
    u.Name = name // Modify the original
}

func (u User) GetName() string {
    return u.Name // Read from a copy — no change to original
}

Use a pointer receiver (*User) when modifying the original and a value receiver (User) for read-only. However, the Go community convention is that if even one method uses a pointer receiver, all methods should use pointer receivers for consistency.

nil Pointers

When a pointer is declared without initialization, its default value is nil.

package main

import "fmt"

func main() {
    var p *int
    fmt.Println(p) // <nil>

    if p != nil {
        fmt.Println(*p) // Dereferencing nil without checking causes panic
    }
}

Dereferencing a nil pointer causes a runtime panic. To write defensive code, develop the habit of nil-checking first in functions that receive pointers.

func printUser(u *User) {
    if u == nil {
        fmt.Println("no user information")
        return
    }
    fmt.Printf("%s (%d years old)\n", u.Name, u.Age)
}

When to Use Pointers

You don’t need to use pointers everywhere. Here are some guidelines.

Use pointers when:

Use values directly when:

Go’s pointers are much safer compared to C. There’s no pointer arithmetic, and the garbage collector handles memory reclamation, so there are no dangling pointer issues. That said, mistakes like nil dereference still happen, so whenever you use a pointer, always think first: “Could this pointer be nil?”


The essence of pointers is simple. & gets an address, and * accesses the value at that address. Pointers appear when you need to overcome the limitations of pass-by-value to modify the original or avoid copying large data. They’re especially common with structs and method receivers, so once you’re comfortable with these patterns, your speed reading Go code will increase dramatically.

The next part covers Go’s killer feature: goroutines and channels. We’ll explore how concurrent programming becomes natural in Go.

-> Part 9: Goroutines and Channels


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Go Part 7 — Interfaces
Next Post
Go Part 9 — Goroutines and Channels