Day 04: Pointers and Value Semantics
Core Concepts
Pass by Value
When you call a function and pass a variable as an argument, Go makes a complete copy of that variable and hands the copy to the function. The function works entirely on its own copy. When the function returns, the copy is discarded.
The original variable in the caller is untouched — the function cannot reach it.
Think of it like a photocopier: you hand someone a photocopy of a document. They scribble all over it. Your original stays clean.
package main
import "fmt"
func double(n int) {
n = n * 2 // modifies the copy
fmt.Println("inside:", n) // 10
}
func main() {
x := 5
fmt.Println("before:", x) // 5
double(x)
fmt.Println("after:", x) // 5 — unchanged
}
What is happening in memory:
Caller (main) Function (double)
┌──────────────┐ ┌──────────────┐
│ x = 5 │────────▶│ n = 5 │
└──────────────┘ └──────────────┘
x is unchanged double() works on n (a copy of x)
n = n * 2 → n becomes 10
n is discarded when double() returns
Pass by value is Go’s default for all arguments: integers, floats, booleans, strings, arrays, and structs. Every function call is a copy operation.
Pass by Reference
In a “pass by reference” model, instead of handing the function a photocopy, you hand it directions to the original. The function follows those directions, finds the original variable, and modifies it directly. Any change the function makes is immediately visible back in the caller.
Go does not have built-in reference parameters. Instead, Go simulates reference behaviour using pointers. A pointer is a value that stores the memory address of another variable. When you pass a pointer, you are still passing by value (the address itself is copied), but the function can use that address to reach and modify the original variable.
package main
import "fmt"
func doublePtr(n *int) {
*n = *n * 2 // dereference: follow the address and modify the original
fmt.Println("inside:", *n) // 10
}
func main() {
x := 5
fmt.Println("before:", x) // 5
doublePtr(&x) // pass the address of x
fmt.Println("after:", x) // 10 — x was modified!
}
What is happening in memory:
main's memory doublePtr's memory
┌─────────────────┐ ┌─────────────────┐
│ x = 5 │◀─────│ n = 0xc001 │ n holds the address of x
│ at addr 0xc001 │ │ │
└─────────────────┘ └─────────────────┘
*n = *n * 2 follows the address in n and writes the result into x:
main's memory (n still points here)
┌─────────────────┐ ┌─────────────────┐
│ x = 10 │◀─────│ n = 0xc001 │ *n wrote 10 into x
│ at addr 0xc001 │ │ │
└─────────────────┘ └─────────────────┘
&x means “give me the address of x” — it produces a pointer.
*n means “follow the pointer n and give me the value stored there” — this is called dereferencing.
When doublePtr does *n = *n * 2, it is not touching n (the address) — it is following n to reach x and modifying x in place.
Go’s model in plain terms
| Pass by value | Pass by pointer | |
|---|---|---|
| What the function receives | A copy of the value | A copy of the address |
| Can the function modify the original? | No | Yes, via *pointer |
| Original variable after return | Unchanged | May be changed |
| Typical use | Reading, computing | Mutating, large structs, optional values |
Go always passes by value. Passing a pointer is still passing by value — the address is copied — but the address is a key that unlocks the original variable.
Structs follow the same rules
Structs are passed by value too. The entire struct is copied on every function call:
package main
import "fmt"
type Point struct {
X, Y int
}
func move(p Point) {
p.X += 10
p.Y += 10
fmt.Println("inside move:", p) // {11 11}
}
func movePtr(p *Point) {
p.X += 10
p.Y += 10
fmt.Println("inside movePtr:", *p) // {11 11}
}
func main() {
a := Point{1, 1}
move(a)
fmt.Println("after move:", a) // {1 1} — unchanged
movePtr(&a)
fmt.Println("after movePtr:", a) // {11 11} — modified
}
For small structs like Point, copying is fast and pass by value is often the right choice. For large structs (dozens of fields), passing a pointer avoids the copy cost.
Pointer Operators
| Operator | Meaning |
|---|---|
&x |
address of x — produces *T |
*p |
value at address p — dereference |
new(T) |
allocate zeroed T, return *T |
When to Use Pointers
- Mutation — the function or method must modify the original value
- Large structs — avoid expensive copies on every call
- Optional values —
*Tcan benilto represent “absent”
Do not use a pointer just because you’re used to reference semantics from another language. Go’s escape analysis often heap-allocates anyway — trust the compiler.
Nil Pointers
The zero value of any pointer type is nil. Dereferencing a nil pointer panics:
var p *int // nil
*p = 5 // panic: runtime error: invalid memory address
Always check p != nil before dereferencing pointers that may be nil.
Value Receivers and Pointer Receivers
A receiver is the named type that appears in parentheses between the func keyword and the method name. It declares which type the method belongs to and gives you a local name for the value you are operating on.
// receiver
// ┌──────┐
func (c Counter) Value() int { return c.n }
// ^
// local name for the Counter value inside this method
There are two kinds of receiver, and the difference is exactly the same as the pass-by-value vs pass-by-pointer distinction you just learned:
Value receiver (c Counter)
The method receives a copy of the struct — the same as passing a struct to a plain function by value. Changes made inside the method do not affect the caller’s variable.
type Counter struct{ n int }
func (c Counter) Double() {
c.n *= 2 // mutates the copy only — caller's Counter is unchanged
}
Use a value receiver when the method only needs to read the struct.
Pointer receiver (c *Counter)
The method receives a pointer to the original struct — the same as passing &counter to a plain function. Changes made by dereferencing c persist back to the caller.
func (c *Counter) Increment() {
c.n++ // c is *Counter — this modifies the original Counter
}
Use a pointer receiver when the method must change the struct’s fields.
Side by side
type Counter struct{ n int }
func (c Counter) Value() int { return c.n } // value receiver — safe read-only copy
func (c *Counter) Inc() { c.n++ } // pointer receiver — mutates original
c := Counter{}
c.Inc() // Go auto-takes address: (&c).Inc()
fmt.Println(c.Value()) // 1
Go’s auto-address rule: when you call a pointer-receiver method on an addressable variable (c.Inc()), Go automatically rewrites it as (&c).Inc(). You do not write the & yourself. This only works for variables stored in memory — not for map values or function return values.
Labs
Lab 1: Value vs Pointer — See the Difference
What you’ll practise: Observing that a function with a value parameter receives a copy, while a pointer parameter can modify the original.
Task:
Write two functions — modify(n int) and modifyPtr(n *int) — call both on the same variable, and print the result after each call to prove which one changed the original.
Steps:
- Write
modify(n int)that doesn *= 10— works on a local copy - Write
modifyPtr(n *int)that does*n *= 10— dereferences and changes the original - Call both on the same
x := 5and printxafter each call
package main
import "fmt"
func modify(n int) {
n *= 10
fmt.Printf(" inside modify: n=%d\n", n)
}
func modifyPtr(n *int) {
*n *= 10
fmt.Printf(" inside modifyPtr: *n=%d\n", *n)
}
func main() {
x := 5
fmt.Printf("before modify: x=%d\n", x)
modify(x)
fmt.Printf("after modify: x=%d (unchanged)\n", x)
fmt.Printf("before modifyPtr: x=%d\n", x)
modifyPtr(&x)
fmt.Printf("after modifyPtr: x=%d (changed)\n", x)
}
Expected output:
before modify: x=5
inside modify: n=50
after modify: x=5 (unchanged)
before modifyPtr: x=5
inside modifyPtr: *n=50
after modifyPtr: x=50 (changed)
Checkpoint: After both calls, x is 50 (modified once by modifyPtr). Add a second call to modifyPtr(&x) — confirm x becomes 500.
Lab 2: new() and & — Allocating Pointers
What you’ll practise: Using new(T) to allocate a zeroed value, using &value to take the address of an existing value, and printing pointer addresses with %p.
Task:
Allocate two int pointers — one with new, one with & — and compare their addresses and values.
Steps:
- Allocate
p1 := new(int)— prints as a zero-valued int - Set
*p1 = 42 - Declare
n := 99; p2 := &n - Print both pointers with
%pand their dereferenced values with%d - Show that two separate
new(int)calls produce different addresses
package main
import "fmt"
func main() {
// Allocate with new — returns a *int pointing to a zeroed int
p1 := new(int)
fmt.Printf("p1 address: %p value before: %d\n", p1, *p1)
*p1 = 42
fmt.Printf("p1 address: %p value after: %d\n", p1, *p1)
// Take address of an existing variable
n := 99
p2 := &n
fmt.Printf("p2 address: %p value: %d\n", p2, *p2)
// Modifying via pointer changes the original variable
*p2 = 100
fmt.Printf("n after *p2=100: %d\n", n)
// Two separate allocations always have different addresses
a, b := new(int), new(int)
fmt.Printf("a=%p b=%p same? %v\n", a, b, a == b)
}
Expected output:
p1 address: 0xc000... value before: 0
p1 address: 0xc000... value after: 42
p2 address: 0xc000... value: 99
n after *p2=100: 100
a=0xc000... b=0xc000... same? false
Checkpoint: Addresses will differ each run — that is correct. a == b is always false. Setting *p2 changes n — they point to the same memory.
Lab 3: Pointer vs Value Receivers on a Struct
What you’ll practise: Seeing why a method that must mutate state needs a pointer receiver, and what happens when you accidentally use a value receiver for mutation.
Task:
Build a Counter struct with both a value-receiver Reset() (intentionally broken) and a pointer-receiver Increment() (correct). Compare the results.
Steps:
- Define
type Counter struct { n int } - Write
(c Counter) BrokenReset()that setsc.n = 0— operates on a copy - Write
(c *Counter) Increment()that doesc.n++— correct pointer receiver - Write
(c Counter) Value() int— safe read-only value receiver - Call
BrokenResetafter incrementing — show it has no effect
package main
import "fmt"
type Counter struct{ n int }
// BrokenReset uses a VALUE receiver — it resets a copy, not the original.
func (c Counter) BrokenReset() {
c.n = 0
fmt.Printf(" BrokenReset: inside c.n=%d\n", c.n)
}
// Increment uses a POINTER receiver — correctly mutates the original.
func (c *Counter) Increment() { c.n++ }
// Value is a read-only value receiver — safe and cheap.
func (c Counter) Value() int { return c.n }
func main() {
c := Counter{}
c.Increment()
c.Increment()
c.Increment()
fmt.Printf("after 3 increments: %d\n", c.Value())
c.BrokenReset()
fmt.Printf("after BrokenReset: %d (still %d!)\n", c.Value(), c.Value())
// Correct way: pointer receiver reset
c.n = 0
fmt.Printf("after manual reset: %d\n", c.Value())
}
Expected output:
after 3 increments: 3
BrokenReset: inside c.n=0
after BrokenReset: 3 (still 3!)
after manual reset: 0
Checkpoint: BrokenReset does not change the real c.n. Fix it by changing the receiver to *Counter and confirm c.Value() returns 0 after calling it.
Lab 4: Nil Pointer — Panic, Recover, and the Safe Pattern
What you’ll practise: Deliberately triggering a nil-pointer panic, catching it with recover() in a defer, and applying the correct nil-check pattern.
Task:
Dereference a nil pointer inside a function that uses defer/recover to catch the panic gracefully. Then show the idiomatic safe-check approach.
Steps:
- Write
safeDeref(p *int) (val int, err error)— usesdefer/recoverto catch the panic - Call it with
nil— observe it returns an error instead of crashing - Call it with a valid pointer — observe it returns the value
- Show the simple nil-check as the preferred real-world pattern
package main
import "fmt"
func safeDeref(p *int) (val int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("caught panic: %v", r)
}
}()
val = *p // panics if p is nil
return
}
func main() {
// Nil pointer — caught by recover
v, err := safeDeref(nil)
fmt.Printf("nil deref: val=%d err=%v\n", v, err)
// Valid pointer — works fine
n := 42
v, err = safeDeref(&n)
fmt.Printf("valid deref: val=%d err=%v\n", v, err)
// Idiomatic safe pattern — no recover needed
var p *int
if p != nil {
fmt.Println("value:", *p)
} else {
fmt.Println("p is nil — skip deref")
}
}
Expected output:
nil deref: val=0 err=caught panic: runtime error: invalid memory address or nil pointer dereference
valid deref: val=42 err=<nil>
p is nil — skip deref
Checkpoint: recover() stops the panic from propagating. The idiomatic pattern (nil check before deref) is always preferred over recover — recover is a last resort, not normal error handling.
Lab 5: Pointer to Interface — Why You Almost Never Need It
What you’ll practise: Understanding why *SomeInterface is almost always wrong, and seeing the pattern that actually works.
Task:
Define a Stringer interface. Store a concrete type in it. Show that *Stringer breaks polymorphism, and demonstrate the correct approach.
Steps:
- Define
type Stringer interface { String() string } - Define
type Foo struct{ msg string }with a pointer-receiverString() - Assign
&Foo{"hello"}to aStringervariable — works - Try using
*Stringeras a function parameter — show the issue - Show the correct pattern: pass
Stringerdirectly (interface value, not pointer-to-interface)
package main
import "fmt"
type Stringer interface {
String() string
}
type Foo struct{ msg string }
func (f *Foo) String() string { return "Foo: " + f.msg }
// WRONG pattern — almost never what you want
func printWrong(s *Stringer) {
fmt.Println((*s).String()) // must double-dereference
}
// CORRECT pattern — pass the interface directly
func printRight(s Stringer) {
fmt.Println(s.String())
}
func main() {
var s Stringer = &Foo{"hello"}
printRight(s) // clean and idiomatic
printWrong(&s) // works but ugly — the interface is already a reference type
// Why *Stringer is wrong: you lose the ability to pass different types
// because &s has type *Stringer, not *Foo or *Bar
fmt.Printf("type of s: %T\n", s)
fmt.Printf("type of &s: %T\n", &s)
}
Expected output:
Foo: hello
Foo: hello
type of s: *main.Foo
type of &s: *main.Stringer
Checkpoint: Interfaces are already reference types (they hold a pointer to data internally). Passing *Stringer adds an unnecessary indirection. The rule: pass interfaces by value, not by pointer.
Lab 6 (Final): Memory Explorer
What you’ll practise: Combining all pointer concepts — address operators, pointer receivers, copy semantics, and nil checks — in one complete program.
Task:
Write day-04/main.go that demonstrates the full range of pointer behaviour: address printing, copy vs share semantics, pointer receiver mutation, and safe nil handling.
Steps:
- Declare an
int, take its address with&, and print the pointer with%p - Define
type Box struct { value int }withSet(v int)(pointer receiver) andGet() int(value receiver) - Show struct copy semantics:
b2 := b1creates an independent copy;p := &b1shares it - Show
b1.Set(99)updatesb1but notb2(the copy) - Demonstrate a nil
*Boxcheck before callingGet()
package main
import "fmt"
type Box struct{ value int }
func (b *Box) Set(v int) { b.value = v }
func (b Box) Get() int { return b.value }
func main() {
// Address and pointer printing
x := 100
p := &x
fmt.Printf("x=%d &x=%p *p=%d\n", x, p, *p)
// Struct copy vs pointer share
b1 := Box{value: 1}
b2 := b1 // copy — independent
pb := &b1 // pointer — shares b1
b1.Set(99)
fmt.Printf("b1.Get()=%d (modified)\n", b1.Get())
fmt.Printf("b2.Get()=%d (copy — unchanged)\n", b2.Get())
fmt.Printf("pb.Get()=%d (pointer — same as b1)\n", pb.Get())
// Nil pointer check
var nilBox *Box
if nilBox != nil {
fmt.Println("value:", nilBox.Get())
} else {
fmt.Println("nilBox is nil — skipping deref")
}
}
Expected output:
x=100 &x=0xc000... *p=100
b1.Get()=99 (modified)
b2.Get()=1 (copy — unchanged)
pb.Get()=99 (pointer — same as b1)
nilBox is nil — skipping deref
Checkpoint: Run go run . and observe the real memory address printed. Confirm b2.Get() is still 1 even after b1.Set(99). go vet . passes with no warnings.
Day Project: Memory Explorer
Write a program that:
- Declares an
int, takes its address with&, and prints the pointer value (%pverb) - Defines a
Boxstruct with avalue int; writesSet(v int)(pointer receiver) andGet() int(value receiver) - Shows that assigning a struct copies it, but assigning a pointer shares it
- Demonstrates a nil pointer check before dereferencing
Run it and observe the memory addresses printed.
Official Documentation
fmt— the%pverb for printing pointer addresses- Language Spec: Pointer types —
*Tpointer syntax - Language Spec: Address operators —
&and*operators - Language Spec: Allocation — the
newbuilt-in - Effective Go: Pointers vs. Values — when to use each
- Go Tour: Pointers — interactive pointer tour