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.
- 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.
- what an address,
&and*are, and whyp.Xis 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.Pointeris.
What is really being asked
The ladder usually looks like this:
- "What is a pointer and why do you need it?" — the warm-up; "the address of a variable" is correct but explains nothing.
- "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.
- "What happens if you assign
nilto a pointer parameter inside a function?" — the same check, on code. - "Can you call a method on a nil pointer?" — and what panics then and what does not.
- "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.
- "Can you return a pointer to a local variable?" — a question from C; in Go the answer is different.
- "Does Go have pointer arithmetic?" — and what to do if you really need it.
- "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.
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):
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 receiverOutput 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()
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.
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.
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.
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):
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 itselfOutput 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 = 42writes at the address — intox, shared by both copies. That is visible outside.p = &ychanges the copy itself. The outerpknows nothing about it and still points atx; 99 went intoy.p = nilis 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 .:
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:
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 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:
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 fieldsOutput 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:
*npandnp.Xdereference — 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 valuePoint. To call it,*nphas to be copied into the receiver, and that is a dereference — a panic.nt.Kind()andnt.Sum()are methods on the pointer*Tree. The receiver is just a parameter holdingnil; there is no panic until the body touches a field.Kindtouches no fields,Sumcheckst == nilfirst.
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
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: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.
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.
Mechanism 4: a pointer to a local variable is legal
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: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.
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
"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: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:
| situation | what turned out cheaper here | why |
|---|---|---|
| pass a small struct | no difference | there is almost nothing to copy |
| pass a large struct that already exists | pointer | an address is copied, not a kilobyte |
| create a struct and return it | value, if the pointer sends it to the heap | here an allocation cost more than a copy |
| the function must change the caller's variable | pointer | a 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
But aren't slices and maps passed by reference?
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.
How can a function change the caller's pointer?
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.
When are two pointers equal?
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.
Why did code that read the result before checking the error start panicking after an upgrade?
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
Pointers in Go are passed by reference
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.
Calling a method on a nil pointer always panics
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.
Dereferencing nil always panics immediately
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.
A pointer to a slice element always points into the slice
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.
After reallocation a pointer to an old element is invalid, as in C
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.
You must not return a pointer to a local variable, just like in C
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.
new(T) allocates on the heap while &T{} allocates on the stack (or the other way round)
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.
new(T) and &T{} are always the same thing
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.
Passing by pointer is always faster
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.
| Version | Change | What it means for code |
|---|---|---|
| go1.24.7 | The 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.1 | Both 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.26 | Per 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
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
Knowledge check
func drop(p *int) { p = nil }. The caller passes p := &x and after drop(p) checks p == nil. What is the result?
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- A pointer is a value that holds the address of a variable.
&xtakes the address,*pgives the variable at the address, and the zero value of a pointer isnil. Fields and methods can be reached without a star:p.Xis(*p).X, andv.Move(5)for a method on*Pointis(&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:newgives a pointer to anilslice ornilmap,&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 =,&p =; inside the functionp =,&p =— the same value, a different variable. That is why*p = 42inside is visible outside, whilep = &yandp = nilare 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
nilpanics withruntime 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 anappendthat 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 printsmoved to heap: c(moves it to the heap, memory that outlives the call). There is no pointer arithmetic:p + 1does not compile, and the only way around isunsafe. 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.
In fact
- Everything is passed by value; a pointer's value is an address. In the run, inside the function
pholds the same<addr 1>but lives at<addr 3>, not at<addr 2>: it is a different variable. That is whyp = nilinside is not visible outside —p == nil: false. - Only if the method dereferences the receiver.
nt.Kind()andnt.Sum()on a*Treeholdingnilran fine —"tree"and0. A method on the value (np.Sum()onPoint) and a field access panic. - 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)returned5. On go1.25.1 it panics. "It did not panic" is nothing to rely on. - Only until the array is reallocated. After
appendto a slice withcap 3, the write*e = 100went into the old array:s[0] = 10, *e = 100. There is no error. - 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.
- 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:noinlinemade 1 allocation per call andmoved to heap: c, while an inlined copy whose pointer did not leave made 0. - For
Pointthe compiler makes identical decisions for them: bothescapes to heapwhen the pointer is returned, bothdoes not escapewhen it is not. Where the variable ends up is decided by escape analysis, not by the notation. - For a struct, yes. For a slice and a map, no:
*new(map[string]int)isnil, and writing to it panics withassignment to entry in nil map;&map[string]int{}gives a map ready for writes. - 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.
By version
- go1.24.7
- The compiler may move the
nilcheck into the branch where the loaded value is used, and on the other path it does not run:f(nil, false)fromissue72860.goreturned5, andfile.Name()onnilpassed without a panic. Checked by runningbench/goptr/nilcheck.go.< - go1.25.1
- Both cases panic with
runtime error: invalid memory address or nil pointer dereference, as the specification requires. Thetightenpass no longer moves nil checks (ssa/tighten.go, lines 48–49), and the distribution gained the regression testtest/fixedbugs/issue72860.go.< - Go 1.26
- Per 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, casenewexpr).<
What is covered
- What is really being asked
- Base: the address of a variable and the variable at an address
- Mechanism 1: a pointer parameter is a copy
- Mechanism 2: nil — a panic on dereference, but not on a method call
- Mechanism 3: an address belongs to a variable — a slice element has one, a map element does not
- Mechanism 4: a pointer to a local variable is legal
- Mechanism 5: no pointer arithmetic — only through unsafe
- Deeper: value or pointer — the cost of a copy against the cost of the heap
- How to answer in an interview
- Next they ask
- Common misconceptions
- Version history
- Practice
- Knowledge check
Sources & further reading
10 SOURCES
- 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
- 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
- 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
- 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
- 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
- Go 1.26 Release Notes — new with an expressionOfficial documentation. «The built-in
newfunction, 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 - 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
- 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 arepanicmem, which panics with that value. The «runtime error: » prefix is added by theerrorString.Errormethod — 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 theRuntimeError()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 - 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 - 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