Passing by Value vs by Reference

In Go, every function call copies its arguments. When you pass an int, a string, or a struct to a function, the function receives a brand-new copy of that value, and changes made inside the function never reach the caller. Pointers give you a way to opt out of that copying: instead of passing the value itself, you pass the address of the value, so the function can read and write the original data. Understanding this distinction — and how it interacts with slices, maps, and structs — is one of the most important steps in thinking like a Go programmer.

How Go Passes Arguments

Go has exactly one argument-passing mechanism: pass by value. Every time you call a function, Go copies each argument into a new local variable inside that function’s stack frame. The function works with its own copy; when it returns, that copy is discarded, and anything you did to it has no effect on the caller’s variable. This is true no matter what type you pass — an int, a string, a struct, even a pointer.

That last point is the key to understanding Go: there is no separate “pass by reference” mode like C++’s reference parameters. Instead, Go achieves the same effect by letting you pass a pointer — the memory address of a variable — as an ordinary value. The pointer itself is copied into the function, just like an int would be, but because that copy still holds the same address, the function can dereference it to read and write the original variable’s memory. In other words, Go doesn’t pass by reference; it passes a reference by value.

Every variable in a running Go program lives somewhere in memory, whether on the goroutine’s stack or on the heap. The & operator asks for that address, producing a value of type *T (“pointer to T”) for a variable of type T. The * operator, applied to a pointer, dereferences it — it follows the address to read or write the value stored there. Go’s compiler performs escape analysis to decide where a variable actually lives: if a pointer to a local variable escapes the function (for example, by being returned or stored somewhere that outlives the call), the compiler allocates that variable on the heap instead of the stack so it remains valid after the function returns. You don’t have to manage this yourself — there is no malloc/free and no dangling-pointer bookkeeping — but it explains why Go lets you safely return a pointer to a local variable, unlike C.

Structs, arrays, and the basic types (int, float64, bool, string, and so on) are true value types: copying one copies all of its data. Slices, maps, channels, and functions, on the other hand, are often called “reference types” informally, because each is a small header that itself contains a pointer to shared underlying data. A slice header holds a pointer to an array, plus a length and a capacity; copying a slice copies that header, not the underlying array. That’s why passing a slice to a function is cheap, and why writes to elements through that slice are visible to the caller — both the original and the copy point at the same backing array. But operations that change the slice header itself, like append growing past capacity or re-slicing, only affect the local copy of the header, not the caller’s. Maps and channels work similarly: they are pointers to runtime-managed structures, so copying a map value copies a pointer to the same hash table, and mutations through either copy are visible through both.

Syntax

The core syntax you need is small: declaring a pointer type, taking an address, and dereferencing.

var x T // a value of type T
var p *T // a pointer to a T
p = &x // p now holds the address of x
*p = value // dereference p, assign through the pointer

func f(x T) { /* ... */ } // x is a copy: mutations inside f do not affect the caller
func f(p *T) { /* ... */ } // p is a copy of the address: *p mutations affect the caller's value
Syntax Meaning
*T The type “pointer to T” — a variable of this type holds an address, or the special value nil.
&x The address-of operator — produces a *T pointing at the variable x.
*p The dereference operator — reads or writes the value that p points to.
new(T) Allocates a zeroed T and returns a *T pointing at it.
func f(x T) Parameter received by value — the function gets its own independent copy.
func f(p *T) Parameter received as a pointer — the function can mutate the caller’s data through p.

Examples

Example 1: Basic Types Are Always Copied

This example passes the same integer to two functions: one takes an int, the other a *int.

package main

import "fmt"

func incrementValue(n int) {
	n = n + 1
}

func incrementPointer(n *int) {
	*n = *n + 1
}

func main() {
	x := 10
	incrementValue(x)
	fmt.Println("After incrementValue:", x)

	incrementPointer(&x)
	fmt.Println("After incrementPointer:", x)
}

Output:

After incrementValue: 10
After incrementPointer: 11

incrementValue receives a copy of x‘s value, 10. It increments that copy to 11, but the copy is a completely separate integer living in a different stack frame, so x back in main is untouched. incrementPointer instead receives a copy of x‘s address. Dereferencing with *n = *n + 1 follows that address and writes directly into x‘s memory, so the change is visible after the call returns.

Example 2: Structs Follow the Same Rule

Structs are value types too — passing one by value copies every field.

package main

import "fmt"

type Point struct {
	X int
	Y int
}

func moveByValue(p Point) {
	p.X += 10
	p.Y += 10
}

func moveByPointer(p *Point) {
	p.X += 10
	p.Y += 10
}

func main() {
	pt := Point{X: 1, Y: 1}
	moveByValue(pt)
	fmt.Println("After moveByValue:", pt)

	moveByPointer(&pt)
	fmt.Println("After moveByPointer:", pt)
}

Output:

After moveByValue: {1 1}
After moveByPointer: {11 11}

moveByValue receives an entirely separate Point with its own X and Y fields, so mutating it never reaches pt. moveByPointer receives a *Point pointing at the same struct as pt; writing through p.X and p.Y (Go automatically dereferences for field access on a pointer, so you don’t need to write (*p).X) changes pt directly.

Example 3: Slices Share Data, But Not Their Header

This is the example that trips up almost every new Go programmer: mutating a slice’s elements works across a function call, but growing it usually doesn’t.

package main

import "fmt"

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

func appendElement(s []int) {
	s = append(s, 999)
}

func main() {
	nums := []int{1, 2, 3}

	modifyElement(nums)
	fmt.Println("After modifyElement:", nums)

	appendElement(nums)
	fmt.Println("After appendElement:", nums)
}

Output:

After modifyElement: [100 2 3]
After appendElement: [100 2 3]

nums is a slice literal, so its length and capacity are both 3. Inside modifyElement, s is a copy of the slice header, but that header still points at the same backing array as nums, so s[0] = 100 changes the array both slices see. Inside appendElement, len(s) already equals cap(s), so append must allocate a brand-new, larger backing array, copy the elements into it, and add 999. That new array is assigned to the local variable s — a new header entirely — and nums back in main still points at the old, three-element array and never learns about the new one.

How It Works Step by Step

Trace through incrementPointer(&x) from Example 1 to see exactly what happens:

  1. x := 10 creates a variable x in main‘s stack frame holding the value 10.
  2. &x evaluates to the memory address of that variable — a value of type *int.
  3. Calling incrementPointer(&x) copies that address into the parameter n, which lives in incrementPointer‘s own stack frame. n is a distinct variable from x, but it stores the same address.
  4. *n = *n + 1 dereferences n: execution follows the address stored in n out to x‘s memory location, reads 10, adds 1, and writes 11 back to that same location.
  5. When incrementPointer returns, its stack frame — including n — is discarded, but the write already landed in x‘s memory, so main sees x as 11.

Contrast that with incrementValue(x): step 3 copies the value 10 instead of an address, so the increment happens to an entirely separate integer that main never observes again.

Common Mistakes

Mistake 1: Dereferencing a Nil Pointer

A pointer’s zero value is nil — it doesn’t point anywhere yet. Dereferencing a nil pointer compiles fine but panics at runtime:

type Point struct {
	X, Y int
}

func printX(p *Point) {
	fmt.Println(p.X)
}

func main() {
	var p *Point
	printX(p) // panics: runtime error: invalid memory address or nil pointer dereference
}

Running this panics because p holds no address for p.X to follow. Always check for nil before dereferencing a pointer that might not have been set:

package main

import "fmt"

type Point struct {
	X, Y int
}

func printX(p *Point) {
	if p == nil {
		fmt.Println("no point provided")
		return
	}
	fmt.Println(p.X)
}

func main() {
	var p *Point
	printX(p)

	p = &Point{X: 5, Y: 9}
	printX(p)
}

Output:

no point provided
5

Mistake 2: Modifying a Copy Inside a Range Loop

The second variable in a for ... range loop over a slice is a copy of each element, not the element itself. Writing to it silently does nothing to the underlying slice:

package main

import "fmt"

type Item struct {
	Name string
	Done bool
}

func main() {
	items := []Item{{Name: "wash car"}, {Name: "buy milk"}}

	for _, item := range items {
		item.Done = true
	}

	fmt.Println(items)
}

Output:

[{wash car false} {buy milk false}]

Every item is a fresh copy of the struct stored at that index; setting item.Done = true only changes the copy, which is discarded at the end of each iteration. Index into the slice directly to reach the real elements:

package main

import "fmt"

type Item struct {
	Name string
	Done bool
}

func main() {
	items := []Item{{Name: "wash car"}, {Name: "buy milk"}}

	for i := range items {
		items[i].Done = true
	}

	fmt.Println(items)
}

Output:

[{wash car true} {buy milk true}]

Best Practices

  • Use a pointer receiver or pointer parameter when a function needs to mutate the caller’s data, or when the value is a large struct and copying it would be wasteful.
  • Use a value parameter for small, cheaply-copied types where mutation isn’t needed — it keeps data effectively immutable from the caller’s point of view and avoids nil-pointer checks.
  • Once a type has any pointer-receiver method, give all of its methods pointer receivers for consistency, even the ones that don’t need to mutate.
  • Remember that slices, maps, and channels already behave like reference types for their contents — you rarely need a pointer to a slice or map just to mutate elements or keys.
  • Always reassign the result of append (s = append(s, x)); never assume the caller’s slice grows in place.
  • Check pointers for nil before dereferencing them unless you can prove, by construction, that they are always set.
  • Avoid handing out pointers to package-level mutable state when a fresh copy would be safer — shared mutable state is a common source of subtle bugs, especially across goroutines.

Practice Exercises

  1. Write a function double(n *int) that doubles the integer its argument points to. Call it on a local variable and print the value before and after.
  2. Write a Rectangle struct with Width and Height fields and a method Scale(factor float64) with a pointer receiver that multiplies both fields by factor. Verify that calling r.Scale(2) on a variable r changes r directly.
  3. Given a slice built with make([]int, 3, 10) (length 3, capacity 10), write a function that appends a fourth element inside the function. Print the slice’s length in both the caller and the function, and explain why the caller’s length changes this time, unlike in Example 3.

Summary

  • Go always passes arguments by value — every function call copies its arguments into new local variables.
  • A pointer (*T) is itself a value that gets copied, but because it stores an address, dereferencing it inside the function reaches the caller’s original data.
  • Structs, arrays, and basic types are copied completely; mutating a copy never affects the original unless you pass a pointer.
  • Slices, maps, and channels are lightweight headers containing a pointer to shared data — element mutations are visible to the caller, but operations that replace the header itself, like append past capacity, are not, unless you pass a pointer to the slice or reassign and return it.
  • A nil pointer is valid to hold but panics on dereference — always check before using one that might be unset.
  • Prefer pointers for mutation and for avoiding large copies; prefer values for small, simple data that should stay effectively immutable from the caller’s perspective.