Deep Engineering
Intermediate·Published·55 MIN

Pointers in Go: everything is passed by value, even a pointer

Interview questions on pointers climb a ladder: what a pointer is — is anything in Go passed by reference — what happens after p = nil inside a function — can you call a method on nil — why a map element has no address — can you return a pointer to a local variable — is there pointer arithmetic. A run answers each step: assigning to a pointer parameter is not visible outside, a method on a nil receiver ran fine, a pointer to a slice element writes into the old array after append, and a function that returned a pointer to its own variable cost more than copying a kilobyte in a measurement on go1.24.7.

Full technical treatment

TL;DR

A pointer is a value that holds the address of a variable. &x takes the address, *p gives the variable at the address, and the zero value of a pointer is nil. Fields and methods can be reached without a star: p.X is (*p).X, and v.Move(5) for a method on *Point is (&v).Move(5). new(T) and &T{} both give a pointer to a new variable; for a struct they are the same thing, for a slice and a map they are not: new gives a pointer to a nil slice or nil map, &T{} to an empty container that is ready for writes.

In Go everything is passed by value, the pointer too: a function receives a copy of the address. The run shows it with address labels: outside p = <addr 1>, &p = <addr 2>; inside the function p = <addr 1>, &p = <addr 3> — the same value, a different variable. That is why *p = 42 inside is visible outside, while p = &y and p = nil are not. What the caller and the function share is not the pointer variable but the variable it points to.

Beyond that comes what separates real understanding from having read about it. Dereferencing nil panics with runtime error: invalid memory address or nil pointer dereference, yet a method on a nil receiver is legal and ran fine as long as it did not touch fields. The address of a map element cannot be taken — a compile error — while the address of a slice element can, and after an append that reallocated the array such a pointer loses its link to the slice's current array: writing through it is legal but goes into the old array — s[0] = 10, *e = 100 — and the old array stays alive as long as the pointer does. Returning a pointer to a local variable is legal: the variable lives as long as something points to it, and where to place it is up to the compiler — for a separately compiled function it prints moved to heap: c (moves it to the heap, memory that outlives the call). There is no pointer arithmetic: p + 1 does not compile, and the only way around is unsafe. And the cost is an observation of one configuration, go1.24.7 linux/amd64: passing a kilobyte by pointer turned out cheaper than by value, but a function that returned a pointer to its own new variable cost more than one that returned the same struct by value, because of the heap allocation. The numbers are in "Deeper"; on your machine, measure them.

Where to start
Before this lesson it is enough to understand
  • how to declare a struct, its fields and its zero value — the lesson on structs;
  • how to use a slice and a map: append, len, cap, writing by key — in detail in the lessons on slices and maps (they come later in the track);
  • that a method is declared with a receiver — in detail in the lesson on method receivers.
You do not need to know in advance
  • what an address, & and * are, and why p.X is written without a star;
  • what addressability, escape analysis and the -gcflags='-m' flag are — what is needed is explained along the way, the details are in their own lessons;
  • what unsafe.Pointer is.

What is really being asked

The ladder usually looks like this:

  1. "What is a pointer and why do you need it?" — the warm-up; "the address of a variable" is correct but explains nothing.
  2. "Is anything in Go passed by reference?" — the main question of the lesson. "Pointers and maps are passed by reference" is the very mistake this question is meant to catch.
  3. "What happens if you assign nil to a pointer parameter inside a function?" — the same check, on code.
  4. "Can you call a method on a nil pointer?" — and what panics then and what does not.
  5. "Why can't you take the address of a map element, while you can for a slice element?" — about addressability and about a pointer that quietly lost its link to the slice's array.
  6. "Can you return a pointer to a local variable?" — a question from C; in Go the answer is different.
  7. "Does Go have pointer arithmetic?" — and what to do if you really need it.
  8. "Which is faster — passing a struct by value or by pointer?" — about cost, and it has two sides.

The ladder has one spine: a pointer is an ordinary value that is copied like any other; what the copies share is only the variable they point to. Everything in this lesson that looks surprising follows from that sentence.

Base: the address of a variable and the variable at an address

The specification defines a pointer through the variable it points to:

A pointer type denotes the set of all pointers to variables of a given type, called the base type of the pointer. The value of an uninitialized pointer is nil.

— The Go Specification — Pointer types
language contractThe rules of argument passing, addressability, nil dereference and new are part of the language specification; the run of bench/goptr/internals.go illustrates them rather than establishes them.

Two operators: &x gives the address of the variable x, *p gives the variable p points to. Through *p you can both read and write. In the run x := 10 and a type with two methods.

Fragment of bench/goptr/internals.go (package-level declarations; comments translated):

GO
type Point struct{ X, Y int }
 
func (p *Point) Move(dx int) { p.X += dx }        // pointer receiver
func (p Point) Sum() int     { return p.X + p.Y } // value receiver

Output of bench/goptr/internals.go, section 1; labels translated:

1) ADDRESS AND DEREFERENCE
──────────────────────────
  &x                       -> <addr 1>
  p := &x; p               -> <addr 1>
  *p                       -> 10
  *p = 20; x               -> 20
  pt := &Point{1, 2}; var v Point
  pt.X                     -> 1    (same as (*pt).X)
  pt.Sum()                 -> 3    (Sum on Point: *pt taken)
  v.Move(5); v.X           -> 5    (Move on *Point: &v taken)

Real addresses change from run to run, so the script prints labels: the same address gets the same label <addr N>. p and &x have one label, that is, one value.

The last three lines are why Go code shows so few stars. A field through a pointer is read without a dereference: the selector rule in the specification applies "for a value x of type T or *T", so pt.X is (*pt).X. A method is called the same way: for a method on Point the compiler turns pt.Sum() into (*pt).Sum(), and for a method on *Point it turns v.Move(5) into (&v).Move(5), because v is addressable (that is, its address can be taken: it is a variable; what else is addressable is Mechanism 3). The method set in the quote below is the set of methods that can be called on a value of the type:

If x is addressable and &x's method set contains m, x.m() is shorthand for (&x).m()

— The Go Specification — Calls

When this shorthand is not available — for example, for a value in an interface or for a map element — is covered in the lesson on method receivers.

new(T) and &T{}. Both give a pointer to a new variable:

Calling the built-in function new or taking the address of a composite literal allocates storage for a variable at run time.

— The Go Specification — Variables

Output of bench/goptr/internals.go, section 4; labels translated:

4) new(T) AND &T{}
──────────────────
  a := new(Point)          -> {X:0 Y:0}
  b := &Point{}            -> {X:0 Y:0}
  *a == *b                 -> true
  a == b                   -> false   (two different variables)
  allocations when the pointer goes into a global variable:
    sinkP = new(Point)     -> 1
    sinkP = &Point{}       -> 1
  new(int); *n             -> 0   (there is no &int{}: int has no composite literal)

For the struct Point both forms give pointers to equal zero values, while the pointers themselves differ: each expression creates its own variable. One allocation each in this context, and the compiler's placement decisions (Mechanism 4) are the same for both; the allocation count is an observation of the go1.24.7 implementation, not a language rule. &T{...} can fill in fields right away, while new works with any type, including int, which has no composite literal.

For a slice and a map the forms differ. new(T) gives a pointer to the zero value of the type, and the zero value of a slice or a map is nil. &T{} gives a pointer to an empty but initialized container. The specification says so directly:

Note that the zero value for a slice or map type is not the same as an initialized but empty value of the same type.

— The Go Specification — Composite literals

Output of bench/goptr/audit.go, section 1; labels translated:

1) new(T) AND &T{} FOR A STRUCT, A SLICE AND A MAP
──────────────────────────────────────────────────
  T                  *new(T)              *(&T{})
  Point              {X:0 Y:0}            {X:0 Y:0}
  []int              == nil: true         == nil: false
  map[string]int     == nil: true         == nil: false

  write (*m)["k"] = 1:
    m := new(map[string]int)    -> panic: assignment to entry in nil map
    m := &map[string]int{}      -> len 1
  *s = append(*s, 1):
    s := new([]int)             -> len 1   (append to a nil slice is legal)
    s := &[]int{}               -> len 1

For a slice the difference barely shows: append to a nil slice is legal. For a map it decides everything: a map from new cannot be written to — a panic — it has to be created with make or a literal first. That is why a pointer to a map is written as &map[K]V{}, or not needed at all: a map is itself a reference to a shared table (see the lesson on maps).

Neither a constant nor the result of a function call has an address.

Output of bench/goptr/compile.sh, case addrconst (the file intentionally does not compile):

addrconst:
  ./main.go:10:8: invalid operation: cannot take address of 10 (untyped int constant)
  ./main.go:11:8: invalid operation: cannot take address of get() (value of struct type Point)

This is enough for a Junior-level answer to "what is a pointer": a value that holds the address of a variable. & takes the address, * gives the variable at the address, the zero value is nil. Through a pointer a function can change the caller's variable without copying it. The last sentence is exactly what the next step checks.

Mechanism 1: a pointer parameter is a copy

What happens on a function call, the specification states directly:

After they are evaluated, new storage is allocated for the function's variables, which includes its parameters and results. Then, the arguments of the call are passed to the function, which means that they are assigned to their corresponding function parameters, and the called function begins execution.

— The Go Specification — Calls

Arguments are assigned to parameters — the rule has no exception for pointers. The parameter p *int is a new variable of the function, and the address is copied into it. Three functions with the same signature.

Fragment of bench/goptr/internals.go (comment translated):

GO
func set(p *int) { *p = 42 }
 
func redirect(p *int) {
    y := 0
    p = &y
    *p = 99
}
 
func drop(p *int) { p = nil }
 
func where(p *int) (string, string) { return addr(p), addr(&p) } // prints the value of p and the address of p itself

Output of bench/goptr/internals.go, section 2; labels translated:

2) A POINTER PARAMETER IS A COPY
────────────────────────────────
  outside  p = <addr 1>   &p = <addr 2>
  inside   p = <addr 1>   &p = <addr 3>
  Same value — the address of x; a different pointer variable.

  set(p):      *p = 42             x = 42    — visible
  redirect(p): p = &y; *p = 99     x = 42, p = <addr 1>    — not visible
  drop(p):     p = nil             p == nil: false    — not visible

The "inside" line is printed by where — the same kind of function with a p *int parameter. The first two lines are the whole mechanism. Inside the function p holds the same <addr 1>, but the variable p itself lives at a different address: <addr 3> against <addr 2>. These are two variables with the same value. Everything else follows:

  • *p = 42 writes at the address — into x, shared by both copies. That is visible outside.
  • p = &y changes the copy itself. The outer p knows nothing about it and still points at x; 99 went into y.
  • p = nil is the same: the copy was cleared.

So the answer to "is anything passed by reference" is no. Everything is passed by value; a pointer's value is an address, and a copy of the address leads to the same variable. "By reference" in C++ means the parameter is another name for the same variable, and assigning to it changes the caller's variable. Go has no such parameter; drop(p) is the check.

All of this fits into a short program. The full file bench/goptr/cases/example/main.go can be copied into an empty directory next to a go.mod (module example, go 1.24) and run with go run .:

GO
package main
 
import "fmt"
 
func set(p *int)  { *p = 42 }
func drop(p *int) { p = nil }
 
func main() {
	x := 1
	p := &x
	set(p)
	fmt.Println(x)
	drop(p)
	fmt.Println(p == nil, *p)
}

Output of bench/goptr/example.sh; the header translated:

go1.24.7 linux/amd64 | full example: go run .

  42
  false 42

If a function really does need to change the caller's pointer, it is given the address of that pointer — and that is again passing by value, only the value is now a **int.

Fragment of bench/goptr/internals.go:

GO
var other = 7
 
func retarget(pp **int) { *pp = &other }

Output of bench/goptr/internals.go, section 9; labels translated:

9) A POINTER TO A POINTER
─────────────────────────
  before: p = <addr 1> (address of x), *p = 1
  retarget(&p): *pp = &other
  after:  p = <addr 2> (address of other), *p = 7

In practice it is simpler to return the new pointer as a result.

Mechanism 2: nil — a panic on dereference, but not on a method call

The dereference rule in the specification is short:

If x is nil, an attempt to evaluate *x will cause a run-time panic.

— The Go Specification — Address operators

The key word is evaluate *x. Not "use the pointer" but read or write the variable through it.

Fragment of bench/goptr/internals.go; comments shortened and translated:

GO
type Tree struct {
    Val         int
    Left, Right *Tree
}
 
func (t *Tree) Sum() int { // checks nil itself
    if t == nil {
        return 0
    }
    return t.Val + t.Left.Sum() + t.Right.Sum()
}
 
func (t *Tree) Kind() string { return "tree" } // touches no fields

Output of bench/goptr/internals.go, section 3; labels translated:

3) nil POINTER
──────────────
  var np *Point; np == nil -> true
  *np                      -> panic: runtime error: invalid memory address or nil pointer dereference
  np.X                     -> panic: runtime error: invalid memory address or nil pointer dereference
  np.Sum()  (on Point)     -> panic: runtime error: invalid memory address or nil pointer dereference
  nt.Kind() (on *Tree)     -> "tree"
  nt.Sum()  (on *Tree)     -> 0
  tree 1 + 2, t.Sum()      -> 3
  panic value is a runtime.Error: true

Three panics and two successful calls, and the line between them runs exactly along the dereference:

  • *np and np.X dereference — a panic. For a field the specification says it separately: "assigning to or evaluating x.f causes a run-time panic".
  • np.Sum() is a method on the value Point. To call it, *np has to be copied into the receiver, and that is a dereference — a panic.
  • nt.Kind() and nt.Sum() are methods on the pointer *Tree. The receiver is just a parameter holding nil; there is no panic until the body touches a field. Kind touches no fields, Sum checks t == nil first.

This is a common technique: the recursive Sum does not check its children before calling — an empty subtree yields 0 by itself.

The panic text comes from the runtime. runtime/panic.go (line 258 in go1.24.7) holds memoryError = error(errorString("invalid memory address or nil pointer dereference")), and the runtime error: prefix is added by errorString.Error in runtime/error.go. The value caught by recover is a runtime.Error, as the run printed. The text has nothing to do with it: errorString has a RuntimeError() method (same file, line 79 in go1.24.7), and "a type is a run time error if it has a RuntimeError method".

One warning worth raising yourself in an interview: a nil pointer stored in an interface makes the interface non-nil. The method on the nil receiver will be called, and err == nil will be false. This is covered in the lesson on interfaces.

Where go1.24 did not panic

implementation detail · Go 1.24Compiler behaviour, not language behaviour: the specification requires a panic in both cases. Run of bench/goptr/nilcheck.go on go1.24.7 and go1.25.1.

go1.24.7 does not always keep the "dereferencing nil panics" guarantee: the compiler could move the nil check into the branch where the loaded value is used, and on the other path it never ran. In go1.25.1 the tighten pass, which moves values closer to where they are used, is no longer allowed to move nil checks (src/cmd/compile/internal/ssa/tighten.go, lines 48–49: "Nil checks need to stay in their block"), and the distribution contains the regression test test/fixedbugs/issue72860.go. Here is the function from it and familiar-looking code that reads the result before checking the error.

Fragment of bench/goptr/nilcheck.go; comments removed, the string in open translated:

GO
//go:noinline
func f(p *int, b bool) int {
    valid := *p >= 0
    if !b || !valid {
        return 5
    }
    return 6
}
 
func open() string {
    file, err := os.Open("no-such-file")
    name := file.Name()
    if err != nil {
        return "open failed, the name is not used"
    }
    return name
}

Output of bench/goptr/nilcheck.go on go1.24.7; labels translated:

go1.24.7 linux/amd64 | the nil check the compiler deferred

  f(nil, false)            -> 5
  open()                   -> open failed, the name is not used

Output of the same file on go1.25.1; labels translated:

go1.25.1 linux/amd64 | the nil check the compiler deferred

  f(nil, false)            -> panic: runtime error: invalid memory address or nil pointer dereference
  open()                   -> panic: runtime error: invalid memory address or nil pointer dereference

file.Name() is return f.name (os/file.go, line 62 in go1.24.7 and 63 in go1.25.1): a field read through nil. On go1.24.7 the code "worked"; on go1.25.1 it panics — as the specification requires. The practical conclusion: code that touches the result before checking the error could pass its tests and start panicking after a compiler upgrade. Fix the code, not the version: first if err != nil, then the result.

Mechanism 3: an address belongs to a variable — a slice element has one, a map element does not

& does not apply to everything. The specification lists what is addressable:

The operand must be addressable, that is, either a variable, pointer indirection, or slice indexing operation; or a field selector of an addressable struct operand; or an array indexing operation of an addressable array.

— The Go Specification — Address operators

Slice indexing is on the list, map indexing is not, and the compiler confirms it.

Output of bench/goptr/compile.sh, cases mapaddr and mapfield (the files intentionally do not compile):

mapaddr:
  ./main.go:9:8: invalid operation: cannot take address of m["a"] (map index expression of struct type Point)

mapfield:
  ./main.go:9:2: cannot assign to struct field m["a"].X in map

The second error follows from the first. m["a"].X = 10 assigns to a field of a struct stored in a map; a field is addressable only in an addressable struct, and a map element is not addressable. The fix: take the value out, change it and put it back — or store pointers in the map. Why maps work this way — entries move to a new table as the map grows — is covered in the lesson on maps.

The address of a slice element can be taken — and right away you can see what happens to the address of an element that has moved.

Output of bench/goptr/internals.go, section 5; labels translated:

5) ADDRESS OF A SLICE ELEMENT AND append
────────────────────────────────────────
  s := []int{1, 2, 3}; e := &s[0]; *e = 10 -> s = [10 2 3]
  len 3, cap 3
  s = append(s, 4)         -> len 4, cap 6
  e == &s[0]               -> false   (array reallocated)
  *e = 100                 -> s[0] = 10, *e = 100
  with spare capacity: make([]int, 3, 8), e2 := &s2[0], append, *e2 = 100
  e2 == &s2[0]             -> true
  s2[0]                    -> 100

There was no spare capacity, so append allocated a new array and copied the elements over. s now looks at the new array and e at the old one. The write *e = 100 succeeded, but into an array the slice no longer sees: s[0] = 10. With spare capacity the array is the same, and the link is kept.

Note what is not happening here: a dangling pointer as in C. The pointer e is not broken — reading and writing through it is legal, and the old array has not gone anywhere. That is exactly why it stays in memory as long as e is alive.

Output of bench/goptr/audit.go, section 2; labels translated:

2) THE OLD ARRAY AFTER REALLOCATION
───────────────────────────────────
  s := make([]byte, 8 MB); e := &s[0]; s = append(s, 1)
  *e = 5; *e, s[0]                -> 5 0   (the write is legal, but goes to the old array)
  live heap after GC, e alive     -> 18 MB
  live heap after GC, e = nil     -> 10 MB
  While e is alive, the whole old 8 MB array is alive too.

Hence the rule for code: a pointer to a slice element is linked to the slice's current array until append reallocates it; after that it works with the old array and keeps it in memory. Holding such a pointer across an append means depending on a capacity nobody usually controls. How growth works and when the array is reallocated is in the lesson on slices.

In C, returning the address of a local variable is a bug: the function's frame has been freed and the pointer looks at garbage. In Go it is an ordinary constructor: the variable lives as long as something points to it. Where to place it — in the function's frame (on the stack) or on the heap, memory that outlives the call — is decided by the compiler. In the run the function is marked //go:noinline, that is, it is not inlined into the call site.

Fragment of bench/goptr/internals.go:

GO
//go:noinline
func newCounter() *int {
    c := 0
    return &c
}

Output of bench/goptr/internals.go, section 6; labels translated:

6) A POINTER TO A LOCAL VARIABLE
────────────────────────────────
  c1, c2 := newCounter(), newCounter(); *c1 += 1
  *c1, *c2                 -> 1 0
  c1 == c2                 -> false   (each call gets its own variable)
  allocations per call:
    newCounter() (return &c)   -> 1
    localOnly()  (&c stays in) -> 0

Each call yields its own variable, and it outlived the function. In this context — the function is not inlined, the caller needs the result — each call cost one allocation. The specification says nothing about the stack and the heap — it speaks of variables. The compiler's decision can be seen.

Output of bench/goptr/escape.sh (file cases/escape/main.go); the header translated:

go1.24.7 linux/amd64 | go build -gcflags='-m -l': compiler decisions

  ./main.go:9:2: moved to heap: c
  ./main.go:22:34: new(Point) escapes to heap
  ./main.go:24:31: &Point{} escapes to heap
  ./main.go:28:10: new(Point) does not escape
  ./main.go:34:7: &Point{} does not escape

Line 9 is c in newCounter: its address is returned, the variable must outlive the frame, and the compiler moves it to the heap. Lines 22–34 are new(Point) and &Point{} in identical positions: when the pointer is returned, both escapes to heap; when it stays inside the function, both does not escape. That is the answer to "are new(Point) and &Point{} the same": the compiler does not tell them apart even here.

These lines also show the reverse: taking an address does not by itself mean a heap allocation. In localOnly the address of c is taken but does not leave the function, and there are zero allocations.

And one more thing: moved to heap: c on line 9 is a decision about the function compiled on its own (the -l flag forbids inlining). If the function can be inlined, the compiler decides again at every call site. The same newCounter without //go:noinline and without -l.

Output of bench/goptr/inline.sh (file cases/inline/main.go; local is line 16, global line 23); the header translated:

go1.24.7 linux/amd64 | go build -gcflags='-m': inlining and the call site

  ./main.go:16:17: inlining call to newCounter
  ./main.go:23:20: inlining call to newCounter
  ./main.go:27:7: inlining call to newCounter
  ./main.go:28:8: inlining call to newCounter
  ./main.go:7:2: moved to heap: c
  ./main.go:23:20: moved to heap: c
  ./main.go:28:8: moved to heap: c

Output of bench/goptr/audit.go, section 3 (allocations per call, testing.AllocsPerRun); labels translated:

3) ALLOCATIONS DEPEND ON THE CALL SITE
──────────────────────────────────────
  newCounterInl is inlined (no //go:noinline):
    p := newCounterInl(); *p++; sinkN = *p  -> 0
    sinkP = newCounterInl()                 -> 1
  newCounter with //go:noinline:
    p := newCounter(); *p++; sinkN = *p     -> 1

In the function local (line 16) the pointer does not leave it, and there is no moved to heap line for that call site; the measurement gives zero allocations. In global (line 23) the pointer goes into a global variable — one allocation. Hence the correct statement: returning the address of a local variable is correct; whether there is an allocation, and how many, is decided by the compiler at the specific call site. The allocation counts here are an observation of go1.24.7 (the same on go1.25.1), not a promise of the language. How the compiler decides and which other cases send a variable to the heap — the lesson on escape analysis.

Mechanism 5: no pointer arithmetic — only through unsafe

In C, p + 1 is a pointer to the next element. In Go it does not compile.

Output of bench/goptr/compile.sh, case ptrarith (the file intentionally does not compile):

ptrarith:
  ./main.go:8:6: invalid operation: p + 1 (mismatched types *int and untyped int)
  ./main.go:9:2: invalid operation: p++ (non-numeric type *int)

There are no arithmetic operations on pointers: no addition, no subtraction, no ++, no < between pointers. They can be compared only for equality (including with nil). So a pointer in Go does not leave the variable it points to — you move to the neighbouring array element by index, and the index is bounds-checked.

If address arithmetic is really needed — say, to parse a binary format without copying — it is in the unsafe package.

Output of bench/goptr/internals.go, section 7; labels translated:

7) ARITHMETIC — ONLY THROUGH unsafe
───────────────────────────────────
  arr := [4]int32{10, 20, 30, 40}; q := &arr[0]
  unsafe.Sizeof(arr[0])    -> 4
  *(*int32)(unsafe.Add(unsafe.Pointer(q), 4))  -> 20
  *(*int32)(unsafe.Add(unsafe.Pointer(q), 12)) -> 40
  without unsafe: arr[1]   -> 20

The offset is counted in bytes, not elements, and there is no bounds check. The package documentation names the price plainly:

Pointer therefore allows a program to defeat the type system and read and write arbitrary memory. It should be used with extreme care.

— The unsafe package — Pointer

And one more thing: packages that import unsafe, in the words of the same documentation, "may be non-portable and are not protected by the Go 1 compatibility guidelines". In an interview that is enough: the language has no pointer arithmetic, unsafe is the way out for rare places, and you pay for it with guarantees.

Deeper: value or pointer — the cost of a copy against the cost of the heap

measured observationbench/goptr/practice.go, go1.24.7 linux/amd64, GOMAXPROCS=2, best of 11 testing.Benchmark rounds (the minimum, not a typical time); the functions are marked //go:noinline and the result is written to a global variable. The ratios vary noticeably from run to run; this is an observation of one configuration — measure on your own machine and version.

"Pass large structs by pointer, it is faster" is advice that is half right. The first half is the copy.

Fragment of the output of bench/goptr/practice.go, task 2; labels translated:

  size     value      pointer     ratio
    16 B     1.66 ns    1.56 ns    1.07
  1024 B    23.01 ns    1.65 ns   13.92

The function reads two fields and nothing else. At 16 bytes the difference is a fraction of a nanosecond: there is almost nothing to copy. At a kilobyte the value is 13.92 times more expensive: every call copies 1024 bytes. The same dependence for a method receiver is covered in the lesson on receivers.

The second half is where the pointer comes from. Two functions build the same kilobyte struct; one returns it by value, the other returns a pointer to its own variable.

Fragment of bench/goptr/practice.go; comments removed:

GO
//go:noinline
func makeBig() Big {
    var b Big
    b.A[0] = 1
    return b
}
 
//go:noinline
func newBig() *Big {
    b := &Big{}
    b.A[0] = 1
    return b
}

Fragment of the output of the same run; labels translated:

  makeBig() Big     25.62 ns   allocs 0, bytes 0
  newBig()  *Big   301.79 ns   allocs 1, bytes 1024
  ratio newBig / makeBig            11.8

Here the pointer is more expensive, by 11.8 times. Copying a kilobyte takes a few tens of nanoseconds; newBig makes a 1024-byte heap allocation on every call, and that memory later has to be reclaimed by the garbage collector. Mechanism 4 showed where the allocation comes from: a variable whose address was returned must outlive the frame.

A summary of this measurement, instead of the rule "a pointer is faster". The mechanism explains where the difference comes from; whether it exists in your code and how large it is, only a measurement on your side shows:

situationwhat turned out cheaper herewhy
pass a small structno differencethere is almost nothing to copy
pass a large struct that already existspointeran address is copied, not a kilobyte
create a struct and return itvalue, if the pointer sends it to the heaphere an allocation cost more than a copy
the function must change the caller's variablepointera question of meaning, not cost

The last row is the main one. The choice between a value and a pointer starts with "must the function change the original" and "may the value be absent (nil)", and the cost is checked with a profiler where it matters — the numbers above answer "can it go either way", not "how it will be in your code".

How to answer in an interview

Short answer: a pointer is a value that holds the address of a variable. In Go everything is passed by value, the pointer too: a function receives a copy of the address. A write through the pointer (*p = 42) is visible to the caller because both copies lead to one variable; assigning to the parameter itself (p = nil) is not, because only the copy changes.

This is enough to answer correctly. What follows is what you add if the interviewer digs.

If the interviewer digs deeper

On "by reference", show the two address labels. "Outside, p holds <addr 1> and lives at <addr 2>; inside the function it holds the same <addr 1> but lives at <addr 3>. One value, two variables — that is why p = nil inside is not visible outside."

On "a method on nil", the line is the dereference. "A method with a pointer receiver can be called on nil: the receiver is just a parameter. It panics if the body touches a field, or if the method is declared on the value — then *p has to be copied. Nil-safe methods like Sum on an empty tree are built on this."

On "the address of a map element", addressability. "&m[k] does not compile, and neither does m[k].X = 1. A slice element does have an address, but after an append that reallocated the array the pointer writes into the old array — in the run s[0] = 10, *e = 100."

On "returning a pointer to a local", lifetime is separate from placement. "It is legal: the variable lives as long as something points to it. Where to place it is up to the compiler: for a function compiled on its own -gcflags=-m prints moved to heap: c, but in our run an inlined call whose pointer did not leave it made no allocation."

On "which is faster", both sides. "Passing an existing large struct by pointer is cheaper than copying it. But returning a pointer to a new struct can cost more than returning it by value, because of the heap allocation; in the measurement I have seen, it did. By how much depends on the machine and the code — that is measured."

Next they ask

Next they ask

But aren't slices and maps passed by reference?

Short answer

No — by value as well. What is copied is the slice header (a pointer to the array, the length, the capacity) or the pointer to the map's internal structure. So whatever goes through the shared pointer is visible, and whatever changes the copy itself is not.

Output of bench/goptr/internals.go, section 8; labels translated:

8) SLICE AND MAP AS A PARAMETER: A COPY OF THE HEADER
─────────────────────────────────────────────────────
  setFirst(s): s[0] = 100          outside s = [100 2 3]    — visible
  grow(s):     s = append(s, 4)    outside len(s) = 3    — not visible
  put(m):      m["k"] = 1          outside len(m) = 1    — visible
  resetMap(m): m = nil             outside m == nil: false    — not visible

It is the same mechanism as with *int: s[0] = 100 writes through the pointer to the array, while append and m = nil change the copy. More in the lessons on slices and maps.

Next they ask

How can a function change the caller's pointer?

Short answer

Two ways. The idiomatic one is to return the new pointer: p = f(p). If that is not possible, pass the address of the pointer, a **T, like retarget(&p) in Mechanism 1: the run showed that after the call p points at other, and *p = 7.

The second way is less common; if it shows up in ordinary code, it is worth checking whether simply returning the new pointer would do.

Next they ask

When are two pointers equal?

Short answer

When they point to the same variable or are both nil. In the run a := new(Point) and b := &Point{} gave a == b — false: these are two different variables with the same contents. To compare contents, use *a == *b.

The specification adds a caveat to this rule: "Pointers to distinct zero-size variables may or may not be equal". So pointers to a struct with no fields cannot be used as unique tokens — equality of two distinct such variables is guaranteed neither way.

Next they ask

Why did code that read the result before checking the error start panicking after an upgrade?

Short answer

Because it should have panicked before and did not. In go1.24.7 the compiler could move the nil check into the branch where the loaded value is used, and on the other path it did not run: f(nil, false) from the regression test issue72860.go returned 5, and file.Name() on nil passed silently. On go1.25.1 both cases panic, as the specification requires.

The fix is not rolling back the compiler but the order of lines: first if err != nil, then the result. Code that relied on the old behaviour was wrong before too — it just did not show.

Common misconceptions

Claim

Pointers in Go are passed by reference

Actually

Everything is passed by value; a pointer's value is an address. In the run, inside the function p holds the same <addr 1> but lives at <addr 3>, not at <addr 2>: it is a different variable. That is why p = nil inside is not visible outside — p == nil: false.

Claim

Calling a method on a nil pointer always panics

Actually

Only if the method dereferences the receiver. nt.Kind() and nt.Sum() on a *Tree holding nil ran fine — "tree" and 0. A method on the value (np.Sum() on Point) and a field access panic.

Claim

Dereferencing nil always panics immediately

Actually

According to the specification, yes. But go1.24.7 could move the check into the branch where the loaded value is used and skip it on the other path: f(nil, false) returned 5. On go1.25.1 it panics. "It did not panic" is nothing to rely on.

Claim

A pointer to a slice element always points into the slice

Actually

Only until the array is reallocated. After append to a slice with cap 3, the write *e = 100 went into the old array: s[0] = 10, *e = 100. There is no error.

Claim

After reallocation a pointer to an old element is invalid, as in C

Actually

It is valid: reading and writing through it is legal. It has only lost its link to the slice's current array — and keeps the old array in memory: in the run the live heap is 18 MB while the pointer is alive and 10 MB without it.

Claim

You must not return a pointer to a local variable, just like in C

Actually

You may: the variable lives as long as something points to it. Whether there is an allocation is decided by the compiler at the call site: newCounter() with //go:noinline made 1 allocation per call and moved to heap: c, while an inlined copy whose pointer did not leave made 0.

Claim

new(T) allocates on the heap while &T{} allocates on the stack (or the other way round)

Actually

For Point the compiler makes identical decisions for them: both escapes to heap when the pointer is returned, both does not escape when it is not. Where the variable ends up is decided by escape analysis, not by the notation.

Claim

new(T) and &T{} are always the same thing

Actually

For a struct, yes. For a slice and a map, no: *new(map[string]int) is nil, and writing to it panics with assignment to entry in nil map; &map[string]int{} gives a map ready for writes.

Claim

Passing by pointer is always faster

Actually

Passing an existing large struct — in our measurement, yes. But in the same measurement returning a pointer to a new struct turned out more expensive than returning it by value: 301.79 ns with a 1024-byte allocation against 25.62 ns with none (go1.24.7 linux/amd64, best of 11 rounds). At 16 bytes there was almost no difference when passing. On another machine the numbers will differ — they are measured.

Version history

The lesson's measurements were taken on go1.24.7 and go1.25.1, linux/amd64; the language rules were checked against their specifications on 14.10.2026. Newer versions had been released by that date; of their release notes, one change in Go 1.26 concerns this lesson — it is in the last row of the table, and the lesson did not measure it: Go 1.26 is not in the measurement environment.

VersionChangeWhat it means for code
go1.24.7The compiler may move the nil check into the branch where the loaded value is used, and on the other path it does not run: f(nil, false) from issue72860.go returned 5, and file.Name() on nil passed without a panic. Checked by running bench/goptr/nilcheck.go.code that touches the result before the error check may not panic
go1.25.1Both cases panic with runtime error: invalid memory address or nil pointer dereference, as the specification requires. The tighten pass no longer moves nil checks (ssa/tighten.go, lines 48–49), and the distribution gained the regression test test/fixedbugs/issue72860.go.such code panics: check the error first, then the result
Go 1.26Per the release notes: "The built-in new function, which creates a new variable, now allows its operand to be an expression, specifying the initial value of the variable". Not measured: we have go1.24.7 and go1.25.1, and on them new(42) does not compile — 42 is not a type (bench/goptr/compile.sh, case newexpr).new(expression) since Go 1.26; on older versions a compile error

Practice

Two tasks. Answer first, then check against the real output: in both, the correct answer is taken from the script's run, not assigned.

Practice · predict the output

reset assigns nil to its parameter, set writes through its parameter. Then a pointer to the first element of a slice with length and capacity 3 is taken, and an element is appended to the slice. What does this code print?
func reset(p *int) { p = nil }
func set(p *int)   { *p = 7 }

x := 1
p := &x
reset(p)
fmt.Println(p == nil)
set(p)
fmt.Println(x)
s := []int{1, 2, 3}
q := &s[0]
s = append(s, 4)
*q = 100
fmt.Println(s[0], *q)

Practice · estimate

Big is a 1024-byte struct. makeBig() builds it and returns it by value; newBig() builds it with &Big{} and returns a pointer. Neither function is inlined. How many times more expensive is a call to newBig?
times

Knowledge check

Question 1 of 6

func drop(p *int) { p = nil }. The caller passes p := &x and after drop(p) checks p == nil. What is the result?

Sources & further reading

10 SOURCES

  1. The Go Specification — Pointer types and Comparison operatorsOfficial documentation. What a pointer is: «A pointer type denotes the set of all pointers to variables of a given type, called the base type of the pointer. The value of an uninitialized pointer is nil». When pointers are equal: «Two pointer values are equal if they point to the same variable or if both have value nil. Pointers to distinct zero-size variables may or may not be equal». Checked against doc/go_spec.html of the go1.24.7 and go1.25.1 distributions.https://go.dev/ref/spec#Pointer_types
  2. The Go Specification — CallsOfficial documentation. Why a parameter is a copy: «After they are evaluated, new storage is allocated for the function's variables, which includes its parameters and results. Then, the arguments of the call are passed to the function, which means that they are assigned to their corresponding function parameters, and the called function begins execution». And a method call without an ampersand: «If x is addressable and &x's method set contains m, x.m() is shorthand for (&x).m()».https://go.dev/ref/spec#Calls
  3. The Go Specification — Address operatorsOfficial documentation. What has an address: «The operand must be addressable, that is, either a variable, pointer indirection, or slice indexing operation; or a field selector of an addressable struct operand; or an array indexing operation of an addressable array». The exception: «As an exception to the addressability requirement, x may also be a (possibly parenthesized) composite literal». Dereferencing nil: «For an operand x of pointer type *T, the pointer indirection *x denotes the variable of type T pointed to by x. If x is nil, an attempt to evaluate *x will cause a run-time panic».https://go.dev/ref/spec#Address_operators
  4. The Go Specification — SelectorsOfficial documentation. Where p.X without a star comes from: «For a value x of type T or *T where T is not a pointer or interface type, x.f denotes the field or method at the shallowest depth in T where there is such an f». And nil: «If x is of pointer type and has the value nil and x.f denotes a struct field, assigning to or evaluating x.f causes a run-time panic».https://go.dev/ref/spec#Selectors
  5. The Go Specification — Variables, Composite literals, AllocationOfficial documentation. What new(T) and &T{} have in common: «Calling the built-in function new or taking the address of a composite literal allocates storage for a variable at run time». On the literal: «Taking the address of a composite literal generates a pointer to a unique variable initialized with the literal's value». On new: «The built-in function new takes a type T, allocates storage for a variable of that type at run time, and returns a value of type *T pointing to it». And where the forms differ: «Note that the zero value for a slice or map type is not the same as an initialized but empty value of the same type».https://go.dev/ref/spec#Allocation
  6. Go 1.26 Release Notes — new with an expressionOfficial documentation. «The built-in new function, which creates a new variable, now allows its operand to be an expression, specifying the initial value of the variable». Not measured by this lesson: Go 1.26 is not in the measurement environment; on go1.24.7 and go1.25.1 new(42) does not compile (bench/goptr/compile.sh, case newexpr). Checked on 14.10.2026.https://go.dev/doc/go1.26
  7. The unsafe package — Pointer and AddOfficial documentation. What arithmetic costs: «Pointer therefore allows a program to defeat the type system and read and write arbitrary memory. It should be used with extreme care». And the arithmetic itself: «The function Add adds len to ptr and returns the updated pointer Pointer(uintptr(ptr) + uintptr(len))». Checked against go doc unsafe.Pointer and go doc unsafe.Add, go1.24.7.https://pkg.go.dev/unsafe#Pointer
  8. runtime/panic.go and runtime/error.go — the panic text on a nil dereferenceGo source code. Line 258 of runtime/panic.go in go1.24.7 and go1.25.1: var memoryError = error(errorString("invalid memory address or nil pointer dereference")); lines 260–263 are panicmem, which panics with that value. The «runtime error: » prefix is added by the errorString.Error method — runtime/error.go, lines 81–82 in go1.24.7 (91–92 in go1.25.1). What makes the value a runtime.Error is not the text but the RuntimeError() method — line 79 in go1.24.7 (89 in go1.25.1); go doc runtime.Error: «a type is a run time error if it has a RuntimeError method».https://github.com/golang/go/blob/go1.24.7/src/runtime/panic.go#L258-L263
  9. test/fixedbugs/issue72860.go — a nil check the compiler skippedGo source code. A regression test from the go1.25.1 distribution; the go1.24.7 distribution has no such file. Function f reads *p into valid and returns on !b without looking at valid; the test requires a panic on f(nil, false). The run of bench/goptr/nilcheck.go: on go1.24.7 the call returned 5, on go1.25.1 it panicked.https://github.com/golang/go/blob/go1.25.1/test/fixedbugs/issue72860.go
  10. cmd/compile/internal/ssa/tighten.go — where a nil check must stayGo source code. The tighten pass, line 9: «tighten moves Values closer to the Blocks in which they are used». In go1.25.1 lines 48–49 add an exception: «Nil checks need to stay in their block. See issue 72860». The tighten.go of the go1.24.7 distribution has no such exception.https://github.com/golang/go/blob/go1.25.1/src/cmd/compile/internal/ssa/tighten.go#L48-L49