- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You reach for unsafe.Pointer to skip a copy. Maybe a []byte
you want as a string without the allocation, maybe a struct you
want to reinterpret as another. The code compiles. go vet stays
quiet. Tests pass. Then, weeks later, under GC pressure, a rare
crash shows up in a place that has nothing to do with your change.
The Go unsafe package documentation is one long doc comment. It
lists the conversions that are valid and warns that everything else
is not portable and not guaranteed to keep working. The trouble is
that "everything else" includes a lot of code that looks obviously
correct. The rules are narrow on purpose. There are effectively four
patterns you are allowed to write, and the standard library stays
inside all four.
Here they are, in the shape you will actually use them in Go 1.23+.
The one fact under every rule
unsafe.Pointer is a pointer the garbage collector understands. It
keeps the object it points at alive and it moves with the object if
the runtime relocates a stack. uintptr is a plain integer. The GC
does not treat it as a reference. Convert a pointer to a uintptr,
store that integer in a variable, and as far as the runtime is
concerned nothing points at that memory anymore.
That single fact is behind three of the four patterns. Every legal
conversion either avoids uintptr entirely or keeps it inside one
expression where the compiler can prove the pointer stays live.
Pattern 1: reinterpret *T1 as *T2 with the same layout
The first pattern converts a *T1 to *T2 when the two types have
the same memory shape. You take the address, run it through
unsafe.Pointer, and cast to the target pointer type.
The standard library does exactly this in math.Float64bits:
func Float64bits(f float64) uint64 {
return *(*uint64)(unsafe.Pointer(&f))
}
A float64 and a uint64 are both 8 bytes. Reading the bits of one
through a pointer to the other gives you the raw representation with
no arithmetic and no copy.
The rule has a hard edge: T2 must not be larger than T1, and the
layouts have to be equivalent. Reinterpret a [4]byte as a uint32
and you are fine. Reinterpret a uint32 as a [8]byte and you are
reading four bytes past the value. That read is out of bounds even
though the compiler said nothing.
Pattern 2: pointer arithmetic in a single expression
This is the pattern people get wrong. To walk to a field or an array
element by offset, you convert to uintptr, add the offset, and
convert back. The rule is that the whole round trip has to happen in
one expression.
The wrong version stores the integer:
// WRONG: uintptr lives in a variable across statements
addr := uintptr(unsafe.Pointer(&arr[0]))
addr += uintptr(i) * unsafe.Sizeof(arr[0])
p := (*T)(unsafe.Pointer(addr)) // may already be stale
Between the first line and the third, nothing holds a live pointer
to arr. If the stack grows and moves, or the object is collected,
addr now points at freed or relocated memory. The bug is timing
dependent, which is why it survives tests and shows up under load.
Since Go 1.17 you write this with unsafe.Add, which keeps the
pointer live and reads better:
p := (*T)(unsafe.Add(
unsafe.Pointer(&arr[0]),
uintptr(i)*unsafe.Sizeof(arr[0]),
))
If you must use raw uintptr, keep the conversion, the arithmetic,
and the conversion back inside one statement:
p := (*T)(unsafe.Pointer(
uintptr(unsafe.Pointer(&arr[0])) + off,
))
The compiler recognizes that shape and holds the source pointer
alive across the arithmetic. Split it into two lines and that
guarantee is gone.
Pattern 3: uintptr(unsafe.Pointer(...)) inside a syscall call
The third pattern is a special case the compiler knows about by
name. When you pass uintptr(unsafe.Pointer(p)) as an argument
directly in a call to syscall.Syscall and its siblings, the
runtime keeps p alive for the duration of the call.
n, _, errno := syscall.Syscall(
syscall.SYS_READ,
uintptr(fd),
uintptr(unsafe.Pointer(&buf[0])),
uintptr(len(buf)),
)
The kernel needs a raw address, so the argument has to be a
uintptr. Normally that would make the buffer eligible for
collection while the syscall is still writing into it. The compiler
special-cases the conversion when it appears in the call expression
itself, so the buffer survives.
Pull it out into a variable first and the protection disappears:
// WRONG: buffer not kept alive across the call
ptr := uintptr(unsafe.Pointer(&buf[0]))
syscall.Syscall(syscall.SYS_READ, uintptr(fd),
ptr, uintptr(len(buf)))
In application code you rarely call syscall.Syscall directly
anymore; golang.org/x/sys/unix wraps most of it. But the rule is
the same wherever a syscall wrapper takes a raw address argument.
Pattern 4: string and slice headers without a copy
The fourth pattern turns raw bytes into a string or a []T and
back without copying. Before Go 1.20 this meant poking at
reflect.StringHeader and reflect.SliceHeader, which is fragile
and now discouraged. The modern intrinsics do it safely.
strings.Builder uses one of them to hand out its buffer as a
string with no allocation:
func (b *Builder) String() string {
return unsafe.String(unsafe.SliceData(b.buf), len(b.buf))
}
unsafe.SliceData returns the pointer to a slice's backing array.
unsafe.String builds a string header over a *byte and a length.
The pair, plus unsafe.StringData and unsafe.Slice, is the
supported way to move between the three views of the same bytes:
// []byte -> string, no copy
s := unsafe.String(unsafe.SliceData(b), len(b))
// string -> []byte, no copy (read-only in practice)
p := unsafe.StringData(s)
b := unsafe.Slice(p, len(s))
One caveat the compiler cannot enforce: strings are supposed to be
immutable. If you build a []byte view over a string and then write
to it, you have corrupted a value the rest of the program assumes is
constant. Use this direction for reads only.
Where the standard library actually uses this
You do not have to guess whether these patterns are real. Grep the
standard library and you find all four:
-
math.Float64bitsand friends use Pattern 1 to read bit layouts. -
reflectandruntimeuse Pattern 2 to walk fields and elements by offset. - The
syscallandospackages use Pattern 3 to hand buffers to the kernel. -
strings.Builderand parts ofbytesuse Pattern 4 to avoid copies.
None of them invent a fifth pattern. When the runtime authors, who
wrote the GC, want to touch memory unsafely, they stay inside the
same four conversions the docs hand you. That is the signal worth
taking: if the answer is not one of these four, the answer is to not
use unsafe.
Catching the violations before production
Two tools cover most of the gap between "compiles" and "correct."
go vet runs an unsafeptr analyzer that flags uintptr-to-
unsafe.Pointer conversions that do not match a known-safe shape.
It is not exhaustive, but it catches the stored-uintptr mistake
from Pattern 2 in common forms.
The bigger safety net is checkptr, the runtime instrumentation the
compiler inserts when you build with -race. It validates pointer
arithmetic and reinterpretation at runtime and panics on a
violation instead of returning a silently wrong value. Run your
unsafe-touching tests under -race and a lot of these bugs turn
into loud failures on the machine where you can still fix them.
The rule of thumb: reach for unsafe.Pointer only when a profiler
put you there, keep every conversion inside one of these four
shapes, and never let a uintptr outlive the expression it was born
in.
unsafe.Pointer earns its name honestly. It also has a small,
documented set of moves that are genuinely safe when you follow them
exactly. The Complete Guide to Go Programming works through the
memory model, the GC's view of pointers, and why these four patterns
are the ones the runtime allows. Hexagonal Architecture in Go is
about keeping code like this at a single well-tested boundary, so the
rest of your service never has to know it exists.

Top comments (0)