Go Unsafe Package: Low-Level Operations

The `unsafe` package is Go's escape hatch from type safety. It provides operations that bypass Go's memory safety guarantees, allowing you to manipulate memory directly like you would in C. This...

Key Insights

  • The unsafe package breaks Go’s type safety and memory guarantees, trading compiler protections for performance and low-level control—use it only when profiling proves it necessary
  • Understanding pointer mechanics, memory layout, and the six unsafe.Pointer rules is critical to avoid garbage collector issues, memory corruption, and undefined behavior
  • Legitimate use cases exist in high-performance serialization, CGo interop, and reflection optimization, but each requires rigorous testing and documentation of safety assumptions

Introduction to the Unsafe Package

The unsafe package is Go’s escape hatch from type safety. It provides operations that bypass Go’s memory safety guarantees, allowing you to manipulate memory directly like you would in C. This power comes with significant risk—the package name itself is a warning.

Go’s philosophy emphasizes safety and simplicity. The compiler prevents you from casting between incompatible types, ensures pointer arithmetic doesn’t corrupt memory, and coordinates with the garbage collector to prevent use-after-free bugs. The unsafe package disables these protections.

You should reach for unsafe only after profiling proves a bottleneck exists and safer optimizations have failed. Common scenarios include: zero-copy conversions between strings and byte slices, interfacing with C libraries via CGo, and implementing high-performance serialization where every allocation matters.

Here’s a simple comparison showing the difference:

// Safe: Type-checked conversion
func safeConvert(x int32) int64 {
    return int64(x)
}

// Unsafe: Reinterpret bytes directly
func unsafeConvert(x int32) int64 {
    return *(*int64)(unsafe.Pointer(&x))
}

The unsafe version reinterprets the memory location of x as an int64 pointer and dereferences it. This reads uninitialized memory beyond the 4 bytes of the int32, producing garbage. This example is intentionally broken to illustrate the danger.

Understanding Pointer Types and Conversions

Go has three pointer-related types in the unsafe world:

  1. *T: Regular typed pointer, managed by the garbage collector
  2. unsafe.Pointer: Generic pointer that can convert to/from any *T
  3. uintptr: Integer type that holds a pointer’s numeric address

The critical rule: unsafe.Pointer is a pointer, uintptr is just a number. The garbage collector tracks unsafe.Pointer but ignores uintptr. If you convert to uintptr, the GC may move or collect the object, making your integer address invalid.

type Data struct {
    value int64
}

func demonstrateConversions() {
    d := &Data{value: 42}
    
    // Convert typed pointer to unsafe.Pointer
    ptr := unsafe.Pointer(d)
    
    // Convert to different type (dangerous!)
    intPtr := (*int64)(ptr)
    fmt.Println(*intPtr) // 42
    
    // Convert to uintptr for arithmetic
    addr := uintptr(ptr)
    
    // WRONG: Storing uintptr allows GC to invalidate it
    time.Sleep(time.Second)
    badPtr := unsafe.Pointer(addr) // May point to garbage
    
    // RIGHT: Immediate conversion back
    offset := unsafe.Sizeof(int64(0))
    goodPtr := unsafe.Pointer(uintptr(ptr) + offset)
    _ = goodPtr
}

The six rules for unsafe.Pointer from the Go documentation govern valid conversions. The most important: conversions to uintptr must convert back to unsafe.Pointer in the same expression, without storing the uintptr in a variable.

Memory Layout and Offsetof

Go structs lay out fields sequentially in memory, with padding for alignment. The unsafe package exposes three functions to inspect this layout:

type Message struct {
    Type    uint8
    _       [3]byte // padding
    Length  uint32
    Payload [64]byte
}

func inspectLayout() {
    var m Message
    
    fmt.Printf("Type offset: %d\n", unsafe.Offsetof(m.Type))       // 0
    fmt.Printf("Length offset: %d\n", unsafe.Offsetof(m.Length))   // 4
    fmt.Printf("Payload offset: %d\n", unsafe.Offsetof(m.Payload)) // 8
    
    fmt.Printf("Type size: %d\n", unsafe.Sizeof(m.Type))       // 1
    fmt.Printf("Length size: %d\n", unsafe.Sizeof(m.Length))   // 4
    fmt.Printf("Total size: %d\n", unsafe.Sizeof(m))           // 72
    
    fmt.Printf("Type align: %d\n", unsafe.Alignof(m.Type))     // 1
    fmt.Printf("Length align: %d\n", unsafe.Alignof(m.Length)) // 4
}

You can use offsets to access fields via pointer arithmetic, bypassing field names:

func accessViaOffset() {
    m := Message{Type: 1, Length: 100}
    
    // Get pointer to struct
    ptr := unsafe.Pointer(&m)
    
    // Access Length field via offset
    lengthOffset := unsafe.Offsetof(m.Length)
    lengthPtr := (*uint32)(unsafe.Pointer(uintptr(ptr) + lengthOffset))
    
    fmt.Println(*lengthPtr) // 100
    *lengthPtr = 200
    fmt.Println(m.Length)   // 200
}

This technique appears in serialization libraries that need to iterate over struct fields without reflection overhead.

String and Slice Internals

Strings and slices in Go are descriptors containing pointers to underlying data. A string is a {ptr, len} pair, while a slice is {ptr, len, cap}.

Here’s the zero-copy string-to-bytes conversion that appears in performance-critical code:

func stringToBytes(s string) []byte {
    return unsafe.Slice(unsafe.StringData(s), len(s))
}

func bytesToString(b []byte) string {
    return unsafe.String(unsafe.SliceData(b), len(b))
}

Go 1.20+ provides unsafe.String, unsafe.StringData, unsafe.Slice, and unsafe.SliceData for these operations. Before these helpers, you needed to manually construct slice headers:

// Pre-Go 1.20 approach
func oldStringToBytes(s string) []byte {
    type stringHeader struct {
        data unsafe.Pointer
        len  int
    }
    type sliceHeader struct {
        data unsafe.Pointer
        len  int
        cap  int
    }
    
    strHdr := (*stringHeader)(unsafe.Pointer(&s))
    sliceHdr := sliceHeader{
        data: strHdr.data,
        len:  strHdr.len,
        cap:  strHdr.len,
    }
    return *(*[]byte)(unsafe.Pointer(&sliceHdr))
}

Warning: Converting a string to []byte this way creates a mutable view of immutable data. Modifying the slice can cause subtle bugs because string data may be deduplicated or stored in read-only memory:

func dangerousModification() {
    s := "hello"
    b := stringToBytes(s)
    b[0] = 'H' // May crash or corrupt memory!
}

Real-World Use Cases

High-Performance Serialization

Binary serialization can use unsafe to avoid allocations:

type Point struct {
    X, Y, Z float64
}

func serializePoint(p *Point) []byte {
    size := unsafe.Sizeof(*p)
    return unsafe.Slice((*byte)(unsafe.Pointer(p)), size)
}

func deserializePoint(data []byte) *Point {
    return (*Point)(unsafe.Pointer(&data[0]))
}

This works for simple structs but breaks with pointers, slices, or maps inside the struct.

CGo Interoperability

When calling C code, you often need to pass Go pointers as C pointers:

/*
#include <stdlib.h>

void process_buffer(void* buf, int size) {
    // C code processes buffer
}
*/
import "C"

func callCFunction(data []byte) {
    ptr := unsafe.Pointer(&data[0])
    C.process_buffer(ptr, C.int(len(data)))
}

Memory Pool Implementation

Custom allocators use unsafe to manage memory blocks:

type Pool struct {
    blocks []unsafe.Pointer
    size   uintptr
}

func (p *Pool) Alloc() unsafe.Pointer {
    if len(p.blocks) == 0 {
        // Allocate new block
        block := make([]byte, p.size)
        return unsafe.Pointer(&block[0])
    }
    ptr := p.blocks[len(p.blocks)-1]
    p.blocks = p.blocks[:len(p.blocks)-1]
    return ptr
}

Safety Guidelines and Best Practices

The six rules for unsafe.Pointer are:

  1. Convert *T to unsafe.Pointer to *U when T and U have identical memory layout
  2. Convert unsafe.Pointer to uintptr and back in a single expression
  3. Convert unsafe.Pointer to uintptr for pointer arithmetic, then immediately back
  4. Call syscall.Syscall with uintptr converted from unsafe.Pointer
  5. Convert reflect.Value.Pointer or reflect.Value.UnsafeAddr result to unsafe.Pointer
  6. Convert reflect.SliceHeader or reflect.StringHeader data field to/from unsafe.Pointer

Common mistakes to avoid:

// WRONG: Storing uintptr
func broken() {
    x := 42
    addr := uintptr(unsafe.Pointer(&x))
    // GC may run here, moving x
    ptr := unsafe.Pointer(addr) // Invalid!
}

// RIGHT: Single expression
func correct() {
    x := 42
    ptr := unsafe.Pointer(uintptr(unsafe.Pointer(&x)) + 0)
}

// WRONG: Pointer to stack variable escapes
func returnsStackPointer() unsafe.Pointer {
    x := 42
    return unsafe.Pointer(&x) // x's memory is invalid after return
}

Testing strategies for unsafe code:

  1. Run tests with -race to catch data races
  2. Use GODEBUG=cgocheck=2 to detect invalid CGo pointers
  3. Test on different architectures (32-bit vs 64-bit)
  4. Add fuzz testing for serialization code
  5. Document all safety assumptions in comments
// SAFETY: This assumes Point contains only float64 fields with no padding.
// Breaks if struct layout changes or contains pointers.
func serializePoint(p *Point) []byte {
    size := unsafe.Sizeof(*p)
    return unsafe.Slice((*byte)(unsafe.Pointer(p)), size)
}

Conclusion

The unsafe package is a necessary evil in Go’s ecosystem. It enables performance optimizations and low-level operations that would otherwise be impossible, but every use introduces potential bugs that the compiler can’t catch.

Before using unsafe, exhaust safer alternatives: optimize algorithms, reduce allocations with sync.Pool, use build tags for architecture-specific code, or accept the performance trade-off. When unsafe is truly necessary, isolate it in small, well-tested functions with clear documentation of safety invariants.

Remember: “unsafe means unsafe.” The Go 1 compatibility guarantee doesn’t apply to code using unsafe. Compiler changes, garbage collector improvements, or architecture differences can break working unsafe code. Use it sparingly, test thoroughly, and always question whether the performance gain justifies the risk.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.