Go is a safe language. The compiler catches type mismatches. The runtime prevents buffer overflows. The garbage collector manages memory.
Then there’s unsafe.
The unsafe package lets you bypass all of Go’s safety guarantees. It gives you raw memory access, pointer arithmetic, and the ability to reinterpret any bytes as any type.
This article explains what unsafe actually does, how it works under the hood, and when it’s worth the risk.
What Is unsafe?
The unsafe package provides three things:
unsafe.Pointer- A pointer that can point to any type- Type functions -
Sizeof,Alignof,Offsetof - Memory functions -
Add,Slice,String,SliceData,StringData
That’s it. No magic. Just raw memory access.
import "unsafe"
// Convert any pointer to unsafe.Pointer
var x int = 42
ptr := unsafe.Pointer(&x)
// Convert unsafe.Pointer to any pointer type
y := *(*float64)(ptr) // Reinterpret int bits as float64
The package exists because sometimes you need to:
- Talk to C code via cgo
- Implement low-level data structures
- Squeeze out every last nanosecond of performance
The Types
unsafe.Pointer
unsafe.Pointer is the escape hatch from Go’s type system. Four conversions are allowed:
// 1. Any pointer → unsafe.Pointer
var x int = 42
p := unsafe.Pointer(&x)
// 2. unsafe.Pointer → any pointer
f := (*float64)(p)
// 3. unsafe.Pointer → uintptr (for arithmetic)
addr := uintptr(p)
// 4. uintptr → unsafe.Pointer (dangerous!)
p2 := unsafe.Pointer(addr)
The first two are useful. The last two are dangerous.
uintptr: The Trap
uintptr is just an integer. It holds a memory address, but the garbage collector doesn’t know that.
// DANGEROUS: Don't do this
ptr := unsafe.Pointer(&x)
addr := uintptr(ptr) // Now it's just a number
// GC can move or free x here!
ptr2 := unsafe.Pointer(addr) // May point to garbage
The garbage collector can move objects at any time. If you store an address in a uintptr, the GC won’t update it. Your pointer becomes invalid.
Rule: Never store uintptr in a variable. Convert back to unsafe.Pointer in the same expression.
// CORRECT: Same expression
nextField := unsafe.Pointer(uintptr(ptr) + offset)
// WRONG: Stored in variable
addr := uintptr(ptr) // GC can invalidate this
// ... any code here ...
nextField := unsafe.Pointer(addr) // Undefined behavior
The Functions
Sizeof, Alignof, Offsetof
These are compile-time constants. No runtime cost.
type User struct {
ID int64
Name string
Age int32
}
unsafe.Sizeof(User{}) // 32 (on 64-bit)
unsafe.Alignof(User{}) // 8
unsafe.Offsetof(User{}.Age) // 24
Offsetof is particularly useful for accessing struct fields by offset:
func getAge(u *User) int32 {
ptr := unsafe.Pointer(u)
agePtr := unsafe.Add(ptr, unsafe.Offsetof(u.Age))
return *(*int32)(agePtr)
}
unsafe.Add
Pointer arithmetic without the uintptr dance:
next := unsafe.Add(ptr, 8)
Cleaner and safer than the old unsafe.Pointer(uintptr(ptr) + 8) pattern. The compiler can verify it.
unsafe.Slice
Create a slice from a pointer and length:
slice := unsafe.Slice((*byte)(ptr), length)
unsafe.String, unsafe.StringData
Create strings from bytes and vice versa:
// Bytes → String (zero-copy)
b := []byte("hello")
s := unsafe.String(&b[0], len(b))
// String → underlying bytes pointer
ptr := unsafe.StringData(s)
Warning: Since strings are immutable, you must not modify the bytes after creating a string from them.
unsafe.SliceData (Go 1.20+)
Get the underlying array pointer from a slice:
s := []int{1, 2, 3}
ptr := unsafe.SliceData(s) // *int pointing to first element
The Six Valid Patterns
The Go documentation specifies exactly six valid patterns for unsafe.Pointer. Everything else is undefined behavior.
Pattern 1: Type Conversion
Convert between pointer types with compatible memory layouts:
// int64 and float64 have same size
var i int64 = 0x4059000000000000
f := *(*float64)(unsafe.Pointer(&i))
fmt.Println(f) // 100.0
Requirement: T2 must be no larger than T1.
Pattern 2: unsafe.Pointer → uintptr → unsafe.Pointer (Same Expression)
// Advance pointer by offset
p := unsafe.Pointer(uintptr(ptr) + offset)
Both conversions must be in the same expression. No storing in variables.
Pattern 3: Calling reflect.Value.Pointer() or UnsafeAddr()
// Must convert immediately in same expression
p := (*int)(unsafe.Pointer(reflect.ValueOf(&x).Pointer()))
Pattern 4: Syscall Arguments
The compiler has special handling for syscalls:
// Compiler keeps the object alive during syscall
syscall.Syscall(SYS_READ, fd, uintptr(unsafe.Pointer(&buf[0])), len(buf))
Warning: You cannot store the uintptr in a variable first.
Pattern 5: unsafe.Add, unsafe.Slice
next := unsafe.Add(ptr, 8)
slice := unsafe.Slice((*byte)(ptr), length)
Zero-Copy String Conversion
The most common use of unsafe: converting between string and []byte without allocation.
The Problem
Standard conversion copies data:
s := "hello"
b := []byte(s) // Allocates new []byte, copies 5 bytes
b2 := []byte("hello")
s2 := string(b2) // Allocates new string, copies 5 bytes
For large strings in hot paths, this hurts performance.
The Solution
// String → []byte (zero-copy, READ-ONLY!)
func StringToBytes(s string) []byte {
return unsafe.Slice(unsafe.StringData(s), len(s))
}
// []byte → String (zero-copy)
func BytesToString(b []byte) string {
return unsafe.String(unsafe.SliceData(b), len(b))
}
Compiler Optimization
The compiler can optimize standard conversions in some cases:
// The compiler may optimize this to zero-copy
// if b doesn't escape and isn't modified
s := string(b)
The compiler detects when the result doesn’t escape to the heap and isn’t modified, then skips the copy. Check if your code benefits before reaching for unsafe.
Critical Warning
Never modify a byte slice obtained from a string:
s := "hello"
b := StringToBytes(s)
b[0] = 'H' // UNDEFINED BEHAVIOR!
Strings are immutable. The bytes might live in read-only memory. Modifying them can crash your program or corrupt memory silently.
Real-World Usage: How Projects Use unsafe
fasthttp
fasthttp processes 200K requests/second using zero-allocation patterns:
// From fasthttp: zero-copy string conversion
func b2s(b []byte) string {
return *(*string)(unsafe.Pointer(&b))
}
func s2b(s string) []byte {
return unsafe.Slice(unsafe.StringData(s), len(s))
}
They use unsafe throughout for:
- Converting request/response bodies without copying
- Reusing buffers across requests
- Avoiding allocations in hot paths
Standard Library
Even Go’s standard library uses unsafe:
- reflect: Implements type introspection
- sync: Atomic operations on complex types
- runtime: Obviously needs raw memory access
- strings.Builder: Uses
unsafeinternally for efficiency
// From strings.Builder
func (b *Builder) String() string {
return unsafe.String(unsafe.SliceData(b.buf), len(b.buf))
}
Protocol Buffers
Libraries like molecule use unsafe for zero-allocation parsing:
// Returns unsafe view over underlying bytes
func (v Value) AsBytesUnsafe() []byte {
return unsafe.Slice((*byte)(v.ptr), v.len)
}
Performance: Is It Worth It?
Benchmark: string ↔ []byte conversion on a 1KB string.
BenchmarkStandardConversion-8 5000000 234 ns/op 1024 B/op 1 allocs/op
BenchmarkUnsafeConversion-8 500000000 2.3 ns/op 0 B/op 0 allocs/op
100x faster. Zero allocations.
But here’s the thing: 234ns is nothing if you’re doing I/O. Network calls take milliseconds. Database queries take milliseconds. That 234ns is noise.
Use unsafe for:
- HTTP servers handling 100K+ req/s
- Protocol parsers processing GB/s
- Hot loops in data processing pipelines
Don’t use unsafe for:
- Normal application code
- Code that does I/O
- Anything where safety matters more than speed
The Dangers
1. Memory Corruption
s := "hello"
b := unsafe.Slice(unsafe.StringData(s), len(s))
b[0] = 'H' // May corrupt memory or crash
2. Race Conditions
// Shared byte slice used as string
var shared []byte
go func() {
s := BytesToString(shared) // Zero-copy
fmt.Println(s)
}()
go func() {
shared[0] = 'X' // Race! Other goroutine sees corruption
}()
3. GC Hazards
func bad() *int {
x := 42
addr := uintptr(unsafe.Pointer(&x))
// x goes out of scope, GC can reclaim it
return (*int)(unsafe.Pointer(addr)) // Dangling pointer
}
4. Platform Dependence
// Assumes 64-bit pointers
type Header struct {
ptr uintptr // 8 bytes on 64-bit, 4 bytes on 32-bit
}
Your code may break on different architectures.
Tools for Safety
go vet
Catches common unsafe misuses:
go vet ./...
Race Detector
Finds data races involving unsafe:
go test -race ./...
go run -race main.go
checkptr
Runtime checking for unsafe.Pointer rules:
go build -gcflags=all=-d=checkptr ./...
Enabled by default with -race or -msan.
unsafe gives you raw memory access and zero-copy conversions at the cost of type safety, memory safety, and portability. Profile first, isolate the unsafe code behind a clean API, and always test with -race. For 99% of Go code, you don’t need it.