The unsafe Package
The standard-library package
unsafeis the single, sanctioned door out of Go’s type system. Its own documentation opens by saying it “contains operations that step around the type safety of Go programs” and warns that “packages that import unsafe may be non-portable and are not protected by the Go 1 compatibility guidelines” (pkg.go.dev/unsafe). Despite the name,unsafeis not a library of dangerous functions you can call freely — it is a compiler-recognized package whose identifiers (Pointer,Sizeof,Alignof,Offsetof,Add,Slice,String,SliceData,StringData) are handled specially by thegccompiler rather than implemented as ordinary Go code. Using it correctly means obeying a precise, written contract; using it incorrectly produces code that works today and corrupts memory after the next garbage-collection cycle or compiler upgrade.
Why unsafe Exists
Go is a memory-safe, garbage-collected language. The type system normally guarantees that a *int always points at an int, that a slice index is in bounds, and that you cannot reinterpret the bytes of one type as another. That safety has a cost: there are legitimate, performance-critical, or interoperability-critical operations the safe language simply cannot express.
Three motivating use cases recur:
- Zero-copy reinterpretation. Converting a
float64to its IEEE-754 bit pattern, or viewing a[]byteas a[]uint32, without copying. The standard library does exactly this —math.Float64bitsis implemented withunsafe(shown below). - Foreign function interface (FFI). Talking to C through cgo or to the operating system through
syscallrequires handing raw addresses across a boundary the type system does not model.unsafe.Pointeris the currency of that exchange. - Low-level data-structure work. Reflection (
reflect), high-performance serialization, memory-mapped I/O, and atomic structure manipulation all need to compute field offsets, build slices over arbitrary memory, or treat a struct as bytes.
Without unsafe, every one of these would force a copy, a cgo call, or an interface{} round-trip. unsafe is the escape hatch that keeps Go usable as a systems language while confining the danger to packages that explicitly opt in by writing import "unsafe".
Mental Model
The cleanest way to think about unsafe is as two distinct facilities that happen to share a package, plus a set of rules that bind them.
flowchart TD U[package unsafe] U --> A[Compile-time facilities] U --> B[Runtime facilities] A --> A1[Sizeof / Alignof / Offsetof<br/>constant-folded layout queries] B --> B1[unsafe.Pointer<br/>the universal pointer] B --> B2[Add — pointer arithmetic] B --> B3[Slice / String / SliceData / StringData<br/>build & deconstruct headers] B1 -. governed by .-> R[The 6 conversion patterns<br/>see note: unsafe.Pointer Conversion Rules] B2 -. governed by .-> R style A fill:#e8f5e9 style B fill:#fff3e0 style R fill:#ffebee
Diagram: the unsafe package splits into compile-time layout queries (green) — which are pure functions of the type, fully resolved by the compiler, and harmless — and runtime pointer facilities (orange) — which manipulate live addresses and are governed by a strict conversion contract (red). The insight: Sizeof/Alignof/Offsetof are not actually “unsafe” at all; the genuine danger lives entirely in unsafe.Pointer and the operations that produce or consume it.
The central type is unsafe.Pointer, declared as type Pointer *ArbitraryType. It is a pointer that the type checker treats as compatible with every pointer type, in both directions. The documentation enumerates “four special operations available for type Pointer that are not available for other types”: any typed pointer converts to Pointer; Pointer converts to any typed pointer; a uintptr converts to Pointer; and Pointer converts to uintptr (pkg.go.dev/unsafe). Those four conversions are what “defeat the type system and read and write arbitrary memory.”
The critical mental distinction is between unsafe.Pointer and uintptr. An unsafe.Pointer is a pointer — the garbage collector sees it, scans it, and keeps the pointed-at object alive. A uintptr is an integer — it merely holds a numeric address. The garbage collector does not treat a uintptr as a reference: it will not keep the object alive, and on a moving collector it would not update the value. Go’s collector is currently non-moving (see Go Garbage Collector), so a stale uintptr keeps pointing at the right bytes — but those bytes may be freed and reused. Confusing the two is the single most common way to introduce silent corruption with unsafe.
Mechanical Walk-through — What the Compiler Actually Does
unsafe has no .go implementation file with executable bodies for most of its surface; it is a compiler intrinsic. When you write unsafe.Sizeof(x), the parser recognizes the unsafe import and the cmd/compile front end replaces the call with a constant during type checking — there is no function call in the emitted code. The Go specification confirms this: it documents unsafe directly in the language spec (“Package unsafe”), and states that Sizeof, Offsetof, and Alignof return uintptr constants when the operand type does not have variable size (go.dev/ref/spec).
unsafe.Pointer conversions are likewise compiler magic: the type checker simply permits the conversion that it would otherwise reject. No runtime code runs for the conversion itself; the bits are reinterpreted in place. unsafe.Add, unsafe.Slice, unsafe.String, unsafe.SliceData, and unsafe.StringData do produce real runtime behavior (pointer arithmetic, header construction, nil/length panics), but the compiler generates that code inline rather than calling into a library.
This “the compiler knows about it” property is why unsafe cannot be reimplemented in user code and why it is exempt from the Go 1 compatibility promise: its behavior is part of the toolchain, and the toolchain reserves the right to tighten it.
The version timeline
The package has grown deliberately, each addition aimed at making correct unsafe code easier to write so that programmers stop hand-rolling fragile uintptr arithmetic:
- Go 1.17 added
unsafe.Addandunsafe.Slice. The release notes stateunsafe.Add(ptr, len)“addslentoptrand returns the updated pointer,” andunsafe.Slice(ptr, len)“returns a slice of type[]Twhose underlying array starts atptr.” Crucially, the same notes say “the rules remain unchanged … new programs must still follow the rules when usingunsafe.Addorunsafe.Slice” (go.dev/doc/go1.17). - Go 1.20 added
unsafe.String,unsafe.StringData, andunsafe.SliceData. The release notes describe them as completing the set: “Along with Go 1.17’sSlice, these functions now provide the complete ability to construct and deconstruct slice and string values, without depending on their exact representation” (go.dev/doc/go1.20).
Together, Slice/SliceData/String/StringData are meant to replace the old idiom of casting through reflect.SliceHeader and reflect.StringHeader. Those header types are notoriously easy to misuse (you must never declare a plain variable of them — see unsafe.Pointer Conversion Rules for the “case 6” subtlety), and the modern functions remove the need to touch them. See unsafe.Slice and unsafe.String for the full treatment of that family.
Code — The Sanctioned Patterns
The standard library is the best teacher of disciplined unsafe. The most cited example is math.Float64bits, quoted verbatim in the unsafe documentation:
func Float64bits(f float64) uint64 {
return *(*uint64)(unsafe.Pointer(&f))
}Line by line:
&ftakes the address of thefloat64argument, yielding a*float64.unsafe.Pointer(&f)converts that typed pointer to the universal pointer. This is legal — “a pointer value of any type can be converted to a Pointer.”(*uint64)(...)converts the universal pointer to*uint64. Also legal — “a Pointer can be converted to a pointer value of any type.” This is conversion pattern 1 from the rules:*T1→Pointer→*T2, valid becauseuint64andfloat64are both 8 bytes with an equivalent (trivial, scalar) memory layout.- The leading
*dereferences the*uint64, reading the 8 bytes of the float as an unsigned integer with no copy and no conversion of value — only of interpretation.
A second everyday pattern is querying layout. Suppose a struct:
type Header struct {
Magic uint32 // offset 0
_ [4]byte // explicit padding
Payload uintptr // offset 8
}
const headerSize = unsafe.Sizeof(Header{}) // 16 on a 64-bit platform
const payloadOff = unsafe.Offsetof(Header{}.Payload) // 8
const ptrAlign = unsafe.Alignof(Header{}.Payload) // 8 on amd64Every right-hand side here is a compile-time constant — the values are baked into the binary, cost nothing at runtime, and can be used in array sizes or const blocks. This is the genuinely safe half of the package; the detailed semantics (what Sizeof includes, why Alignof of a struct field can differ from the type’s natural alignment) live in unsafe.Sizeof Alignof and Offsetof.
A third pattern — building a slice over foreign memory — is the modern, blessed alternative to reflect.SliceHeader surgery:
// Given a *byte and a length from C or mmap, view it as a Go slice
// without copying. Lifetime of the backing memory is the caller's job.
func bytesFromC(ptr *byte, n int) []byte {
if ptr == nil || n == 0 {
return nil
}
return unsafe.Slice(ptr, n)
}unsafe.Slice(ptr, n) is documented as equivalent to (*[n]ArbitraryType)(unsafe.Pointer(ptr))[:], with the special case that a nil ptr and zero n yields nil. It panics at runtime if n is negative or if ptr is nil while n is non-zero (pkg.go.dev/unsafe).
The Compatibility Exemption — Why It Matters
The unsafe documentation’s warning that imported-unsafe packages “are not protected by the Go 1 compatibility guidelines” is not boilerplate; it is a concrete operational risk. The Go 1 compatibility promise (go.dev/doc/go1compat) says that correct programs written against Go 1 will keep compiling and running across Go 1.x releases. unsafe is carved out of that promise. The practical consequences are several.
First, the internal representation of built-in types is not frozen. Code that assumes a slice header is three words in a particular order, or that a string header is laid out a certain way, or that an interface value is two words, is relying on facts the language spec deliberately does not promise. The 1.17/1.20 addition of unsafe.Slice/String/SliceData/StringData exists precisely so that code can construct and deconstruct slices and strings without depending on their representation — which is why the modern functions, not raw header casts, are the future-proof path.
Second, the toolchain reserves the right to tighten what unsafe permits. The arrival of -d=checkptr instrumentation, and its automatic enablement under -race, means code that “worked” for years can start panicking on a new Go release — not because the code changed, but because the runtime got better at noticing it was always wrong. This is a feature: the failure is surfacing latent corruption. But it means an unsafe-using package must be re-tested on every Go upgrade, not assumed-good.
Third, garbage-collector evolution can invalidate unsafe code. Go’s heap collector is currently non-moving, so a stale uintptr keeps addressing the right bytes (even if the object was freed and reused). Goroutine stacks, however, already move when they grow. If the heap collector ever became moving, every uintptr-holds-an-address assumption that today merely risks use-after-free would break outright. Conforming to the six conversion patterns of unsafe.Pointer Conversion Rules is what insulates code from that hypothetical — which is the deeper reason the rules are written the way they are.
The takeaway: treat an import "unsafe" as a standing liability that must be re-justified and re-tested on every toolchain bump, and prefer the representation-independent functions (Slice, String, Sizeof, …) over raw pointer surgery wherever they suffice.
Failure Modes and Common Misunderstandings
“unsafe is just a normal package with risky functions.” No — it is compiler-recognized, exempt from the Go 1 compatibility guarantee, and partly evaluated at compile time. Code that imports it can break on a toolchain upgrade even if it never changes. Treat an import "unsafe" line as a contract you re-verify on every Go release.
Storing a uintptr and converting it back later. The package is explicit: “Conversion of a uintptr back to Pointer is not valid in general … the garbage collector will not update that uintptr’s value if the object moves, nor will that uintptr keep the object from being reclaimed.” A uintptr does not pin its target. If you stash an address in a uintptr variable, the GC may reclaim the object, and a later cast back to Pointer then dereferences freed memory. The full enumeration of when uintptr↔Pointer is legal is the subject of unsafe.Pointer Conversion Rules.
Reinterpreting a larger type as a smaller one — or vice versa. Pattern 1 requires “T2 is no larger than T1.” Casting a *[2]byte to *uint32 reads four bytes from a two-byte object: an out-of-bounds read that may straddle into another allocation or page-fault.
Assuming Sizeof is portable. unsafe.Sizeof(int(0)) is 8 on a 64-bit platform and 4 on a 32-bit one; struct sizes shift with alignment. A note that hard-codes a size for one architecture silently miscomputes on another (see Word Size and Architecture Portability).
Believing go vet clears your code. The documentation states plainly: “Running ‘go vet’ can help find uses of Pointer that do not conform to the documented patterns, but silence from ‘go vet’ is not a guarantee that the code is valid.” Vet’s unsafeptr analyzer catches some shapes of misuse; the runtime -d=checkptr instrumentation (covered in unsafe.Pointer Conversion Rules) catches more, at runtime — but nothing is exhaustive.
Alternatives and When to Choose Them
unsafe should be a last resort. Before reaching for it, weigh the safe options:
- A plain copy.
copy(),[]byte(s),string(b)— if the data is small or the operation is rare, copying is free of everyunsafehazard and the compiler often optimizes the obvious cases anyway. Reach forunsafeonly when profiling shows the copy matters. encoding/binary. For reading structured bytes (network protocols, file formats),binary.Read/binary.Writeand thebinary.ByteOrdermethods give portable, endianness-correct parsing without touching pointers.reflect. Runtime type inspection that does not require raw addresses should usereflect. Note thatreflectitself usesunsafeinternally — but it confines the danger behind a checked API.runtime/cgo.Handle. To pass a Go value’s identity through C without exposing a Go pointer at all,cgo.Handleis strictly safer than smuggling anunsafe.Pointer(see cgo Internals).- Generics. Some pre-1.18 uses of
unsafeto write type-agnostic containers are now better served by type parameters, which keep full type safety.
Choose unsafe only when (a) a safe alternative is measurably too slow on a real hot path, or (b) you are crossing an FFI boundary that has no safe expression. In both cases, isolate the unsafe code in a tiny, well-commented, well-tested function and convert back to safe types as fast as possible.
Production Notes
Mature Go codebases treat unsafe as a quarantined dependency. The standard library uses it heavily but surgically — reflect, sync, runtime, strings, and syscall all import it, each confining it to a handful of audited functions. Application code should mirror this: one file, a build tag if portability is in question, and a comment at every conversion naming the conversion pattern it relies on.
Two operational practices are worth adopting. First, run the test suite with -gcflags=all=-d=checkptr (and routinely under -race, which enables checkptr automatically) so that violating-but-currently-working code is caught before it ships. The coupling is unconditional and not platform-specific: the compiler’s flag handling sets checkptr whenever the race, memory-sanitizer, or address-sanitizer instrumentation is on — if Flag.Race || Flag.MSan || Flag.ASan { ... Debug.Checkptr = 1 } — gated on the instrumentation flag, not on the target OS (cmd/compile/internal/base/flag.go). What is platform-limited is the race detector itself: it supports only linux/amd64, linux/ppc64le, linux/arm64, linux/s390x, linux/loong64, freebsd/amd64, netbsd/amd64, darwin/amd64, darwin/arm64, and windows/amd64, and on Windows it additionally needs a C compiler with mingw-w64 runtime version 8 or later (go.dev/doc/articles/race_detector). So -race does enable checkptr on every platform where -race works at all — including windows/amd64; the historical Windows friction was about the race runtime’s C-toolchain requirements, never about whether checkptr followed along. (As of Go 1.14 the -d=checkptr=0 opt-out briefly malfunctioned under -race; that regression was fixed — see the golang-dev thread (groups.google.com).) Second, treat go vet’s unsafeptr warnings as build-breaking, while remembering vet is not exhaustive. Engineering write-ups by Matt Layher (mdlayher.com, Gopher Academy) — built from real unsafe-heavy networking packages — make the same point: the danger is not the act of importing unsafe, it is forgetting which conversion pattern applies and letting a uintptr outlive the expression that produced it.
See Also
- unsafe.Pointer Conversion Rules — the six valid conversion patterns, in full, and the
unsafeptr/checkptrtooling - unsafe.Sizeof Alignof and Offsetof — the compile-time layout queries
- unsafe.Slice and unsafe.String — building and deconstructing slice/string headers
- cgo Internals — the other major consumer of
unsafe.Pointer - Pointer Types in Go · Value Semantics and Pointer Semantics · Struct Memory Layout and Alignment
- Word Size and Architecture Portability — why
unsafesizes are not portable - Go Garbage Collector — why a
uintptrdoes not keep an object alive - Go Internals MOC — parent map, §14 (
unsafe, cgo, and the FFI boundary)