unsafe and Low-Level Programming

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Go 1.27 (go1.27.2), checked on 2026-10-18; every go.mod shown has the line go 1.27. Go is a memory-safe language: the type system, bounds checks, the garbage collector and the absence of pointer arithmetic together mean that an ordinary Go program cannot read or write memory it does not own. The unsafe package is the documented escape hatch from those guarantees. It exists because a small number of programs genuinely need to see the memory under their values: the standard library itself (strings.Builder, sync/atomic, reflect, os, syscall), operating-system interfaces, memory-mapped files, binary formats and C interoperability. This page explains what the package offers, which uses are valid, how to find the invalid ones, and the other low-level tools that sit next to it: structs.HostLayout, //go:linkname, assembly and the syscall and golang.org/x/sys packages.

The package gives up two promises. Code that imports unsafe "may be non-portable and is not protected by the Go 1 compatibility guidelines" (the package documentation says so in its first paragraph), and a mistake does not produce a Go panic but memory corruption that can surface much later and far away. Treat every unsafe import as a liability that needs a reason, a test and a comment, and prefer the safe alternatives listed in When not to use unsafe.

It builds on Pointers and Value Semantics (what a pointer is and why Go has no arithmetic on it), Structs and Embedding (padding and field order), Arrays and Slices (the slice header) and Strings, Runes and UTF-8 (immutable strings). The related pages are Reflection (which implements most of its power with unsafe), Memory Management and Garbage Collection (why a uintptr is not a reference), Cleanups, Weak Pointers and Interning (runtime.KeepAlive), cgo and C Interoperability, WebAssembly and WASI, Sync and Atomics, Memory Model and Data Races, Directives, Code Generation and Embedding, Formatting, Vet, Fix and Static Analysis and The go Command, Builds and Cross-Compilation. The same topics in other languages: pointer arithmetic and alignment in C (C pointers, C memory model and alignment), and repr and FFI in Rust (Rust memory layout and repr, Rust FFI and C interop).

Every example was compiled, vetted with go vet and run with Go 1.27.2 on a 64-bit macOS machine (arm64), and the output shown is the real output (the compiler, vet, linker and runtime messages are copied from real runs). Sizes, offsets and addresses assume a 64-bit platform, and anything that depends on the machine says so. Examples that are meant to fail were run to show the failure. The go.mod of the example module is:

module example.com/shelf

go 1.27

What the package contains

The whole package fits in one table. Pointer and the three layout functions have been there since Go 1.0; the rest came as the language gained safer replacements for the old tricks.

Name Purpose Since

type Pointer *ArbitraryType

A pointer to an arbitrary type. The one type that can be converted to and from any pointer type, and to and from uintptr.

1.0

Sizeof(x)

Size in bytes of the value x as if it were a variable, including padding. A constant for types without variable size.

1.0

Alignof(x)

Required alignment of a variable of that type, in bytes. A constant for types without variable size.

1.0

Offsetof(x.f)

Offset of field f from the start of the struct. The argument must be a selector.

1.0

Add(ptr, len)

Pointer(uintptr(ptr) + uintptr(len)), in one call the compiler and vet understand.

1.17

Slice(ptr, len)

A slice of len elements starting at ptr, with the same length and capacity.

1.17

SliceData(slice)

Pointer to the first element of the slice’s backing array.

1.20

String(ptr, len)

A string whose bytes start at ptr, with no copy.

1.20

StringData(str)

Pointer to the bytes of a string, with no copy. The bytes must not be modified.

1.20

ArbitraryType and IntegerType in the signatures are documentation placeholders, not real types: they stand for "any type" and "any integer type". The language specification defines the package in Package unsafe, and the guarantees about sizes in Size and alignment guarantees.

Size, alignment and offsets

Every Go type has a size and an alignment. A value of alignment n must live at an address divisible by n, and the compiler inserts padding between fields so that each one is aligned. unsafe.Sizeof, unsafe.Alignof and unsafe.Offsetof expose the numbers the compiler chose. They are the safest members of the package: they only look, never touch memory, and they are evaluated at compile time, so they are constant expressions whenever the type does not have variable size (a type parameter, or an array or struct of one, has variable size).

package main

import (
	"fmt"
	"unsafe"
)

// Loan is a shelf loan record laid out in a careless order.
type Loan struct {
	Returned bool
	ID       int64
	Renewals uint8
	Member   int32
}

// Compact has the same fields ordered from largest to smallest alignment.
type Compact struct {
	ID       int64
	Member   int32
	Renewals uint8
	Returned bool
}

func main() {
	var l Loan
	fmt.Println("Loan size:", unsafe.Sizeof(l), "align:", unsafe.Alignof(l))
	fmt.Println("  Returned offset:", unsafe.Offsetof(l.Returned))
	fmt.Println("  ID offset:      ", unsafe.Offsetof(l.ID))
	fmt.Println("  Renewals offset:", unsafe.Offsetof(l.Renewals))
	fmt.Println("  Member offset:  ", unsafe.Offsetof(l.Member))

	var c Compact
	fmt.Println("Compact size:", unsafe.Sizeof(c), "align:", unsafe.Alignof(c))

	var (
		s  string
		sl []int
		m  map[string]int
		i  any
		f  func()
		ch chan int
	)
	fmt.Println("string:", unsafe.Sizeof(s), "slice:", unsafe.Sizeof(sl), "map:", unsafe.Sizeof(m),
		"interface:", unsafe.Sizeof(i), "func:", unsafe.Sizeof(f), "chan:", unsafe.Sizeof(ch))
	fmt.Println("[4]int16:", unsafe.Sizeof([4]int16{}), "struct{}:", unsafe.Sizeof(struct{}{}))

	const size = unsafe.Sizeof(int64(0)) // a constant expression
	var arr [size]byte
	fmt.Println("array length from Sizeof:", len(arr))
}
Loan size: 24 align: 8
  Returned offset: 0
  ID offset:       8
  Renewals offset: 16
  Member offset:   20
Compact size: 16 align: 8
string: 16 slice: 24 map: 8 interface: 16 func: 8 chan: 8
[4]int16: 8 struct{}: 0
array length from Sizeof: 8

What the numbers say:

  • Loan wastes eight bytes. Returned takes one byte, then seven bytes of padding bring ID to an 8-byte boundary, and Renewals followed by Member need three more bytes of padding. Sorting the same fields from the largest alignment to the smallest (Compact) gives 16 bytes. The struct padding story, the fieldalignment analyzer and the effect on cache lines are on Structs and Embedding.

  • Sizeof measures the header, not what it points to. A string is two words (pointer and length), a slice three (pointer, length and capacity), a map or channel one (a pointer to a runtime structure), an interface two (type and data) and a function value one (a pointer to a closure). Sizeof(s) of a ten-megabyte slice is still 24.

  • struct{} has size 0 and an array of int16 has the size of its elements times its length.

  • Because Sizeof(int64(0)) is a constant, it is legal as an array length. This is also how compile-time layout assertions work, shown in Reinterpreting binary data.

The relevant guarantees come from the specification. The size and alignment of basic types, the rule that the alignment of a struct is the largest alignment of its fields, and the fact that Sizeof of a struct includes padding are guaranteed. The order in which fields are placed in memory and the exact padding are what the compiler implements, and are the reason structs.HostLayout exists (see structs.HostLayout). Offsetof takes a selector expression: unsafe.Offsetof(l.ID) is fine, unsafe.Offsetof(Loan.ID) is not, and for an embedded field accessed through a pointer the compiler rejects an offset that would have to follow the pointer.

A 64-bit platform (amd64, arm64, wasm) has 8-byte words, a 32-bit platform (386, arm) 4-byte words. If your code depends on a particular size, assert it at compile time instead of assuming it (see below) and avoid int and uintptr in structures that are written to disk or sent over the network: use int32, uint64 and friends.

unsafe.Pointer and the six valid patterns

unsafe.Pointer is a pointer type with four extra conversion rules, listed in the package documentation:

  • A pointer value of any type can be converted to a Pointer.

  • A Pointer can be converted to a pointer value of any type.

  • A uintptr can be converted to a Pointer.

  • A Pointer can be converted to a uintptr.

Between them, these rules allow a program to defeat the type system: read a float64 as a uint64, treat a byte buffer as a struct, or compute the address of a field from the address of its struct. The package documentation does not stop at the rules. It enumerates six patterns, and states that "code not using these patterns is likely to be invalid today or to become invalid in the future". The patterns exist because the garbage collector and the compiler must be able to track pointers, and a uintptr is an integer, not a reference. Converting a pointer to a uintptr produces a number the collector ignores: it does not keep the object alive, and if the object moves (stacks are copied and grown, and a future collector may move heap objects) the number is not updated.

# Pattern Typical use

1

Conversion of a *T1 to Pointer to *T2, provided T2 is no larger than T1 and the two share an equivalent memory layout.

Reinterpreting data of one type as another, such as math.Float64bits.

2

Conversion of a Pointer to a uintptr, never converted back.

Printing an address, or logging and comparing it.

3

Conversion of a Pointer to a uintptr and back, with arithmetic, in a single expression.

Reaching a struct field or an array element: unsafe.Pointer(uintptr(p) + offset). Modern code uses unsafe.Add.

4

Conversion of a Pointer to a uintptr in the argument list of a call to a function implemented in assembly (such as syscall.Syscall).

Passing pointers to the operating system.

5

Conversion of the result of reflect.Value.Pointer or reflect.Value.UnsafeAddr from uintptr to Pointer, in the same expression as the call.

Getting at the address behind a reflected value.

6

Conversion of the Data field of the reflect slice and string header types to or from Pointer.

The old header-cast technique, replaced by unsafe.Slice, SliceData, String and StringData.

Pattern 6 is the one in older books and blog posts: build a slice by filling in the three words of a header structure through a pointer cast. The reflect header types have been marked deprecated since Go 1.21, and since Go 1.17 and 1.20 there are checked functions that do the job without the cast (see Slices and strings without copying). Do not write new code with pattern 6.

The next example exercises patterns 1 and 3. The valid forms are all in one place so that go vet can confirm they pass.

package main

import (
	"fmt"
	"math"
	"unsafe"
)

func main() {
	// Pattern 1: *T1 to *T2 through unsafe.Pointer.
	f := 1.5
	bits := *(*uint64)(unsafe.Pointer(&f))
	fmt.Printf("%#016x %#016x %v\n", bits, math.Float64bits(f), bits == math.Float64bits(f))

	// Writing through the converted pointer changes f.
	*(*uint64)(unsafe.Pointer(&f)) = math.Float64bits(-2.25)
	fmt.Println(f)

	// Pattern 3 and 4: pointer arithmetic with unsafe.Add.
	isbns := [3]int32{9780, 1342, 7718}
	p := unsafe.Pointer(&isbns[0])
	second := (*int32)(unsafe.Add(p, unsafe.Sizeof(isbns[0])))
	fmt.Println(*second)

	// The same address computed in one expression, uintptr only in the middle.
	third := (*int32)(unsafe.Pointer(uintptr(p) + 2*unsafe.Sizeof(isbns[0])))
	fmt.Println(*third)

	// Offsetof reaches a field of a struct.
	type pair struct {
		A int8
		B int64
	}
	v := pair{A: 1, B: 99}
	pb := (*int64)(unsafe.Add(unsafe.Pointer(&v), unsafe.Offsetof(v.B)))
	fmt.Println(*pb)
}
0x3ff8000000000000 0x3ff8000000000000 true
-2.25
1342
7718
99

The first two lines show the math.Float64bits trick done by hand: both approaches read the IEEE 754 bit pattern of 1.5, and writing through the converted pointer changes the float64. In real code use math.Float64bits and math.Float64frombits; the example is here to show the conversion rule. The isbns lines show the same address computed two ways. unsafe.Add is the preferred spelling, and the go fix modernizer described in Finding mistakes rewrites the second form into the first.

Two further rules apply to every pattern:

  • The result of the arithmetic must still point into the original allocated object. Unlike C, Go does not allow a pointer one past the end of an object, because that address may belong to the next object and keep it alive, or keep the wrong thing alive. Computing a pointer to the end of a buffer to use as a loop bound is a bug.

  • A nil pointer is not an object. unsafe.Pointer(uintptr(nilPtr) + offset) is invalid.

Why a uintptr cannot be stored

The most common mistake is splitting a valid pattern across statements. The following program is the textbook example. It computes a field address by converting to uintptr in one statement and back in the next.

package main

import (
	"fmt"
	"unsafe"
)

type header struct {
	a, b int32
}

func main() {
	h := new(header)
	// Convert to uintptr in one statement and back in the next.
	addr := uintptr(unsafe.Pointer(h))
	p := (*int32)(unsafe.Pointer(addr + unsafe.Offsetof(h.b)))
	fmt.Println(*p)
}
$ go run ./e6
0
$ go vet ./e6
e6/main.go:16:16: possible misuse of unsafe.Pointer

The program prints 0 and appears to work, which is exactly why this bug is dangerous. Between the two statements the variable addr is just an integer. If the garbage collector ran at that moment and h were unreachable it could be freed; if the goroutine’s stack grew and h lived on the stack it could be moved, and addr would point to the old location. Neither happens in this tiny program, which is why a test passes, and either can happen months later in production after an unrelated change. go vet (its unsafeptr analyzer, part of the default set) finds this class of mistake: "possible misuse of unsafe.Pointer" means that a uintptr that was not produced in the same expression is being converted to a pointer. The correct version uses unsafe.Add(unsafe.Pointer(h), unsafe.Offsetof(h.b)), which keeps the pointer a pointer throughout.

The analyzer’s documentation puts the underlying rule precisely: a conversion from uintptr to unsafe.Pointer is invalid if it implies that there is a uintptr-typed word in memory that holds a pointer value, because that word will be invisible to stack copying and to the garbage collector. The same applies to storing a pointer’s address in a field of type uintptr, in a map key or in a file: it is never a reference.

Compile errors

The compiler enforces the conversion rules for everything not in the four rules above. Arithmetic on Pointer, converting an integer variable to Pointer and converting between unrelated pointer types without going through Pointer are all rejected:

package main

import (
	"fmt"
	"unsafe"
)

type Book struct {
	Title string
	Pages int
}

func main() {
	b := Book{"Learning Go", 375}
	p := unsafe.Pointer(&b)
	q := p + 8
	n := 5
	r := unsafe.Pointer(n)
	s := (*int)(&b)
	fmt.Println(q, r, s)
}
# example.com/shelf/e13
e13/main.go:16:7: invalid operation: p + 8 (mismatched types unsafe.Pointer and untyped int)
e13/main.go:18:22: cannot convert n (variable of type int) to type unsafe.Pointer
e13/main.go:19:14: cannot convert &b (value of type *Book) to type *int

A conversion such as unsafe.Pointer(uintptr(0x1000)) does compile, but go vet reports "possible misuse of unsafe.Pointer" and the pointer points into nothing the program owns. Forgetting the import is the other common error: without import "unsafe" the compiler reports undefined: unsafe.

Slices and strings without copying

The only places where ordinary code legitimately needs unsafe today are those where it must avoid a copy that the type system would otherwise force. Go’s strings are immutable and slices are mutable, so converting between string and []byte copies by default. For a hot path that has proven, with a profile, that the copy matters (see Profiling, Tracing and Diagnostics and Performance and PGO), Go 1.20 provides four functions that make the conversion expressible without any header casts:

package main

import (
	"fmt"
	"unsafe"
)

// bytesToString returns a string that shares b's memory. b must not change
// while the string is in use.
func bytesToString(b []byte) string {
	if len(b) == 0 {
		return ""
	}
	return unsafe.String(unsafe.SliceData(b), len(b))
}

// stringToBytes returns a slice that shares s's memory. The caller must never
// write to it.
func stringToBytes(s string) []byte {
	if s == "" {
		return nil
	}
	return unsafe.Slice(unsafe.StringData(s), len(s))
}

func main() {
	buf := []byte("The Go Programming Language")
	s := bytesToString(buf)
	fmt.Println(s)

	buf[0] = 't' // the string changes too: they share memory
	fmt.Println(s)

	title := "Learning Go"
	b := stringToBytes(title)
	fmt.Println(len(b), cap(b), string(b[:8]))

	// unsafe.Slice builds a slice header from a pointer and a length.
	nums := [5]int{10, 20, 30, 40, 50}
	mid := unsafe.Slice(&nums[1], 3)
	fmt.Println(mid, len(mid), cap(mid))
	mid[0] = 21
	fmt.Println(nums)

	// unsafe.SliceData returns the address of the first element.
	var empty []int
	fmt.Println(unsafe.SliceData(empty) == nil, unsafe.SliceData(nums[:]) == &nums[0])
}
The Go Programming Language
the Go Programming Language
11 11 Learning
[20 30 40] 3 3
[10 21 30 40 50]
true true

The second line proves that no copy was made: after the byte buffer changed, the string seemed to change with it. That is the cost of the technique and the reason the functions are in unsafe. Their contracts are exact:

Function Contract

unsafe.String(ptr, len)

Returns a string whose bytes start at ptr and have length len. A negative length, or a nil ptr with a non-zero length, panics. The bytes must not be modified for as long as the string exists, because string values are assumed immutable (they can be used as map keys, shared between goroutines and compared without locks).

unsafe.StringData(s)

Returns a pointer to the bytes of s. For an empty string the result is unspecified and may be nil. The bytes must not be modified.

unsafe.Slice(ptr, len)

Returns a slice whose backing array starts at ptr and whose length and capacity are len. Equivalent to (*[len]T)(unsafe.Pointer(ptr))[:] except that a nil pointer with zero length gives a nil slice. A negative length, or a nil pointer with a non-zero length, panics.

unsafe.SliceData(s)

Returns &s[:1][0] when cap(s) > 0, nil for a nil slice, and otherwise a non-nil pointer to an unspecified address.

The edge cases behave as documented. This program calls each one in a way that must fail and reports the recovered panic value (the panics are runtime.Error values):

package main

import (
	"fmt"
	"unsafe"
)

func try(name string, f func()) {
	defer func() { fmt.Println(name+":", recover()) }()
	f()
}

func main() {
	var p *int
	n := -1
	k := 3
	try("negative length", func() { _ = unsafe.Slice(new(int), n) })
	try("nil pointer, non-zero length", func() { _ = unsafe.Slice(p, k) })
	try("nil pointer, zero length", func() { fmt.Println(unsafe.Slice(p, 0) == nil) })
	try("unsafe.String nil", func() { _ = unsafe.String(nil, k) })
}
negative length: runtime error: unsafe.Slice: len out of range
nil pointer, non-zero length: runtime error: unsafe.Slice: ptr is nil and len is not zero
true
nil pointer, zero length: <nil>
unsafe.String nil: runtime error: unsafe.String: ptr is nil and len is not zero

Source: unsafe: Slice

The line true is printed by the function body and <nil> by recover (nothing panicked) in the third case. A constant length that is negative is a compile-time error rather than a panic.

The immutable string rule

The dangerous direction is stringToBytes. The bytes of a string literal live in the program’s read-only data segment, and writing to them is not a Go-level error: it is a hardware fault.

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	title := "Learning Go" // a string literal lives in read-only memory
	b := unsafe.Slice(unsafe.StringData(title), len(title))
	fmt.Println("about to write")
	b[0] = 'l'
	fmt.Println(title)
}
about to write
unexpected fault address 0x1045745b0
fatal error: fault
[signal SIGBUS: bus error code=0x1 addr=0x1045745b0 pc=0x1045735a8]

goroutine 1 gp=0x5a6531b281e0 m=0 mp=0x10464e920 [running]:
runtime.throw({0x104573b82?, 0x0?})
...
main.main()
	.../e4/main.go:12 +0x68 fp=0x5a6531ba6ea0 sp=0x5a6531ba6e40 pc=0x1045735a8

This is a fatal error, not a panic: it cannot be recovered, and the process exits. Addresses vary between runs and machines (they are shown only to illustrate the format), and on other operating systems the signal can be SIGSEGV. Even when the memory happens to be writable, modifying the bytes of a string breaks every assumption the rest of the program makes: a string that was a map key can no longer be found in the map. The safe rules:

  1. Never write through a slice obtained from unsafe.StringData.

  2. Never modify a byte slice while a string created from it with unsafe.String is still reachable.

  3. Never let the slice or the string outlive the memory they point into when that memory is not managed by Go (for example, memory from mmap that is later unmapped).

  4. Prefer the safe copy until a profile shows the copy matters. The compiler already avoids copies for some common patterns: string(b) used as a map lookup key (m[string(b)]), in comparisons and in for range over a string(b) conversion, and for small results that do not escape. strings.Builder converts its buffer to a string with exactly the technique on this page, which is why the type is safe to use but must not be copied.

For converting in the other direction without unsafe at all, see slices.Clone, strings.Clone and bytes.Clone on Collections and Utility Packages, and the conversions of Strings, Runes and UTF-8.

Reinterpreting binary data

A frequent reason to reach for unsafe is to read a binary file format by casting a byte buffer to a struct. It is tempting because it is fast and it is what C does. In Go it is correct only when all of the following are true, and each one is a separate way for the program to break:

  • Layout. The struct has no padding that the file format does not have, and the field order matches. A compile-time assertion can enforce it.

  • Alignment. The address of the first byte is a multiple of the struct’s alignment. Slices returned by make and by os.ReadFile are aligned for any type that fits, but a sub-slice at an odd offset is not.

  • Byte order. The file’s byte order equals the host’s. Files are usually little-endian and most hosts are, but a big-endian platform such as s390x exists.

  • Type. The struct contains only fixed-size numeric types and arrays of them: no pointers, strings, slices, maps, interfaces or int, because their bytes in the file are meaningless as a Go pointer and the collector would find invented pointers.

  • Lifetime. The buffer must stay alive and unmodified while the pointer is used.

The following program contrasts the safe decoder (encoding/binary) with the unsafe one and shows the guards that make the second one honest. binary.Append and binary.Decode (Go 1.23) are used for the portable path.

package main

import (
	"encoding/binary"
	"fmt"
	"runtime"
	"unsafe"
)

// IndexHeader is the fixed-size header of a shelf index file. All fields are
// little-endian on disk and the layout has no padding.
type IndexHeader struct {
	Magic   [4]byte
	Version uint16
	Flags   uint16
	Count   uint32
	Offset  uint32
}

// The compiler rejects this file if the struct ever gains padding: the constant
// is negative (and so not a valid uintptr) unless Sizeof is exactly 16.
const _ = uintptr(unsafe.Sizeof(IndexHeader{}) - 16)
const _ = uintptr(16 - unsafe.Sizeof(IndexHeader{}))

// Padded is the same data with a wider Offset: it has padding, so its size
// differs from the number of bytes on disk.
type Padded struct {
	Magic   [4]byte
	Version uint16
	Flags   uint16
	Count   uint32
	Offset  uint64
}

// parseSafe decodes the header with encoding/binary: portable and checked.
func parseSafe(data []byte) (IndexHeader, error) {
	var h IndexHeader
	_, err := binary.Decode(data, binary.LittleEndian, &h)
	return h, err
}

// parseUnsafe reinterprets the bytes in place. It is only correct when the host
// is little-endian, data is at least as aligned as IndexHeader, and the struct
// has no padding.
func parseUnsafe(data []byte) (*IndexHeader, error) {
	if len(data) < int(unsafe.Sizeof(IndexHeader{})) {
		return nil, fmt.Errorf("short header: %d bytes", len(data))
	}
	if uintptr(unsafe.Pointer(unsafe.SliceData(data)))%unsafe.Alignof(IndexHeader{}) != 0 {
		return nil, fmt.Errorf("misaligned header")
	}
	return (*IndexHeader)(unsafe.Pointer(unsafe.SliceData(data))), nil
}

func main() {
	want := IndexHeader{Magic: [4]byte{'S', 'H', 'L', 'F'}, Version: 2, Flags: 1, Count: 1500, Offset: 4096}
	data, err := binary.Append(nil, binary.LittleEndian, want)
	if err != nil {
		panic(err)
	}
	fmt.Println("binary.Size:", binary.Size(want), "bytes:", len(data), "Sizeof:", unsafe.Sizeof(want))
	fmt.Println("padded: binary.Size:", binary.Size(Padded{}), "Sizeof:", unsafe.Sizeof(Padded{}))
	fmt.Printf("% x\n", data)

	h, err := parseSafe(data)
	fmt.Printf("%+v %v\n", h, err)

	// make returns memory aligned for any type that fits, so this works.
	aligned := make([]byte, len(data))
	copy(aligned, data)
	p, err := parseUnsafe(aligned)
	fmt.Println(*p == want, err, runtime.GOARCH)

	_, err = parseUnsafe(append(aligned, 0)[1:])
	fmt.Println(err)
}
binary.Size: 16 bytes: 16 Sizeof: 16
padded: binary.Size: 20 Sizeof: 24
53 48 4c 46 02 00 01 00 dc 05 00 00 00 10 00 00
{Magic:[83 72 76 70] Version:2 Flags:1 Count:1500 Offset:4096} <nil>
true <nil> arm64
misaligned header

Source: encoding/binary

How the guards work:

  • The two const _ lines are a compile-time assertion. uintptr cannot hold a negative number, so each constant expression is a compile error unless Sizeof(IndexHeader{}) is exactly 16. Adding a field, or changing a field to uint64, stops the build; with the wider Offset of Padded the compiler reports 16 - unsafe.Sizeof(IndexHeader{}) (constant -8 of type uintptr) overflows uintptr. This is the idiom to use whenever a layout is part of an external contract.

  • Padded shows why the assertion matters: binary.Size says the encoded form has 20 bytes (it ignores padding), while unsafe.Sizeof says 24. Casting a 20-byte buffer to Padded would read past the end of the data.

  • parseUnsafe checks the length, then the alignment. Without the length check a short buffer would be read beyond its end; without the alignment check a misaligned pointer is an invalid conversion (-d=checkptr reports it, see below) and a hardware fault on some architectures.

  • The byte-order assumption is not checked in the unsafe version. On a big-endian host the comparison would be false. encoding/binary handles it. Measure before replacing it: the portable path is usually fast enough, and the unsafe one is only worth its risk when a profile says so.

This book-style technique is the topic where the older Go books are most outdated. The first edition of Learning Go (Bodner, chapter 14, "Here Be Dragons") demonstrates reading binary data with unsafe.Pointer, Sizeof and Offsetof and the header-cast trick for converting between strings and byte slices. The ideas (layout, alignment, endianness) are all still correct, and the safe functions of the previous section replace the header-cast code. The book also correctly advises that cgo is for integration, not performance (see cgo and C Interoperability), and that unsafe should be limited to the rare case where nothing else will do.

Memory-mapped files and shared memory

Memory outside the Go heap, such as an mmap of a file or of anonymous pages, is the place where unsafe.Slice earns its keep. The kernel hands back an address; Go needs a []byte over it, and sometimes a typed view of the same bytes. The syscall package has Mmap and Munmap on Unix systems and returns a []byte already.

//go:build unix

package main

import (
	"fmt"
	"syscall"
	"unsafe"
)

// Slot is a fixed-size record stored in shared memory.
type Slot struct {
	ID    int64
	Loans uint32
	Flags uint32
}

func main() {
	size := syscall.Getpagesize()
	mem, err := syscall.Mmap(-1, 0, size,
		syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_ANON|syscall.MAP_PRIVATE)
	if err != nil {
		fmt.Println("mmap:", err)
		return
	}
	defer syscall.Munmap(mem)

	// View the mapped bytes as a slice of Slot values: no copy.
	slots := unsafe.Slice((*Slot)(unsafe.Pointer(unsafe.SliceData(mem))), len(mem)/int(unsafe.Sizeof(Slot{})))
	slots[0] = Slot{ID: 9780135, Loans: 3}
	slots[len(slots)-1].Loans = 7

	fmt.Println("page is a multiple of 4096:", size%4096 == 0)
	fmt.Println("slots:", len(slots), slots[0], slots[len(slots)-1].Loans)
	fmt.Println("first bytes:", mem[:8], "zeroed rest:", mem[16] == 0)
}
page is a multiple of 4096: true
slots: 1024 {9780135 3 0} 7
first bytes: [167 59 149 0 0 0 0 0] zeroed rest: true

Source: syscall: Mmap

The page size is machine-specific: this Apple Silicon machine has 16 KiB pages, so the 16-byte Slot gives 1024 slots (4 KiB pages would give 256). The program is Unix-only because of the //go:build unix constraint; the Windows equivalent uses CreateFileMapping and MapViewOfFile from golang.org/x/sys/windows. Things to remember about mapped memory:

  • The typed view is valid only while the mapping exists. After Munmap, using mem, slots or any pointer into them is a use-after-free that faults or corrupts. There is no finalizer to save you: the Go collector does not know the memory exists.

  • Slot contains only fixed-size numbers, so the collector has nothing to scan. Putting a pointer, a string or a slice into mapped memory is a bug: the collector will not find that pointer, and the object it references can be freed while the mapping still uses it.

  • Memory shared between processes needs the synchronisation rules of the architecture, not Go’s: use the sync/atomic typed values on aligned fields (see Sync and Atomics).

  • The same technique, with the same caveats, is used by libraries that expose a file as a []byte (read-only mappings of a database file, for example). A read-only mapping makes writes fault, just like the string literal earlier.

Finding mistakes

Silence from the compiler proves nothing about unsafe code. Three tools catch most mistakes, and all of them are part of the Go distribution.

go vet: the unsafeptr analyzer

go vet runs the unsafeptr analyzer by default. It reports conversions from uintptr to unsafe.Pointer that do not follow the patterns above ("possible misuse of unsafe.Pointer", shown earlier). The go vet documentation notes that silence is not a guarantee: the analyzer is deliberately conservative, since it looks at one expression at a time. See Formatting, Vet, Fix and Static Analysis for the rest of the vet suite, including asmdecl, which checks assembly (see Assembly in brief).

The checkptr instrumentation

The compiler can instrument every conversion of an unsafe.Pointer to a *T so that the runtime checks it. The option -d=checkptr was introduced in Go 1.14 and checks that:

  • the resulting pointer is aligned for T, and

  • the result does not straddle multiple allocations (the converted pointer plus Sizeof(T) stays inside the same Go object).

It also checks the arithmetic results of pointer calculations. It is enabled automatically by -race, -msan and -asan, and can be enabled alone for the whole build, standard library included, with -gcflags=all=-d=checkptr. It slows the program and enlarges the binary, so it belongs in test runs and CI, not in production. The following program has two bad conversions and runs one of them according to its argument.

package main

import (
	"fmt"
	"os"
	"unsafe"
)

var sink *[2]int64

func main() {
	switch os.Args[1] {
	case "straddle":
		// A pointer to a 16-byte allocation viewed as a pointer to 64 bytes.
		small := new([2]int64)
		sink = small // moves the allocation to the heap
		big := (*[8]int64)(unsafe.Pointer(small))
		big[7] = 1
	case "misaligned":
		// A string header holds a pointer, so it must be 8-byte aligned.
		buf := make([]byte, 64)
		s := (*string)(unsafe.Pointer(&buf[1]))
		fmt.Println(len(*s))
	}
}
$ go run -gcflags=all=-d=checkptr ./e5 straddle
fatal error: checkptr: converted pointer straddles multiple allocations

goroutine 1 gp=0xad8e4cfe1e0 m=0 mp=0x10240e940 [running]:
runtime.throw({0x102339630?, 0x10240e940?})
	.../runtime/panic.go:1243 +0x38 fp=0xad8e4d9adf0 sp=0xad8e4d9adc0 pc=0x102301808
runtime.checkptrAlignment(0x10240e940?, 0xad8e4e0e000?, 0xad8e4cfe1e0?)
	.../runtime/checkptr.go:26 +0x70 fp=0xad8e4d9ae10 sp=0xad8e4d9adf0 pc=0x10229fc50
main.main()
	.../e5/main.go:17 +0xa4 fp=0xad8e4d9aea0 sp=0xad8e4d9ae10 pc=0x102330c14
...
$ go run -gcflags=all=-d=checkptr ./e5 misaligned
fatal error: checkptr: misaligned pointer conversion
...
$ go run ./e5 misaligned
0

Without the instrumentation, the misaligned program prints 0 and the straddling write silently corrupts whatever follows the small allocation. With it, both stop with a fatal error and a stack that points at the exact conversion. (The stack lines are abbreviated, and the addresses are machine-specific.) A checkptr failure is always a bug in the code, not a false alarm, with one caveat: the check was unreliable on Windows when it was introduced, so enable it there only on a recent release and look at reports with care. Making go test -race ./... part of CI gives you checkptr for free (see Testing and Memory Model and Data Races). To disable the check for a build that enables it implicitly, use -gcflags=all=-d=checkptr=0.

The unsafefuncs fixer (Go 1.27)

Code written before Go 1.17 computes addresses with the uintptr pattern. Go 1.27 ships the unsafefuncs modernizer in go fix, which rewrites unsafe.Pointer(uintptr(ptr) + uintptr(n)), where ptr is an unsafe.Pointer, into unsafe.Add(ptr, n). The release notes list it among the new go fix modernizers (together with atomictypes, embedlit and slicesbackward). Run go fix -diff to preview, and go fix to apply. Running it on the second program of this page:

$ go fix -unsafefuncs -diff ./e2
--- .../e2/main.go (old)
+++ .../e2/main.go (new)
@@ -23,7 +23,7 @@
 	fmt.Println(*second)

 	// The same address computed in one expression, uintptr only in the middle.
-	third := (*int32)(unsafe.Pointer(uintptr(p) + 2*unsafe.Sizeof(isbns[0])))
+	third := (*int32)(unsafe.Add(p, 2*unsafe.Sizeof(isbns[0])))
 	fmt.Println(*third)

 	// Offsetof reaches a field of a struct.

The fix is mechanical and behaviour-preserving, and only applies to modules whose go line is at least 1.17 (unsafe.Add does not exist before then). The modernizer does not yet handle unsafe.Slice, SliceData, String and StringData; the source of the analyzer lists them as future work, so check go tool fix help before relying on them.

structs.HostLayout

The Go specification does not say in what order the fields of a struct are placed in memory or how much padding is inserted: it says only that fields appear in an order and have alignment. Today the compiler lays them out exactly like a C compiler would, but a future release may reorder fields to save space. Structs that are passed to or read from host code (system calls, cgo, memory-mapped hardware, WebAssembly imports) must not be affected by such an optimisation. Go 1.23 added the structs package with one type, HostLayout, to state the intent:

package main

import (
	"fmt"
	"structs"
	"unsafe"
)

// CTimespec mirrors struct timespec in C. HostLayout asks the compiler to lay
// the fields out exactly as the host C ABI would, whatever future Go layout
// optimisations are introduced.
type CTimespec struct {
	_    structs.HostLayout
	Sec  int64
	Nsec int64
}

// Plain has no such promise.
type Plain struct {
	Sec  int64
	Nsec int64
}

func main() {
	fmt.Println(unsafe.Sizeof(CTimespec{}), unsafe.Offsetof(CTimespec{}.Nsec))
	fmt.Println(unsafe.Sizeof(Plain{}), unsafe.Offsetof(Plain{}.Nsec))
}
16 8
16 8

Today the two layouts are identical, as the output shows, and the marker costs nothing: it is a zero-size field. The value is the contract: a field of type structs.HostLayout makes the struct "laid out in memory according to host expectations, generally following the host’s C ABI". The documentation lays down a convention and two limits:

  • By convention the marker is a blank field named _ placed first in the struct.

  • It does not propagate. Struct-typed fields of the marked struct are laid out as they would be otherwise, and a struct that contains a marked struct is not itself marked. Every struct in a chain of nested C-compatible structures needs its own marker.

Use it on any struct whose bytes cross a boundary: the arguments of syscall calls, the types you pass through cgo (see cgo and C Interoperability), and structs serialised by overlaying memory. For the equivalent in Rust see #[repr©] on Rust memory layout and repr.

//go:linkname

The //go:linkname directive makes two Go symbols share the same object-file symbol. In its two-argument form //go:linkname localname importpath.name it turns a local declaration (a function without a body, or a variable) into an alias of a symbol that is defined elsewhere, including unexported ones in the standard library. It bypasses both encapsulation and type safety, which is why the compiler only accepts it in files that import unsafe, even if only with a blank import. Without the import the compiler reports:

e14/main.go:5:3: //go:linkname only allowed in Go files that import "unsafe"
e14/main.go:6:6: missing function body

The compiler documentation describes two forms. In the pull form the package that wants the symbol declares it and links to the definition in another package. In the push form the package that defines a symbol marks it with a //go:linkname of its own (a directive with just the local name, or with a different target name), which allows other packages to refer to it. For years the ecosystem pulled unexported runtime functions such as nanotime, memhash and fastrand into third-party packages. That made the runtime impossible to change without breaking a large part of the module ecosystem, so Go 1.23 restricted it:

  • The linker rejects //go:linkname references to internal symbols of the standard library (including the runtime) unless the symbol is marked with //go:linkname on its definition (push form), i.e. unless the standard library agreed to export it.

  • For compatibility, references found in a large corpus of open-source code stay allowed. New references to other internals are rejected.

  • The same restriction applies to assembly that refers to such a symbol.

  • The linker flag -checklinkname=0 disables the check, for debugging and experiments only.

An unmarked symbol is rejected at link time. This program tries to bind to runtime.nanotime1, which is not marked:

package main

import (
	"fmt"
	_ "unsafe" // required for go:linkname
)

// The pull form of linkname: bind a local declaration to a runtime symbol.
//
//go:linkname nanotime runtime.nanotime1
func nanotime() int64

func main() {
	fmt.Println(nanotime() > 0)
}
$ go run ./e9
# example.com/shelf/e9
link: main: invalid reference to runtime.nanotime1
$ go run -ldflags=-checklinkname=0 ./e9
true

The same program written against runtime.nanotime, which the runtime marks for the benefit of existing code, links and prints true without any flag. That is a historical exception, and not a recommendation: the symbol may become unavailable in a later release. Practical rules:

  • Do not use //go:linkname to reach standard-library internals. If you need the functionality, there is almost always a public API (time.Now, hash/maphash, math/rand/v2), and if not, file a proposal to add one.

  • When you maintain a package that is linked to by others, mark the symbol with the push form so that the linker allows it, and keep the signature stable.

  • -checklinkname=0 in a build script is a smell that something unsupported is being relied on.

Assembly in brief

Go has its own assembler. Its syntax derives from the Plan 9 assembler, is not the same as the assembler of the platform’s C toolchain, and uses pseudo-registers (FP, SB, SP, PC) that give the assembler a portable view of the frame. Assembly files (.s) sit beside the Go files of a package, are assembled by go build, and are used in a few places only: cryptographic and hashing primitives, the runtime, SIMD routines and operations that Go cannot express. The official guide is A Quick Guide to Go’s Assembler. For most code the right answer is to write Go, and let the compiler’s intrinsics do the work: math/bits, sync/atomic, crypto/* and the experimental simd packages mentioned on Performance and PGO already map to single instructions.

The rule of the thumb: a function implemented in assembly is declared in Go without a body, and defined in a .s file for each supported architecture, with a pure-Go fallback for the others.

package main

// add returns a+b. The body is in add_arm64.s or add_amd64.s.
//
//go:noescape
func add(a, b int64) int64

// addUint64s returns the sum of s. The body is in assembly on arm64 and amd64.
//
//go:noescape
func addUint64s(s []uint64) uint64

A small driver calls both functions:

package main

import "fmt"

func main() {
	fmt.Println(add(40, 2))
	fmt.Println(addUint64s([]uint64{1, 2, 3, 4, 5, 6, 7}))
}

The arm64 implementation is in add_arm64.s (the file-name suffix is an implicit build constraint; see The go Command, Builds and Cross-Compilation):

#include "textflag.h"

// func add(a, b int64) int64
TEXT ·add(SB), NOSPLIT, $0-24
	MOVD a+0(FP), R0
	MOVD b+8(FP), R1
	ADD  R1, R0, R0
	MOVD R0, ret+16(FP)
	RET

// func addUint64s(s []uint64) uint64
TEXT ·addUint64s(SB), NOSPLIT, $0-32
	MOVD s_base+0(FP), R0
	MOVD s_len+8(FP), R1
	MOVD $0, R2
loop:
	CBZ  R1, done
	MOVD.P 8(R0), R3
	ADD  R3, R2, R2
	SUB  $1, R1, R1
	B    loop
done:
	MOVD R2, ret+24(FP)
	RET

and the amd64 implementation, in add_amd64.s, does the same with MOVQ, ADDQ and a counted loop:

#include "textflag.h"

// func add(a, b int64) int64
TEXT ·add(SB), NOSPLIT, $0-24
	MOVQ a+0(FP), AX
	ADDQ b+8(FP), AX
	MOVQ AX, ret+16(FP)
	RET

// func addUint64s(s []uint64) uint64
TEXT ·addUint64s(SB), NOSPLIT, $0-32
	MOVQ s_base+0(FP), SI
	MOVQ s_len+8(FP), CX
	XORQ AX, AX
loop:
	TESTQ CX, CX
	JZ    done
	ADDQ  (SI), AX
	ADDQ  $8, SI
	DECQ  CX
	JMP   loop
done:
	MOVQ AX, ret+24(FP)
	RET
$ go run ./e8
42
28
$ GOARCH=amd64 go vet ./e8 && GOARCH=amd64 go build -o /dev/null ./e8 && echo amd64 ok
amd64 ok

The program was run on arm64 and the amd64 version was vetted and cross-built, but not run (the machine is not amd64). What the example shows:

  • TEXT ·add(SB), NOSPLIT, $0-24 defines the function add of the current package (the · is the middle dot that separates package and name); NOSPLIT omits the stack-growth check, $0 is the local frame size and 24 the argument-plus-result size (two 8-byte arguments and an 8-byte result).

  • Arguments and results are addressed as name+offset(FP), with the name matching the Go declaration. A slice argument is three words, addressed as s_base, s_len and s_cap.

  • //go:noescape tells the compiler that the function does not keep the pointers passed to it, so that the arguments need not be moved to the heap.

  • This is the stack-based (ABI0) calling convention, which the assembler wraps; Go code uses register-based ABIInternal. Assembly functions are reached through compiler-generated wrappers, which is a small cost per call and one more reason to put whole loops, not single instructions, in assembly.

go vet includes the asmdecl analyzer, which checks that every assembly function agrees with its Go declaration. A wrong frame size, a misnamed argument or a result written at the wrong offset is reported at the assembly line. Changing $0-24 to $0-16 in the arm64 file gives:

e8bad/add_arm64.s:4:1: [arm64] add: wrong argument size 16; expected $...-24

Maintain a pure-Go version in a file with a constraint such as //go:build !amd64 && !arm64 so that other platforms and GOARCH=wasm still build, and test the two against each other, with a fuzz test (see Benchmarks, Fuzzing and synctest). Assembly cannot be inlined, and is opaque to the compiler’s optimisations, so a loop in assembly can be slower than the Go loop for short inputs. Benchmark both.

The syscall package and golang.org/x/sys

The standard syscall package is the lowest-level portable interface to the operating system. It has Syscall, Syscall6, RawSyscall, the system call numbers, the structures (Stat_t and Sockaddr, for example), the error type Errno and wrappers such as Mmap, Getpid and Kill. Most programs should not use it. The package documentation says that the primary use is inside other packages that provide a more portable interface, such as os, time and net: "use those packages rather than this one if you can".

The syscall package is frozen. The Go 1.4 proposal that froze it (linked below) says it will no longer be updated with new operating-system features, and that new code should use golang.org/x/sys instead; the package documentation says so too. The golang.org/x/sys repository (version v0.49.0 at the time of writing) is where new system calls, constants, flags and platforms are added, and it has one package per platform family: unix (Linux, the BSDs, macOS, Solaris, AIX), windows and plan9, and cpu for CPU feature detection. The two packages differ in important ways:

Aspect syscall golang.org/x/sys

Updates

Frozen: bug fixes only.

Updated continuously, versioned in go.mod.

Coverage

What existed in Go 1.4, plus a few additions.

New system calls and constants for each kernel and OS release; more complete.

Portability

One file per platform; code often needs build tags.

Same, but with a more regular API across unix platforms (unix.Mmap, unix.Madvise, the unix.Ioctl family).

Used by the standard library

Yes.

The Go distribution carries a private copy of part of it under cmd/vendor for its own tools.

Cost

None.

One more dependency to track with govulncheck.

The following program is the memory-mapped example again, written against golang.org/x/sys/unix. It has an additional call, unix.Uname, that the frozen package does not have in a portable form. The module here is only available offline, so the example was compiled and run with replace pointing at the copy of x/sys/unix that ships inside the Go distribution’s cmd/vendor; with a network connection the module path and the go.mod requirement golang.org/x/sys v0.49.0 below are what go get would add. The output on macOS is shown.

module example.com/shelf

go 1.27

require golang.org/x/sys v0.49.0
//go:build unix

package main

import (
	"fmt"
	"os"
	"unsafe"

	"golang.org/x/sys/unix"
)

// Slot is a fixed-size record stored in shared memory.
type Slot struct {
	ID    int64
	Loans uint32
	Flags uint32
}

func main() {
	size := os.Getpagesize()
	mem, err := unix.Mmap(-1, 0, size,
		unix.PROT_READ|unix.PROT_WRITE, unix.MAP_ANON|unix.MAP_PRIVATE)
	if err != nil {
		fmt.Println("mmap:", err)
		return
	}
	defer unix.Munmap(mem)

	slots := unsafe.Slice((*Slot)(unsafe.Pointer(unsafe.SliceData(mem))), len(mem)/int(unsafe.Sizeof(Slot{})))
	slots[0] = Slot{ID: 9780135, Loans: 3}
	fmt.Println(slots[0], len(slots) == size/16)

	// Anything the syscall package lacks is one call away.
	var uts unix.Utsname
	if err := unix.Uname(&uts); err != nil {
		fmt.Println("uname:", err)
		return
	}
	fmt.Println(unix.ByteSliceToString(uts.Sysname[:]))
}
{9780135 3 0} true
Darwin

Rules for raw system calls:

  • Call the typed wrappers (unix.Mmap, unix.Read, unix.Getrandom…​) first. They convert pointers correctly.

  • When you must call syscall.Syscall (or unix.Syscall) directly, convert pointers to uintptr in the argument list of the call (pattern 4). The compiler then keeps the referenced object alive and in place until the call returns. Storing the uintptr in a variable first is invalid, and go vet reports it.

  • An Errno that is zero is success; non-zero is an error, and Errno implements error and Is(fs.ErrNotExist) and friends (see Error Handling).

  • On macOS and some BSDs the stable interface to the kernel is libc, not the raw system call numbers, so raw Syscall numbers there are not a dependable interface. Linux system call numbers are stable.

  • A function in os or net that needs the file descriptor of an open file offers SyscallConn, whose Control method runs a callback with the descriptor held open. Prefer it to Fd() for ioctl-style calls on files and sockets managed by Go.

  • Windows has its own families: syscall, golang.org/x/sys/windows, and the LazyDLL types that load a DLL at first use.

When not to use unsafe

Use the least powerful tool that works:

You want to Use instead of unsafe

Convert string and []byte without a copy

The safe copy, unless a profile proves otherwise; strings.Builder, bytes.Buffer, slices.Clone when copying is wanted.

Read or write binary formats

encoding/binary, binary.Read, binary.Append, binary.Decode, bufio and io.ReadFull; or generate the codecs.

Reinterpret a float or integer

math.Float64bits, Float64frombits, Float32bits, math/bits.

Get a struct field by name

reflect (Reflection), or an exported accessor, or an interface.

Share a counter between goroutines

sync/atomic typed values (atomic.Int64, atomic.Pointer[T]) and sync.Mutex (Sync and Atomics).

Call C

cgo (cgo and C Interoperability), or a separate process with a pipe or RPC.

Make a program faster

A better algorithm; fewer allocations (Memory Management and Garbage Collection); profile-guided optimisation (Performance and PGO).

Reach a runtime or standard-library internal

A public API, or a proposal for one; //go:linkname is restricted since Go 1.23.

Hold a pointer without keeping an object alive

The weak package (Cleanups, Weak Pointers and Interning).

The decision can be drawn as a short flow:

flowchart TD A["Need to see or change memory the type system hides"] --> B{"Does a safe API exist?"} B -- yes --> C["Use it: encoding/binary, math/bits, atomic, reflect, weak"] B -- no --> D{"Is the data owned by Go and typed?"} D -- yes --> E["unsafe.Add, Slice, String, SliceData and StringData"] D -- "no: C or OS memory" --> F["syscall or x/sys, cgo, mmap with unsafe.Slice"] E --> G["Add a layout assertion and run tests with -race or -d=checkptr"] F --> G G --> H["Run go vet and go fix, and document the invariant next to the code"]

If you do use unsafe:

  1. Confine it to one small file or package, behind a safe API that checks its inputs (length, alignment, nil).

  2. Write the invariant in a comment next to the conversion, and name what keeps the memory alive.

  3. Add compile-time layout assertions (the const _ idiom above) for every struct whose layout matters.

  4. Test with -race (which turns on checkptr), run go vet, and run a fuzz test on any parser that casts bytes.

  5. Keep a uintptr only for printing or comparing addresses, never to hold a reference.

  6. Keep an ordinary reference alive across the unsafe use with runtime.KeepAlive when the only other reference is a uintptr or a pointer into memory that the collector might otherwise reclaim.

  7. Do not build structures that store Go pointers in memory the garbage collector does not scan (mapped memory, C memory, byte buffers).

Pitfalls

  • Storing uintptr instead of unsafe.Pointer. A uintptr is a plain integer and neither keeps an object alive nor follows it when it moves. This is the number-one unsafe bug.

  • Hiding pointers in byte slices. A []byte is never scanned for pointers. Copying pointer values into one hides them from the collector, and the objects they reference can be freed.

  • A pointer one past the end. Valid in C, invalid in Go.

  • Unaligned access. A pointer cast must be aligned for the target type. On x86 an unaligned load happens to work and is only slow; on other architectures it faults, and checkptr rejects it everywhere.

  • Reading beyond the allocation. unsafe.Slice(p, n) trusts n. The compiler cannot check it.

  • Mutating the immutable. String data, constants, and read-only mapped memory.

  • Assuming layout. The size of int, uintptr and pointers differs between platforms, padding is up to the compiler (use HostLayout and assertions), and unsafe.Sizeof of an interface or map is not a stable interface of the runtime.

  • Peeking inside interfaces, maps, channels and function values. Their internal layout is a private detail of the runtime and changes between releases (Swiss-table maps arrived in Go 1.24). Code that reads a map header or an interface’s type word through unsafe breaks when the runtime changes, and is also exempt from the compatibility promise.

  • Believing silence from go vet is proof. It is not, and neither is checkptr passing on one test run.

Version notes

  • Go 1.0: unsafe.Pointer, Sizeof, Alignof and Offsetof. The syscall package is the system interface.

  • Go 1.4: the syscall package is frozen; new code is directed to golang.org/x/sys.

  • Go 1.14: the -d=checkptr compiler instrumentation, enabled with -race and -msan.

  • Go 1.17: unsafe.Add and unsafe.Slice; conversion from a slice to an array pointer.

  • Go 1.20: unsafe.SliceData, unsafe.String and unsafe.StringData.

  • Go 1.21: the reflect slice and string header types are deprecated in favour of the unsafe functions.

  • Go 1.23: the linker restricts //go:linkname references to standard-library internals (-checklinkname=0 to override); the structs package with HostLayout.

  • Go 1.24: //go:wasmexport for WebAssembly (see WebAssembly and WASI).

  • Go 1.27: the unsafefuncs modernizer in go fix, which rewrites uintptr arithmetic to unsafe.Add. The release notes list no change to the unsafe package itself. Examples were checked with go 1.27 in go.mod.