AlgoMaster Logo

unsafe.Pointer

Low Priority21 min readUpdated June 6, 2026

The unsafe.Pointer type is Go's one approved way to convert between pointers of different types or to do raw pointer arithmetic. It looks like a normal pointer, but the compiler treats it as a wildcard: any *T can be turned into an unsafe.Pointer and any unsafe.Pointer can be turned into any *U. This chapter walks through the four conversion patterns the package documentation declares safe, the rules around uintptr, the modern unsafe.Add and unsafe.Slice helpers added in Go 1.17, and the alignment and garbage-collection hazards that cause bugs.

What unsafe.Pointer Is

A normal Go pointer is strongly typed. A *Order points to an Order and nothing else. The compiler will not let you write var p *Order = &someProduct, even if the two structs happen to have the same shape. That strictness is what keeps the type system honest.

unsafe.Pointer is the way out. It's a built-in type that represents "a pointer to anything". The conversion rules in the language spec say you can convert any pointer type to unsafe.Pointer and back to any other pointer type. So unsafe.Pointer acts as a bridge type that lets you reinterpret memory under one type as if it were another type.

The numbers match because both types occupy 8 bytes with the same bit pattern for small positive values. Nothing was copied; q and &totalCents point at the same memory, just interpreted under different types. unsafe.Pointer lets you change the lens you view a block of memory through, without moving the bytes.

The type itself is defined in the unsafe package as a special built-in. You can pass it around, store it in struct fields, and return it from functions exactly like any other pointer. The garbage collector treats it like any other pointer too: it keeps the target alive and updates the address if the target moves. That second property is critical and we'll come back to it when we discuss uintptr.

The Four Conversion Patterns

The unsafe package documentation lists four patterns that involve unsafe.Pointer and that the Go team explicitly supports. Everything else is undefined behavior, even if it happens to work today on your machine. Stay within the four patterns and you'll be safe; deviate and you're writing code that may break on the next compiler release.

The diagram lists the four supported conversion shapes. Any use of unsafe.Pointer that doesn't fit one of them is, per the package docs, "invalid". The compiler won't reject it; the runtime might give you correct results for now and crash later. Treat the list as a closed set, not a starting point.

Pattern 1: Type Punning Between Pointers

The first pattern is the simplest. Convert a *T1 to unsafe.Pointer, then convert that to *T2. This is the "reinterpret these bytes as a different type" move. We saw it already with int64 and uint64.

The pattern is safe as long as the two types share the same memory layout. Same size, same alignment, same field positions if they're structs. The classic safe use is converting between []byte and a struct whose fields exactly match the byte layout, or between integer types that share a width.

This is the textbook way to look at the raw IEEE-754 bits of a float without going through math.Float64bits (which is itself implemented exactly this way internally). The conversion *(*uint64)(unsafe.Pointer(&price)) reads: take the address of price, cast it to unsafe.Pointer, cast that to *uint64, then dereference.

Pattern 2: Pointer to uintptr and Back

The second pattern converts unsafe.Pointer to uintptr and back to unsafe.Pointer, with one strict rule: the round trip must happen in a single expression. You cannot store the uintptr in a variable, on the stack, or in a struct field, and then convert it back later. The reason is that a uintptr is just an unsigned integer; the garbage collector does not treat it as a pointer. So once you've stored a uintptr, the GC may move the underlying object (because nothing else is keeping it alive at that address), and your stored integer becomes a dangling reference to memory that no longer holds what you thought.

The single-expression rule sounds pedantic but it's load-bearing. The compiler recognizes the pattern unsafe.Pointer(uintptr(unsafe.Pointer(x))) and keeps the original pointer rooted across the conversion. Break the expression apart and you lose that guarantee:

This prints the right answer today, on most platforms, but the program is broken. The Go runtime may not move stack variables in the current implementation, but the spec doesn't promise that, and a future runtime can break this code. The official rule is: never let a uintptr outlive the expression that produced it.

Pattern 3: Pointer Arithmetic Through uintptr

The third pattern is the historical way to do pointer arithmetic in Go. Convert an unsafe.Pointer to uintptr, add or subtract a byte offset, then convert back to unsafe.Pointer. The same single-expression rule applies: the whole arithmetic must happen inside one expression with no intermediate variables.

This is what unsafe.Add (added in Go 1.17) replaces and what you'll still see in older Go code. We'll cover the modern form in the next section.

The expression uintptr(p) + stride is the arithmetic. Wrapping the whole thing in unsafe.Pointer(...) keeps it in one statement so the GC still sees a pointer at the end. unsafe.Sizeof gives you the byte size of the record so you advance the right amount.

Pattern 4: Slice and String Headers

The fourth pattern is about reaching the backing array of a slice or string. Historically you did this through reflect.SliceHeader and reflect.StringHeader, two structs that mirror the runtime's slice and string layout. Since Go 1.20, unsafe.SliceData and unsafe.StringData are the recommended way.

unsafe.SliceData(prices) returns *float64 pointing at element zero. From there we can walk through the array element by element, exactly the way the runtime does internally when you index a slice. The same is true for unsafe.StringData on a string, with the caveat that you must not write through the returned pointer because strings are supposed to be immutable.

The older reflect.SliceHeader form looked like this and is now deprecated:

This still compiles, but the reflect.SliceHeader type is documented as deprecated and unsafe. Use unsafe.SliceData in new code.

unsafe.Add and unsafe.Slice

Go 1.17 added two helpers that make most uses of unsafe.Pointer cleaner and harder to get wrong. They don't do anything you couldn't do before, but they encode the safe pattern directly so the compiler can check it.

unsafe.Add(ptr, length) takes an unsafe.Pointer and an integer length and returns unsafe.Pointer(uintptr(ptr) + uintptr(length)) as a single expression. The same code without Add looks like this:

The old form is correct but easy to break. Forget the outer unsafe.Pointer wrapper and you've stored a uintptr that the GC will not track. Forget to multiply by stride and you've stepped one byte instead of one record. unsafe.Add removes both traps: it takes a typed unsafe.Pointer and returns one, and it makes the byte offset explicit.

unsafe.Slice(ptr, len) is the companion. It takes a *T and a length and returns a []T whose backing array starts at the pointer. This is the inverse of unsafe.SliceData: one gets a pointer from a slice, the other builds a slice from a pointer.

The slice view shares storage with records. Writes through view change records and vice versa. The length you pass to unsafe.Slice is a promise: the runtime trusts you that there really are that many OrderRecord values in memory starting at the pointer. Lie about the length and you'll read or write past the end of the allocation, which is exactly the class of bug Go's normal slice bounds checks are designed to prevent.

Reading a Byte Buffer as Typed Records

Here's the kind of task unsafe.Pointer is well suited to. Suppose an order-log file stores raw OrderRecord values back to back with no framing. A naive reader would loop over the bytes and decode each field manually. With unsafe.Slice we can reinterpret the whole []byte as a []OrderRecord and read it in one pass.

The flow shows the four steps. Start with a []byte you read from disk. Take a pointer to its first element. Cast that to *OrderRecord. Wrap the typed pointer and a count in unsafe.Slice to get a fully indexable []OrderRecord over the same memory.

We use unsafe.Slice twice. The first call exposes the same memory as a []byte to simulate reading from disk. The second call goes the other direction: it takes the byte slice and a record count and gives back a []OrderRecord over the same bytes. No copying happens. The total compute reduces to a tight loop over a typed slice, which is exactly what fast analytics code wants.

This trick only works if three conditions hold. The struct must have no implicit padding the source format doesn't account for (we added an explicit _pad field above to keep the total a multiple of 8). The byte order in the file must match the machine's byte order (little-endian on x86 and most ARM, big-endian on a few legacy systems). The bytes must be aligned correctly for the record type, which is the next topic.

Alignment Requirements

Different types have different alignment requirements. A byte can sit at any address. An int64 (or any 8-byte type) must sit at an address that's a multiple of 8 on most architectures. Reading an int64 from an address that's not 8-aligned can either panic, return garbage, or silently load from the wrong address, depending on the CPU.

When you cast through unsafe.Pointer, the compiler doesn't check alignment. That's your job.

The first cast is safe because buf[0] is at the start of the slice's backing array, which Go aligns for the strictest scalar type. The second case is the trap. &buf[1] is offset one byte from the aligned base, so casting it to *int64 and dereferencing produces undefined behavior. On x86 the load may work but be slower; on some ARM cores it will fault.

Use unsafe.Alignof to ask what alignment a type needs:

The struct as a whole needs 8-byte alignment because its strictest field (uint64) does. A reader that wants to interpret a []byte as a []OrderRecord must therefore ensure the byte slice starts at an 8-aligned address. make([]byte, n) returns a slice that meets this for typical scalars, but a slice that started somewhere mid-buffer (e.g., buf[1:]) does not.

How uintptr Goes Wrong

The single-expression rule for unsafe.Pointer -> uintptr -> unsafe.Pointer exists because of how the garbage collector works. The GC scans pointers and updates them when it moves their targets, which it does for some allocations to compact memory. A uintptr is invisible to the GC: it's just an integer, indistinguishable from any other number.

Three concrete failure modes show up in real code.

The first is storing a uintptr in a struct field and using it later. The runtime may move the target before "later" arrives.

It prints the right answer today on the current runtime. It's still wrong. Storing a uintptr and reusing it later is on the wrong side of the supported-pattern fence. A future Go version that moves heap objects will break this code. The right way is to keep a real *Order or unsafe.Pointer in the field; both keep the target rooted.

The second failure mode is computing an address across a possible GC point. Function calls, channel operations, and allocations are all potential GC points. If you compute uintptr(p), then call a function, then convert back, the target may have moved between the two halves.

Same caveat: prints correctly today, undefined per the spec. The supported single-expression form would be *(*Order)(unsafe.Pointer(uintptr(unsafe.Pointer(&o)))), with no runtime.GC() between the two casts.

The third failure mode is slicing past the original allocation. unsafe.Slice(ptr, n) trusts you that there are n elements at ptr. If the original allocation was only n/2 elements long, the slice will happily read past the end into whatever bytes are next door.

The first two reads are correct. Reading bad[2] through bad[9] would read whatever stack or heap bytes lie next to src, potentially crashing the program or leaking data. The rule for unsafe.Slice is to use it only when you can prove the allocation is at least that large.

Escape Analysis and unsafe

Go's compiler decides whether each value lives on the stack or the heap. The stack is faster (no allocation, freed on function return), and the compiler prefers the stack when it can prove the value doesn't escape the function's lifetime.

unsafe.Pointer interacts with escape analysis in two ways. First, taking the address of a stack value and passing it through unsafe.Pointer does not by itself force escape; the compiler still tracks where the pointer ends up. Second, converting through uintptr defeats this tracking, because once it's an integer the compiler cannot prove anything about where it goes.

Run go build -gcflags='-m' main.go and the compiler will print escape analysis decisions. For the snippet above, records typically stays on the stack because the slice view is also stack-local. The moment you store the pointer somewhere that outlives the function (a package variable, a returned value, a closure passed elsewhere), the value escapes to the heap.

The takeaway for code that uses unsafe: prefer keeping unsafe.Pointer values local and short-lived. Pass them into a function, do the work, return a normal value. The longer an unsafe.Pointer lives and the more places it travels, the harder it is to reason about whether the underlying memory is still valid.

Putting It Together: A Safe Bulk Reader

Here's a complete example that pulls together the conversion patterns, unsafe.Slice, and the alignment caveat. We read a binary order log into a []byte buffer (simulated here with a literal), check the size and alignment, and reinterpret it as []OrderRecord for analytics.

decodeOrders performs two checks before the reinterpretation: the buffer length must be a clean multiple of the record size, and the buffer base must satisfy the record's alignment. Both checks are cheap and catch the two most likely bugs at the boundary. If both pass, unsafe.Slice produces a []OrderRecord over the original bytes and the analytics loop runs at the speed of typed indexed access.

The unsafe calls are confined to a single, small, documented function. The function returns a normal Go slice. The rest of the program sees no unsafe.Pointer at all and can be written in plain Go. That's how production code uses unsafe responsibly: small, audited, well-tested helpers at the boundary, with the unsafe surface area not leaking into the rest of the codebase.

Quiz

unsafe.Pointer Quiz

10 quizzes