The backdoor in Go's type system
You are building a high-performance parser for a binary protocol. You have a buffer of bytes arriving from the network. You need to interpret those bytes as a struct with fields like Timestamp and PayloadLength. Copying the bytes into the struct is slow. You want to treat the buffer as the struct directly. Go's compiler stops you. It says you cannot convert a []byte to a *Header. The types are different. The memory layout might differ. The compiler protects you from misaligned reads and garbage data.
You need a way to tell the compiler, "I know the memory layout. I know the alignment. Let me read this memory as that type." That way is the unsafe package. It is the only package in Go that can bypass the type system and garbage collection rules. It is a tool for escaping Go, not for writing Go.
What unsafe actually does
Go is a safe language. The compiler checks every pointer access. The garbage collector tracks every object. The type system ensures you never treat an integer as a string by accident. The unsafe package removes these checks. It gives you raw access to memory addresses and type reinterpretation.
The package exposes three core types and functions. unsafe.Pointer is a universal pointer type. It can hold the address of any value. You can cast it to any *T. You cannot dereference it directly. You cannot do arithmetic on it. uintptr is an integer type large enough to hold a memory address. You can do arithmetic on it. You cannot cast it to a pointer directly. You must go through unsafe.Pointer. The functions unsafe.Sizeof, unsafe.Alignof, and unsafe.Offsetof return compile-time constants describing type properties. They do not allocate memory. They do not run at runtime.
Think of unsafe.Pointer as a key that opens any door. Think of uintptr as the number written on the door. You can write down the number, add one to it, and store it in a file. But the number itself does not open the door. And if the building moves, the number becomes useless. The garbage collector treats uintptr as just a number. It does not keep the object alive. If you convert a pointer to uintptr and the only reference to the object is that number, the garbage collector will reclaim the memory. You end up with a dangling pointer.
Reinterpreting bits without copying
Here's the simplest use of unsafe: reinterpreting the bits of one type as another without copying. This is common when you need to inspect the binary representation of a value or pass data to a C library that expects a specific layout.
package main
import (
"fmt"
"unsafe"
)
func main() {
// Create a float64 value on the stack.
var f float64 = 3.14159
// Get a pointer to f.
pf := &f
// unsafe.Pointer is the bridge between types.
// It can hold any pointer value but cannot be dereferenced.
up := unsafe.Pointer(pf)
// Cast the universal pointer to a pointer to int64.
// This reinterprets the bits of the float as an integer.
// No data is copied. The memory is read as a different type.
pi := (*int64)(up)
// Read the bit pattern as an integer.
// The value is the IEEE 754 representation of 3.14159.
fmt.Printf("Float: %f, Bits as int: %d\n", f, *pi)
}
The code creates a float, gets its address, casts the address to an integer pointer, and reads the bits. The output shows the float value and the raw integer representation. The cast (*int64)(up) is the critical step. It tells the compiler to treat the memory at up as an int64. The compiler trusts you. It does not check if the alignment is correct. It does not check if the size matches. If you cast a pointer to a type with stricter alignment requirements, the program may crash on architectures like ARM or RISC-V. Go's gofmt will still format this code. The tool does not care about safety, only style.
The pointer arithmetic trap
You often need to move a pointer forward or backward. For example, you have a pointer to the first element of an array and you want a pointer to the third element. You cannot add an integer to unsafe.Pointer. The compiler rejects this with invalid operation: p + 2 (mismatched types unsafe.Pointer and untyped int). You must convert to uintptr, do the math, and convert back.
package main
import (
"fmt"
"unsafe"
)
func main() {
// An array of integers.
var arr [5]int = [5]int{10, 20, 30, 40, 50}
// Pointer to the first element.
p0 := &arr[0]
// Convert to uintptr to perform arithmetic.
// uintptr is just an integer. It does not keep arr alive.
addr := uintptr(unsafe.Pointer(p0))
// Move the address forward by two int-sized steps.
// unsafe.Sizeof returns the size of int in bytes.
// This calculation is safe because arr is on the stack
// and we are accessing within bounds.
nextAddr := addr + 2*uintptr(unsafe.Sizeof(arr[0]))
// Cast back to a pointer to dereference.
// This assumes nextAddr is aligned for int.
p2 := (*int)(unsafe.Pointer(nextAddr))
fmt.Println(*p2) // Prints 30
}
The conversion chain is Pointer to uintptr, math, uintptr to Pointer, then Pointer to *T. The danger lies in the uintptr stage. The garbage collector scans memory for pointers. It recognizes unsafe.Pointer and knows the object must stay alive. It sees uintptr and sees a number. If you store addr in a variable and the garbage collector runs before you cast back, it might collect arr. The address nextAddr then points to freed memory. Dereferencing it causes a panic or silent corruption. The rule is simple: never hold a uintptr across a call site that might trigger garbage collection. Convert back to unsafe.Pointer immediately after the math.
Zero-copy conversions in modern Go
A common use case is converting between strings and byte slices without copying. Strings are immutable in Go. Byte slices are mutable. If you create a string from a slice without copying, modifying the slice corrupts the string. Go 1.20 added helpers to make this pattern safer and more readable. unsafe.String and unsafe.Slice abstract the memory layout details. They are preferred over manual header manipulation.
package main
import (
"fmt"
"unsafe"
)
// BytesToString creates a string from a byte slice without copying.
// The string shares the underlying array with the slice.
// Modifying the slice after this call changes the string content.
func BytesToString(b []byte) string {
// unsafe.String takes a pointer and length.
// It constructs a string header pointing to the slice's data.
// This is the vetted way to do zero-copy in modern Go.
return unsafe.String(unsafe.SliceData(b), len(b))
}
func main() {
// A mutable byte slice.
data := []byte("hello world")
// Create a string sharing the same memory.
s := BytesToString(data)
fmt.Println(s)
// Modifying the slice affects the string.
data[0] = 'H'
fmt.Println(s) // Prints "Hello world"
}
The function unsafe.SliceData returns a pointer to the first element of the slice. unsafe.String uses that pointer and the length to create a string. The result is a zero-copy conversion. The string and slice share memory. This is fast. It is also risky. If the slice is modified elsewhere, the string changes. The convention is to document this behavior clearly. Functions that return zero-copy views must warn callers about aliasing. The if err != nil pattern does not apply here. unsafe functions do not return errors. They panic or corrupt. Validation must happen before the call.
Pitfalls and silent failures
The unsafe package has no runtime checks. Mistakes do not produce compiler errors. They produce panics, memory leaks, or data corruption that appears hours later. Alignment is a frequent issue. Casting a pointer to a type with stricter alignment requirements can crash on some CPUs. Use unsafe.Alignof(T) to check requirements. If you cast a byte pointer to an int64 pointer, the address must be a multiple of 8. If it is not, the hardware raises a bus error. Go's compiler usually handles alignment, but unsafe bypasses this.
Cache line false sharing is another trap. If two goroutines write to different fields of a struct that share a cache line, performance degrades. The unsafe package allows you to pack structs tightly. This can increase false sharing. Use unsafe.Offsetof to inspect field positions. If fields are close together, consider padding or splitting the struct.
The compiler assumes safety. It optimizes code based on the assumption that pointers do not alias unless proven otherwise. If you use unsafe to create aliasing, the compiler's optimizations may break your code. For example, the compiler might cache a value in a register because it believes no other pointer can modify it. If unsafe allows another pointer to modify the value, the register cache becomes stale. The program reads the wrong value. This is hard to debug. The compiler error possible misuse of unsafe.Pointer appears in some cases, but not all. Treat unsafe code as if the compiler is lying to you.
When to use unsafe
Use unsafe when you need zero-copy conversion between types and the performance gain justifies the risk. Use unsafe when interfacing with C code via cgo and you need to manipulate pointers that cgo cannot handle directly. Use unsafe when implementing custom allocators or memory pools where the garbage collector cannot manage the lifecycle. Use unsafe when you need to access the memory layout of types for serialization or debugging tools. Use standard library helpers like unsafe.String or unsafe.Slice when you need zero-copy operations that are vetted by the core team. Avoid unsafe when you can achieve the goal with reflection, encoding/binary, or simple type conversions. Avoid unsafe when the code will be maintained by a team unfamiliar with memory layout and garbage collection constraints. Avoid unsafe when the performance benefit is negligible: the simplest thing that works is usually the right thing.
The garbage collector trusts you. If you lie, memory leaks or crashes follow.