The escape hatch
You are writing a Go program that needs to talk to a legacy C library. The library expects a raw memory address and a byte count. Go's type system refuses to hand over a direct pointer to your slice. Every pointer in Go carries a type. The compiler uses that type to enforce bounds, track ownership, and keep the garbage collector honest. When you hit a wall where the type system blocks a legitimate low-level operation, you step outside the guardrails. That is what unsafe.Pointer is for. It is a deliberate escape hatch built into the language for when you absolutely must manipulate raw memory addresses.
What unsafe.Pointer actually is
In normal Go code, a pointer is typed. *int points to an integer. *MyStruct points to a struct. The compiler knows the size, the alignment, and the lifetime of whatever the pointer references. unsafe.Pointer strips all of that away. It is a raw memory address with no type information attached. You can convert any typed pointer to unsafe.Pointer, and you can convert unsafe.Pointer back to any other pointer type. The compiler will not stop you. The responsibility for correctness shifts entirely to you.
Think of it like a universal key that fits any lock in a building. The building's security system normally tracks which key opens which door, and it automatically locks doors when the last person leaves. When you use a universal key, the security system stops tracking the door. You know exactly where it opens. You also know that if you lose track of who is still inside, the building might clear the room while people are still there. The language gives you the key because sometimes you need it. It also expects you to be careful.
unsafe.Pointer is not a magic type. It is a bridge between Go's managed memory model and unmanaged memory. The garbage collector recognizes it as a valid reference to heap memory, but it does not know the size or layout of the object behind it. That limitation shapes how you use it.
A minimal example
Here is the simplest way to convert a Go slice into a raw pointer and back. The example shows the exact conversion chain the compiler requires.
package main
import (
"fmt"
"unsafe"
)
func main() {
// Create a slice on the heap so the GC can track it
data := []byte{72, 101, 108, 108, 111}
// Get a pointer to the first element, then erase the type
rawPtr := unsafe.Pointer(&data[0])
// Convert to uintptr for arithmetic or passing to C
addr := uintptr(rawPtr)
// Add 2 to skip the first two bytes
shifted := unsafe.Pointer(addr + 2)
// Cast back to a typed pointer to read the value
thirdByte := *(*byte)(shifted)
fmt.Println(thirdByte) // prints: 108
}
The code follows a strict conversion path. Go does not allow direct arithmetic on pointers. You must convert unsafe.Pointer to uintptr to do math, then convert back to unsafe.Pointer before treating it as a memory reference again. The *(*byte)(shifted) syntax dereferences the pointer after casting it to *byte. Each step is explicit because the compiler cannot verify safety.
unsafe.Pointer is a type eraser. Arithmetic requires uintptr. Dereferencing requires a typed pointer.
How the runtime handles it
When the compiler sees unsafe.Pointer, it disables the usual pointer analysis. It still generates valid machine code, but it stops tracking the relationship between the pointer and the underlying object. At runtime, the garbage collector scans the stack and heap for valid pointers. When it encounters an unsafe.Pointer, it treats it as a live reference and keeps the underlying object alive. That behavior is intentional. It prevents the GC from collecting memory that C code or hardware might still be reading.
The trap appears when you store a uintptr value. The GC treats uintptr as an integer, not a pointer. If you convert an unsafe.Pointer to uintptr, store it in a variable, and then let the original Go object go out of scope, the GC will collect the object. Your uintptr will point to freed memory. The program will read garbage or crash on the next access. You must keep a live unsafe.Pointer or a regular typed pointer in scope for as long as the raw address is in use.
The runtime also enforces alignment. Every type in Go has a minimum memory alignment requirement. If you cast a pointer to a type that requires stricter alignment than the original memory provides, the program will panic on some architectures. The compiler cannot catch this at build time. The CPU will raise a hardware fault when the misaligned access happens.
Pointers are references. Integers are values. The GC only follows references.
Real-world usage: bridging Go and C
Most production code uses unsafe.Pointer to talk to C libraries through cgo. C functions expect void* parameters, which map directly to unsafe.Pointer in Go. The pattern requires careful lifetime management. You must ensure the Go object stays alive until the C function returns.
Here is a realistic bridge function that passes a byte slice to a hypothetical C library. The example includes the lifetime guard that prevents premature collection.
package main
/*
#include <stdlib.h>
void process_data(void* ptr, int len);
*/
import "C"
import (
"unsafe"
)
// ProcessSlice passes a Go byte slice to a C function without copying.
func ProcessSlice(data []byte) {
if len(data) == 0 {
return
}
// Pin the slice in memory so the GC cannot move or collect it
// during the C call. The pointer remains valid until this function returns.
ptr := unsafe.Pointer(&data[0])
// Call the C function with the raw pointer and length
C.process_data((*C.char)(ptr), C.int(len(data)))
// The C function has finished. The Go slice can be garbage collected
// on the next GC cycle if nothing else references it.
}
The unsafe.Pointer(&data[0]) line extracts the underlying array address. The cast to (*C.char)(ptr) satisfies the cgo type checker. The slice data remains in scope for the entire duration of the function, which guarantees the GC will not collect it while process_data is running. If you needed to store the pointer for later use, you would have to keep the original slice alive in a longer-lived variable or use runtime pinning APIs.
Convention note: Go functions that interact with external systems usually take context.Context as their first parameter, conventionally named ctx. If your bridge function blocks waiting for C code to finish, pass a context through so callers can cancel long-running foreign calls. The unsafe package does not care about cancellation, but your architecture should.
Where things go wrong
The most common failure mode is the uintptr lifetime trap. Developers convert a pointer to uintptr, store it in a struct field, and then let the original object drop out of scope. The GC collects the object. The next access reads freed memory. The compiler will not warn you. It only checks types, not lifetimes. If you try to convert an unsafe.Pointer directly to an int or uint64, the compiler rejects the program with cannot convert expression of type unsafe.Pointer to type int. You must route through uintptr.
Another frequent issue is type confusion. You cast a []byte to []int using unsafe.Pointer and reflect.SliceHeader (or the modern unsafe.Slice). The underlying memory layout does not change. Reading four bytes as a single integer works on little-endian machines. It produces garbage or panics on big-endian systems. The compiler does not verify endianness. You must document the assumption or handle it explicitly.
Misaligned accesses cause silent corruption on some CPUs and hard faults on others. If you cast a pointer to a struct that requires 8-byte alignment, but the original memory is only 4-byte aligned, the runtime will panic with invalid memory address or nil pointer dereference or a platform-specific alignment fault. The error message rarely mentions alignment. You have to check the pointer value and the type's alignment requirements manually.
The worst unsafe bug is the one that only appears under memory pressure. Keep your pointers live. Keep your types consistent. Keep your alignment correct.
When to reach for unsafe
Use unsafe.Pointer when you must bridge Go memory to C libraries or hardware registers that expect raw addresses. Use unsafe.Slice when you need to reinterpret a contiguous block of memory as a slice of a different type without copying. Use standard typed pointers for 99% of your code. Use sync/atomic or channels when you need shared state across goroutines. Stick to the type system whenever possible.
unsafe is a tool, not a feature. Reach for it only when the type system blocks a legitimate requirement.