Skip to content

Unsafe Package Advanced

Introduction

The unsafe package provides operations that step outside Go's type system. It exists because some tasks — syscalls, hardware interaction, interoperability with C, and extreme performance optimization — are impossible within Go's safety guarantees.

The name is deliberate: unsafe code bypasses the compiler's type checking, can break with any Go release, and can cause memory corruption, crashes, or security vulnerabilities. The Go compatibility guarantee explicitly excludes packages that import unsafe. That said, unsafe is used extensively in Go's own standard library (runtime, reflect, sync, syscall), and understanding it is essential for advanced Go development.

Syntax & Usage

unsafe.Pointer

unsafe.Pointer is a special pointer type that can be converted to and from any pointer type. It is the bridge between Go's type system and raw memory access.

import "unsafe"

// Any *T can convert to unsafe.Pointer
x := 42
p := unsafe.Pointer(&x) // *int → unsafe.Pointer

// unsafe.Pointer can convert to any *T
y := (*float64)(p) // unsafe.Pointer → *float64 (reinterprets bits!)
fmt.Println(*y)    // Garbage — int and float64 have different layouts

The Go spec defines exactly six legal patterns for using unsafe.Pointer. Any other use is undefined behavior:

// Pattern 1: *T1 → unsafe.Pointer → *T2
// Convert between pointer types (type punning)
func Float64bits(f float64) uint64 {
    return *(*uint64)(unsafe.Pointer(&f))
}

func Float64frombits(b uint64) float64 {
    return *(*float64)(unsafe.Pointer(&b))
}

// Pattern 2: unsafe.Pointer → uintptr (for arithmetic, NOT for storage)
// Pattern 3: unsafe.Pointer → uintptr → arithmetic → unsafe.Pointer
// MUST be in a SINGLE expression — never store intermediate uintptr
p := unsafe.Pointer(&someStruct)
fieldPtr := unsafe.Pointer(uintptr(p) + unsafe.Offsetof(someStruct.Field))

// Pattern 4: syscall.Syscall with uintptr arguments
// The compiler knows to keep the pointer alive during the syscall
syscall.Syscall(SYS_READ, fd, uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))

// Pattern 5: reflect.Value.Pointer or reflect.Value.UnsafeAddr → unsafe.Pointer
// Must convert result to unsafe.Pointer IMMEDIATELY
p = unsafe.Pointer(reflect.ValueOf(&x).Pointer())

// Pattern 6: reflect.SliceHeader/StringHeader Data fields
// Deprecated in Go 1.20+ — use unsafe.Slice and unsafe.String instead

unsafe.Sizeof, unsafe.Alignof, unsafe.Offsetof

type Example struct {
    A bool    // 1 byte
    B int64   // 8 bytes
    C bool    // 1 byte
    D int32   // 4 bytes
}

fmt.Println(unsafe.Sizeof(Example{}))    // 24 (with padding!)
fmt.Println(unsafe.Alignof(Example{}))   // 8

fmt.Println(unsafe.Sizeof(Example{}.A))  // 1
fmt.Println(unsafe.Sizeof(Example{}.B))  // 8

fmt.Println(unsafe.Offsetof(Example{}.A)) // 0
fmt.Println(unsafe.Offsetof(Example{}.B)) // 8 (7 bytes of padding after A)
fmt.Println(unsafe.Offsetof(Example{}.C)) // 16
fmt.Println(unsafe.Offsetof(Example{}.D)) // 20

// Memory layout of Example (24 bytes):
// [A][pad][pad][pad][pad][pad][pad][pad] bytes 0-7
// [B][B  ][B  ][B  ][B  ][B  ][B  ][B ] bytes 8-15
// [C][pad][pad][pad][D  ][D  ][D  ][D ] bytes 16-23

Struct Padding Optimization

// ❌ Wasteful layout — 24 bytes due to padding
type Bad struct {
    A bool    // 1 + 7 padding
    B int64   // 8
    C bool    // 1 + 3 padding
    D int32   // 4
}                // Total: 24 bytes

// ✅ Optimized layout — 16 bytes, no wasted padding
type Good struct {
    B int64   // 8
    D int32   // 4
    A bool    // 1
    C bool    // 1 + 2 padding
}                // Total: 16 bytes

// Use 'fieldalignment' tool to detect this:
// go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest
// fieldalignment -fix ./...

Pointer Arithmetic (Accessing Struct Fields by Offset)

type Header struct {
    Magic   uint32
    Version uint16
    Flags   uint16
    Length  uint32
}

func readHeader(data []byte) Header {
    if len(data) < int(unsafe.Sizeof(Header{})) {
        panic("data too short")
    }
    return *(*Header)(unsafe.Pointer(&data[0]))
}

// Accessing a specific field by offset
func getVersion(h *Header) uint16 {
    p := unsafe.Pointer(h)
    versionPtr := (*uint16)(unsafe.Pointer(
        uintptr(p) + unsafe.Offsetof(h.Version),
    ))
    return *versionPtr
}

Converting Between Slice Types (Zero-Copy)

// Convert []byte to []uint32 without copying
// ⚠️ Alignment and endianness must be correct!
func bytesToUint32s(b []byte) []uint32 {
    if len(b)%4 != 0 {
        panic("length must be multiple of 4")
    }
    return unsafe.Slice((*uint32)(unsafe.Pointer(&b[0])), len(b)/4)
}

// Convert string to []byte without copying (READ-ONLY — do not modify!)
func stringToBytes(s string) []byte {
    return unsafe.Slice(unsafe.StringData(s), len(s))
}

// Convert []byte to string without copying
func bytesToString(b []byte) string {
    return unsafe.String(&b[0], len(b))
}

unsafe.String and unsafe.Slice (Go 1.20+)

Go 1.20 introduced safer alternatives to the old reflect.SliceHeader/StringHeader hacks:

// unsafe.String(ptr *byte, len IntegerType) string
// Creates a string from a byte pointer and length
ptr := &someBytes[0]
s := unsafe.String(ptr, len(someBytes))

// unsafe.StringData(s string) *byte
// Returns a pointer to the underlying bytes of a string
dataPtr := unsafe.StringData("hello")

// unsafe.Slice(ptr *T, len IntegerType) []T
// Creates a slice from a pointer and length
arr := [5]int{1, 2, 3, 4, 5}
slc := unsafe.Slice(&arr[0], 5) // []int backed by arr

// unsafe.SliceData(s []T) *T
// Returns a pointer to the underlying array of a slice
p := unsafe.SliceData(slc)

The uintptr Danger: GC Can Move Memory

// ❌ DANGEROUS: storing uintptr across statements
p := uintptr(unsafe.Pointer(&x))
// ... GC may run here, moving x to a new address ...
// ... p is now a dangling pointer (GC doesn't track uintptr) ...
y := (*int)(unsafe.Pointer(p)) // UNDEFINED BEHAVIOR

// ✅ SAFE: single expression, no intermediate storage
y := (*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&x)) + offset))
// The compiler ensures &x stays alive for the entire expression

The critical insight: unsafe.Pointer is tracked by the garbage collector (it keeps the pointed-to object alive), but uintptr is just a number — the GC doesn't know it refers to an object. If the GC moves the object between the conversion to uintptr and the conversion back to unsafe.Pointer, you get a dangling pointer.

Real-World Usage in the Standard Library

sync.Pool internals (simplified)

// The runtime uses unsafe to access pool-local storage per-P (processor)
type poolLocal struct {
    poolLocalInternal
    pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}

// Cache-line padding prevents false sharing between processors

strings.Builder (zero-copy String())

// strings.Builder.String() returns the accumulated string without copying
// by converting the byte slice directly to a string via unsafe
func (b *Builder) String() string {
    return unsafe.String(unsafe.SliceData(b.buf), len(b.buf))
}

reflect package (type metadata access)

// reflect uses unsafe to read type metadata from interface headers
type emptyInterface struct {
    typ  *rtype
    word unsafe.Pointer
}

func TypeOf(i interface{}) Type {
    eface := *(*emptyInterface)(unsafe.Pointer(&i))
    return toType(eface.typ)
}

Syscall Interop

// Using unsafe for system calls (Linux example)
func mmap(addr uintptr, length uintptr, prot int, flags int, fd int, offset int64) (uintptr, error) {
    r, _, err := syscall.Syscall6(
        syscall.SYS_MMAP,
        addr,
        length,
        uintptr(prot),
        uintptr(flags),
        uintptr(fd),
        uintptr(offset),
    )
    if err != 0 {
        return 0, err
    }
    return r, nil
}

// Reading a C struct returned by a syscall
type Statx struct {
    Mask       uint32
    Blksize    uint32
    Attributes uint64
    Nlink      uint32
    UID        uint32
    GID        uint32
    Mode       uint16
    // ... more fields
}

func readStatx(buf []byte) *Statx {
    return (*Statx)(unsafe.Pointer(&buf[0]))
}

CGo Interop

/*
#include <stdlib.h>
#include <string.h>

typedef struct {
    int id;
    char name[64];
    double score;
} Record;
*/
import "C"
import "unsafe"

func newRecord(id int, name string, score float64) *C.Record {
    r := (*C.Record)(C.malloc(C.sizeof_Record))
    r.id = C.int(id)
    r.score = C.double(score)

    // Copy Go string to C char array
    cname := C.CString(name)
    defer C.free(unsafe.Pointer(cname))
    C.strncpy(&r.name[0], cname, 63)

    return r
}

func freeRecord(r *C.Record) {
    C.free(unsafe.Pointer(r))
}

Quick Reference

Function Purpose Return Type
unsafe.Pointer(p) Convert any *T to generic pointer unsafe.Pointer
(*T)(unsafe.Pointer(p)) Convert generic pointer to *T *T
uintptr(unsafe.Pointer(p)) Convert pointer to integer (for arithmetic) uintptr
unsafe.Sizeof(v) Size of value's type in bytes uintptr
unsafe.Alignof(v) Alignment requirement of value's type uintptr
unsafe.Offsetof(s.f) Byte offset of struct field f uintptr
unsafe.Slice(p, len) Create slice from pointer + length (Go 1.17+) []T
unsafe.String(p, len) Create string from *byte + length (Go 1.20+) string
unsafe.StringData(s) Get *byte pointer to string data (Go 1.20+) *byte
unsafe.SliceData(s) Get pointer to slice's backing array (Go 1.20+) *T
unsafe.Add(p, len) Pointer arithmetic (Go 1.17+) unsafe.Pointer

Best Practices

  1. Justify every use of unsafe — add a comment explaining why it's necessary and what invariants must hold.

  2. Isolate unsafe code — put it in a small, well-tested internal package. Export safe wrappers.

    // internal/rawbytes/convert.go
    package rawbytes
    
    // BytesToString converts bytes to string without allocation.
    // The caller must not modify b after this call.
    func BytesToString(b []byte) string {
        return unsafe.String(&b[0], len(b))
    }
    
  3. Use unsafe.Slice and unsafe.String (Go 1.17+/1.20+) instead of reflect.SliceHeader/StringHeader — they're safer and the old approach is deprecated.

  4. Never store uintptr values — convert unsafe.Pointer → uintptr → unsafe.Pointer in a single expression only.

  5. Run go vet — it detects some categories of unsafe misuse, including the uintptr-storage pattern.

  6. Test on multiple architectures — unsafe code that works on amd64 may break on arm64 due to alignment differences.

Common Pitfalls

Storing uintptr Across Statements

The most common unsafe bug. The GC does not track uintptr values — the pointed-to object can be moved or collected.

// ❌ GC can move the object between these two lines
addr := uintptr(unsafe.Pointer(&myStruct))
// ... GC may run here ...
ptr := unsafe.Pointer(addr) // Dangling pointer!

// ✅ Single expression — compiler keeps object alive
ptr := unsafe.Pointer(uintptr(unsafe.Pointer(&myStruct)) + offset)

// ✅ Or use unsafe.Add (Go 1.17+)
ptr := unsafe.Add(unsafe.Pointer(&myStruct), offset)

Violating Alignment Requirements

Accessing a uint64 at an unaligned address causes a crash on some architectures (ARM, MIPS) and is always undefined behavior.

// ❌ buf[3] is probably not 8-byte aligned
val := *(*uint64)(unsafe.Pointer(&buf[3])) // May crash on ARM!

// ✅ Use encoding/binary for unaligned reads
val := binary.LittleEndian.Uint64(buf[3:11])

Modifying String Backing Data

Go strings are immutable. Using unsafe to modify a string's underlying bytes violates this invariant and can cause unpredictable behavior (strings may be interned, shared, or used as map keys).

s := "hello"
b := unsafe.Slice(unsafe.StringData(s), len(s))
b[0] = 'H' // UNDEFINED BEHAVIOR — may crash, corrupt data, or appear to work

Go Version Compatibility

Code using unsafe is NOT covered by the Go 1 compatibility promise. Internal runtime layouts change between versions. Pin your Go version and re-test unsafe code on every upgrade.

Incorrect Sizeof for Slices and Strings

s := []int{1, 2, 3}
fmt.Println(unsafe.Sizeof(s)) // 24, not 24+12!
// Sizeof returns the size of the SLICE HEADER (pointer + len + cap),
// not the total memory including the backing array.

str := "hello"
fmt.Println(unsafe.Sizeof(str)) // 16 (pointer + len), not 21

Performance Considerations

  • Zero-copy string↔[]byte conversion: The most common performance use of unsafe. Saves one allocation (~50ns) per conversion. Worth it in hot paths processing millions of strings (JSON parsers, web frameworks).

  • Struct memory layout: Reordering fields to minimize padding can save 10–30% memory for structs allocated millions of times. Use fieldalignment tool.

  • Direct memory access: Avoiding encoding/binary by casting byte slices to struct pointers eliminates per-field decoding overhead. 5–10x faster for binary protocol parsing, but sacrifices portability (endianness).

  • Type punning: Float64bits/Float64frombits using unsafe are 100x faster than math.Float64bits was before the compiler learned to inline it (now they're equivalent — the compiler recognizes the pattern).

  • Profile first: Most unsafe "optimizations" save nanoseconds. In a web service doing 10ms database calls, saving 50ns on string conversion is irrelevant. Profile to find actual bottlenecks.

// Benchmark: string conversion
func BenchmarkStringCopy(b *testing.B) {
    bs := []byte("hello world this is a longer string for benchmarking")
    for i := 0; i < b.N; i++ {
        _ = string(bs) // ~50ns (allocates)
    }
}

func BenchmarkStringUnsafe(b *testing.B) {
    bs := []byte("hello world this is a longer string for benchmarking")
    for i := 0; i < b.N; i++ {
        _ = unsafe.String(&bs[0], len(bs)) // ~0.3ns (no allocation)
    }
}

Interview Tips

Interview Tip

When asked about unsafe, start with when it's justified: syscalls, C interop, performance-critical zero-copy operations, and accessing runtime internals. Then immediately discuss the risks: no GC tracking of uintptr, alignment violations, Go version incompatibility. This shows you understand both the power and the responsibility.

Interview Tip

A common question is "How would you convert []byte to string without allocation?" Show unsafe.String(&b[0], len(b)) for Go 1.20+, but immediately add the caveat: "The byte slice must not be modified after conversion, because strings are immutable and the compiler/runtime rely on that invariant."

Interview Tip

If asked about struct padding, explain that Go aligns fields to their natural alignment (an int64 must start at an 8-byte boundary). Show how reordering fields from largest to smallest minimizes padding. Mention unsafe.Sizeof, unsafe.Alignof, unsafe.Offsetof as the tools to inspect layout, and fieldalignment as the automated fixer.

Interview Tip

The uintptr gap (GC can move objects between unsafe.Pointer → uintptr and uintptr → unsafe.Pointer) is a deep knowledge question. Explain that unsafe.Pointer is GC-tracked but uintptr is not — it's just an integer. The fix is to do the entire conversion in a single expression so the compiler keeps the original pointer alive.

Key Takeaways

  • unsafe.Pointer is the bridge between Go's type system and raw memory — use it to convert between pointer types and perform pointer arithmetic.
  • Six legal patterns are defined by the spec for unsafe.Pointer use — anything else is undefined behavior.
  • Never store uintptr across statements — the GC doesn't track it, so the target object may be moved or collected.
  • Use unsafe.Slice and unsafe.String (Go 1.17+/1.20+) instead of the deprecated SliceHeader/StringHeader approach.
  • unsafe.Sizeof, unsafe.Alignof, and unsafe.Offsetof inspect memory layout — use them to optimize struct padding.
  • Isolate unsafe code, justify every use, and test on multiple architectures. Unsafe code is not covered by the Go 1 compatibility promise.
  • Most programs never need unsafe — exhaust interfaces, generics, and standard library options first.