Go Performance Guide
Compiler & Runtime

Unsafe Pointer Tricks for Performance

Master safe and unsafe pointer patterns to achieve zero-copy conversions and direct memory access while maintaining correctness.

Introduction to unsafe.Pointer

unsafe.Pointer is Go's mechanism for performing unrestricted pointer arithmetic and type conversions. It bypasses Go's type system to enable direct memory access, which is sometimes necessary for maximum performance.

import "unsafe"

var x int = 42
ptr := unsafe.Pointer(&x)  // Any pointer becomes unsafe.Pointer
xAgain := (*int)(ptr)      // Convert back to typed pointer

While powerful, unsafe code sacrifices safety guarantees. The Go runtime cannot verify that unsafe operations are correct, and mistakes can cause undefined behavior, data corruption, or security vulnerabilities.

Warning: Unsafe pointer usage violates Go's memory safety model. Only use it when the performance benefit is substantial and the code can be thoroughly tested.

When to Consider Unsafe

Unsafe pointer tricks are worth considering in these limited scenarios:

1. Zero-Copy Conversions Between Strings and Byte Slices

Converting between string and []byte normally requires copying the underlying data. For large strings, this copy can be expensive.

// Safe: 1ms for 1GB string
func stringToSliceSafe(s string) []byte {
    b := make([]byte, len(s))
    copy(b, s)  // Copies entire string
    return b
}

// Unsafe: nanoseconds
func stringToSliceUnsafe(s string) []byte {
    return unsafe.Slice(unsafe.StringData(s), len(s))
}

2. Struct Casting for Serialization

Casting a struct directly to bytes bypasses encoding overhead.

type Header struct {
    Magic   uint32
    Version uint8
    Length  uint32
}

// Safe: Uses reflection/encoding
func encodeHeaderSafe(h Header) []byte {
    buf := make([]byte, binary.Size(h))
    binary.Write(buf, binary.LittleEndian, h)
    return buf
}

// Unsafe: Direct memory copy (nanoseconds)
func encodeHeaderUnsafe(h Header) []byte {
    return unsafe.Slice((*byte)(unsafe.Pointer(&h)), unsafe.Sizeof(h))
}

3. Accessing Unexported Struct Fields

For testing or debugging, unsafe can access private fields (not recommended for production).

4. Manual Memory Layout Control

When you need precise control over memory layout and alignment, unsafe provides the tools.

5. Direct Memory-Mapped I/O Access

For kernel modules or embedded systems, unsafe is essential for memory-mapped registers.

The Six Valid Unsafe.Pointer Conversion Patterns

The Go specification defines six valid patterns for converting to and from unsafe.Pointer. Violating these patterns is undefined behavior.

Pattern 1: Pointer Type Conversion

Converting between different pointer types:

var x int = 42
var p *int = &x

// Valid: pointer to pointer
var up unsafe.Pointer = unsafe.Pointer(p)
var q *int = (*int)(up)

// Pattern: T1 to unsafe.Pointer to T2
var f *float64 = (*float64)(unsafe.Pointer(p))  // Reinterpret bits

Pattern 2: unsafe.Pointer to uintptr (and Back)

Converting to integer for arithmetic:

var p *int = &x
addr := uintptr(unsafe.Pointer(p))

// Modify address
addr += 8  // Move 8 bytes forward

// Convert back (valid if pointing to valid allocation)
newp := (*int)(unsafe.Pointer(addr))

Critical: Intermediate uintptr values cannot be stored or passed around. They must be converted back to a pointer immediately.

// ✗ Invalid: storing uintptr breaks GC
var savedAddr uintptr = uintptr(unsafe.Pointer(&x))
// ... later ...
ptr := (*int)(unsafe.Pointer(savedAddr))  // Undefined behavior!

// ✓ Valid: immediate conversion
ptr := (*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&x))))

Pattern 3: Arithmetic Offset with unsafe.Add

Using unsafe.Add for pointer arithmetic (Go 1.17+):

// Before Go 1.17, required uintptr arithmetic
ptr := unsafe.Pointer(&arr[0])
nextElem := (*int)(unsafe.Add(ptr, unsafe.Sizeof(int(0))))

// Equivalent to:
nextElem := (*int)(unsafe.Pointer(uintptr(ptr) + unsafe.Sizeof(int(0))))

unsafe.Add is safer because it keeps the uintptr conversion isolated.

Pattern 4: Conversion to unsafe.Slice

Creating a slice from a pointer (Go 1.17+):

// Create slice from raw pointer
arr := (*[100]int)(unsafe.Pointer(&buffer[0]))
slice := unsafe.Slice(arr[0:], 100)

// Or directly
slice := unsafe.Slice((*int)(unsafe.Pointer(&buffer)), 100)

This is safe only if the underlying memory is valid and allocated for the given length.

Pattern 5: Conversion to unsafe.String

Creating a string from a pointer (Go 1.20+):

// Read null-terminated C string
cStr := (*C.char)(unsafe.Pointer(ptr))
goStr := C.GoString(cStr)  // Go's C interop helper

// Or unsafe (requires length)
bytes := unsafe.Slice((*byte)(unsafe.Pointer(cStr)), len)
str := unsafe.String((*byte)(unsafe.Pointer(cStr)), len)

Pattern 6: unsafe.StringData and unsafe.SliceData

Getting pointers to string/slice data (Go 1.20+):

s := "hello"
ptr := unsafe.StringData(s)  // *byte pointing to 'h'

slice := []int{1, 2, 3}
ptr := unsafe.SliceData(slice)  // *int pointing to first element

Pattern 1 in Depth: Zero-Copy String/Byte Conversions

This is the most common safe use of unsafe pointers.

The Safer APIs (Go 1.20+)

Go 1.20 introduced unsafe.String, unsafe.StringData, and unsafe.SliceData specifically for this purpose:

// ✓ Recommended (Go 1.20+)
func unsafeStringToSlice(s string) []byte {
    return unsafe.Slice(unsafe.StringData(s), len(s))
}

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

How It Works

A string in Go is internally:

type stringHeader struct {
    Data *byte  // Pointer to characters
    Len  int    // Length
}

A slice is:

type sliceHeader struct {
    Data *byte  // Pointer to elements
    Len  int    // Length
    Cap  int    // Capacity
}

For strings backed by immutable data, reading via []byte is safe if you don't modify the bytes:

s := "hello"
b := unsafe.Slice(unsafe.StringData(s), len(s))
// Reading is safe
ch := b[0]  // 'h'

// Writing is UNSAFE - corrupts the string!
b[0] = 'H'  // ✗ undefined behavior

Benchmark: String/Slice Conversion

package main

import (
    "testing"
    "unsafe"
)

func BenchmarkStringToSliceSafe(b *testing.B) {
    s := "hello world hello world hello world"
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        // Creates copy
        buf := make([]byte, len(s))
        copy(buf, s)
        _ = buf
    }
}

func BenchmarkStringToSliceUnsafe(b *testing.B) {
    s := "hello world hello world hello world"
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        // Zero-copy
        buf := unsafe.Slice(unsafe.StringData(s), len(s))
        _ = buf
    }
}

func BenchmarkSliceToStringSafe(b *testing.B) {
    slice := []byte("hello world hello world")
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        // Creates copy
        s := string(slice)
        _ = s
    }
}

func BenchmarkSliceToStringUnsafe(b *testing.B) {
    slice := []byte("hello world hello world")
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        // Zero-copy (but dangerous!)
        s := unsafe.String(unsafe.SliceData(slice), len(slice))
        _ = s
    }
}

Expected Results

BenchmarkStringToSliceSafe-8      100000000   12.5 ns/op   32 B/op   1 allocs/op
BenchmarkStringToSliceUnsafe-8    1000000000   0.35 ns/op   0 B/op   0 allocs/op
BenchmarkSliceToStringSafe-8      100000000   11.3 ns/op   32 B/op   1 allocs/op
BenchmarkSliceToStringUnsafe-8    1000000000   0.52 ns/op   0 B/op   0 allocs/op

The unsafe version is 30-40x faster because it avoids memory allocation and copying.

Pattern 2 in Depth: Struct Casting for Serialization

Converting a struct to bytes without encoding overhead:

// Network packet header
type PacketHeader struct {
    SourceIP   uint32
    DestIP     uint32
    Protocol   uint8
    _padding   [3]byte  // Explicit padding for alignment
    Length     uint16
}

func encodePacketUnsafe(p PacketHeader) []byte {
    return unsafe.Slice((*byte)(unsafe.Pointer(&p)), unsafe.Sizeof(p))
}

func decodePacketUnsafe(b []byte) PacketHeader {
    return *(*PacketHeader)(unsafe.Pointer(unsafe.SliceData(b)))
}

Safety Considerations

This only works safely if:

  1. Struct layout is predictable: No interior pointers, no interface{} fields
  2. Padding is controlled: Use explicit padding fields or //go:packed
  3. Endianness is consistent: Both sides use the same CPU byte order
  4. No dynamic data: All fields are fixed-size (no slices, strings, maps)

Benchmark: Struct Serialization

type Point struct {
    X, Y, Z float64
}

func BenchmarkEncodeSafe(b *testing.B) {
    p := Point{1.0, 2.0, 3.0}
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        buf := make([]byte, 24)
        binary.LittleEndian.PutUint64(buf[0:8], math.Float64bits(p.X))
        binary.LittleEndian.PutUint64(buf[8:16], math.Float64bits(p.Y))
        binary.LittleEndian.PutUint64(buf[16:24], math.Float64bits(p.Z))
        _ = buf
    }
}

func BenchmarkEncodeUnsafe(b *testing.B) {
    p := Point{1.0, 2.0, 3.0}
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        buf := unsafe.Slice((*byte)(unsafe.Pointer(&p)), unsafe.Sizeof(p))
        _ = buf
    }
}

Results show unsafe is 5-10x faster for struct serialization.

Pattern 3: Pointer Arithmetic with unsafe.Add

Moving pointers within allocated memory:

func readUint32LE(data []byte, offset int) uint32 {
    // Safe: validates bounds
    return binary.LittleEndian.Uint32(data[offset : offset+4])

    // Unsafe: no bounds check
    ptr := unsafe.Pointer(unsafe.SliceData(data))
    ptr = unsafe.Add(ptr, offset)
    return *(*uint32)(ptr)
}

Valid Use Case: Array Iteration

func sumArrayUnsafe(arr []int64) int64 {
    sum := int64(0)
    ptr := unsafe.Pointer(unsafe.SliceData(arr))
    end := unsafe.Add(ptr, len(arr)*int(unsafe.Sizeof(int64(0))))

    for ptr != end {
        sum += *(*int64)(ptr)
        ptr = unsafe.Add(ptr, unsafe.Sizeof(int64(0)))
    }
    return sum
}

This is rarely faster than safe iteration because the compiler already optimizes safe loops well.

The Dangers of Unsafe Pointers

Understanding what can go wrong is essential:

1. Garbage Collection Invalidation

The garbage collector can move objects in memory. Storing uintptr breaks this:

// ✗ WRONG
var savedAddr uintptr
func init() {
    x := 42
    savedAddr = uintptr(unsafe.Pointer(&x))  // Stores address
}

func later() {
    p := (*int)(unsafe.Pointer(savedAddr))  // Might point to garbage!
    // x may have moved, or the memory might be reused
}

2. Pointer Escaping Go's Type System

Type safety is lost:

s := "hello"
b := unsafe.Slice(unsafe.StringData(s), len(s))
b[0] = 'H'  // ✗ Corrupts the string!
// String is now broken, other code using s will see corrupted data

3. Memory Aliasing Violations

Writing through one pointer invalidates assumptions about another:

var x int = 5
var y int = 10

// ✗ Creates unsafe aliasing
px := unsafe.Pointer(&x)
py := (*int)(px)  // Treats x as if it's y

*py = 20  // Corrupts x!

4. Alignment Violations

Misaligned access can cause crashes on some architectures:

// ✗ Bad: may cause crash on ARM
b := []byte{1, 2, 3, 4, 5, 6, 7, 8}
ptr := unsafe.Pointer(unsafe.SliceData(b))
ptr = unsafe.Add(ptr, 1)  // Misaligned!
val := *(*uint32)(ptr)    // May crash

Real-World Unsafe in the Go Standard Library

The stdlib uses unsafe carefully in performance-critical code:

strings.Builder

// strings/builder.go simplified
type Builder struct {
    addr *Builder
    buf  []byte
}

func (b *Builder) String() string {
    return unsafe.String(unsafe.SliceData(b.buf), len(b.buf))
}

Avoids copying the buffer when converting to string.

sync.Pool

// sync/pool.go
type poolLocal struct {
    poolLocalInternal
    pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}

Uses unsafe to control memory layout and prevent false sharing (padding).

reflect Package

// reflect/value.go
func (v Value) Bytes() []byte {
    return *(*[]byte)(unsafe.Pointer(&v.ptr))
}

Reinterprets internal representation as byte slice.

Runtime Safety Tools

Go provides tools to catch unsafe mistakes:

The -race Detector

go run -race main.go

Detects data races involving unsafe pointers:

WARNING: DATA RACE
Write at 0x00c0001a0000 by goroutine 5:
    main.modifyUnsafe()
        main.go:20 +0x4c

Previous write at 0x00c0001a0000 by goroutine 4:
    main.modifyUnsafe()
        main.go:20 +0x4c

Pointer Checking with -checkptr

go build -msan -checkptr ./...

Checks for invalid pointer operations:

RUNTIME ERROR: unsafe pointer conversion
    cgo argument has Go pointer to Go pointer

Memory Sanitizer (MSAN)

go test -msan ./...

Detects memory errors (only works on Linux x86_64):

SUMMARY: MemorySanitizer: use-of-uninitialized-value /path/to/main.go:42

Safer Alternatives to Unsafe

Before using unsafe, consider these safer options:

1. Use encoding/binary

For serialization:

// ✓ Safe, clear
var h PacketHeader
binary.Read(buf, binary.LittleEndian, &h)

// vs Unsafe
h := *(*PacketHeader)(unsafe.Pointer(unsafe.SliceData(buf)))

2. Use reflect Package

Inspect types safely:

// ✓ Safe
t := reflect.TypeOf(x)
for i := 0; i < t.NumField(); i++ {
    field := t.Field(i)
    // Analyze field
}

// vs Unsafe
ptr := unsafe.Pointer(&x)
// Manual offset calculation

3. Use Generics (Go 1.18+)

Type-safe alternatives to unsafe casting:

// ✓ Safe with generics
func min[T interface{ int | float64 }](a, b T) T {
    if a < b { return a }
    return b
}

// vs Unsafe interface{} casting
func minUnsafe(a, b interface{}) interface{} {
    // Type assertion needed
}

4. Copy When Needed

Sometimes explicit copying is better:

// ✓ Clear intent, safe
buf := make([]byte, len(s))
copy(buf, s)

// vs Unsafe zero-copy (risky if buf is modified)
buf := unsafe.Slice(unsafe.StringData(s), len(s))

Best Practices for Unsafe Code

When unsafe is genuinely needed:

  1. Document invariants clearly: Explain why unsafe is necessary and what assumptions it makes
// readUint32LE reads a little-endian uint32 from b without bounds checks.
// Requires b to have at least 4 bytes of valid data at offset.
// UNSAFE: Bounds checking is the caller's responsibility.
func readUint32LE(b []byte, offset int) uint32 {
    ptr := unsafe.Add(unsafe.Pointer(unsafe.SliceData(b)), offset)
    return *(*uint32)(ptr)
}
  1. Isolate unsafe into small packages: Contain risk
unsafe/
├── strings/    // String <-> []byte conversions only
├── binary/     // Struct serialization only
└── ...
  1. Test thoroughly with -race and -checkptr:
go test -race -checkptr ./unsafe/...
  1. Add integration tests: Ensure unsafe code works correctly
func TestStringSliceConversion(t *testing.T) {
    original := "hello world"
    slice := unsafeStringToSlice(original)

    if string(slice) != original {
        t.Fatalf("round-trip failed")
    }

    // Verify we didn't copy
    if cap(slice) != 0 {
        t.Fatal("unsafe conversion created a copy")
    }
}
  1. Profile impact: Verify the unsafe code actually improves performance
func BenchmarkLogicWithoutUnsafe(b *testing.B) { ... }
func BenchmarkLogicWithUnsafe(b *testing.B) { ... }

If the improvement is under 5%, stick with safe code.

Summary

Unsafe pointers are powerful tools for performance optimization, but they require careful use. The Go specification defines six valid conversion patterns, and violating them causes undefined behavior.

The safest use case is zero-copy string/byte conversions with Go 1.20+ APIs like unsafe.String and unsafe.StringData. These provide the speed benefit (30-40x faster) with minimal risk when used correctly.

Key principles:

  1. Only use unsafe for performance-critical code where measurements show significant improvement
  2. Isolate unsafe code into well-tested, documented packages
  3. Use -race and -checkptr to catch errors
  4. Prefer safe alternatives - encoding/binary, reflect, generics
  5. Document assumptions - explain why unsafe is necessary
  6. Test thoroughly - unsafe bugs are subtle and dangerous

The Go runtime's memory safety model exists for good reason. Unsafe breaks those guarantees, so use it sparingly and carefully.

On this page