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 |
|---|---|---|
|
A pointer to an arbitrary type. The one type that can be converted to and from any pointer type, and to and from |
1.0 |
|
Size in bytes of the value |
1.0 |
|
Required alignment of a variable of that type, in bytes. A constant for types without variable size. |
1.0 |
|
Offset of field |
1.0 |
|
|
1.17 |
|
A slice of |
1.17 |
|
Pointer to the first element of the slice’s backing array. |
1.20 |
|
A string whose bytes start at |
1.20 |
|
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.
Source: unsafe package documentation
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:
-
Loanwastes eight bytes.Returnedtakes one byte, then seven bytes of padding bringIDto an 8-byte boundary, andRenewalsfollowed byMemberneed three more bytes of padding. Sorting the same fields from the largest alignment to the smallest (Compact) gives 16 bytes. The struct padding story, thefieldalignmentanalyzer and the effect on cache lines are on Structs and Embedding. -
Sizeofmeasures 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 ofint16has 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
Pointercan be converted to a pointer value of any type. -
A
uintptrcan be converted to aPointer. -
A
Pointercan be converted to auintptr.
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 |
Reinterpreting data of one type as another, such as |
2 |
Conversion of a |
Printing an address, or logging and comparing it. |
3 |
Conversion of a |
Reaching a struct field or an array element: |
4 |
Conversion of a |
Passing pointers to the operating system. |
5 |
Conversion of the result of |
Getting at the address behind a reflected value. |
6 |
Conversion of the |
The old header-cast technique, replaced by |
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
nilpointer 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
Source: go vet: the unsafeptr analyzer
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 |
|---|---|
|
Returns a string whose bytes start at |
|
Returns a pointer to the bytes of |
|
Returns a slice whose backing array starts at |
|
Returns |
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
Source: unsafe: StringData
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:
-
Never write through a slice obtained from
unsafe.StringData. -
Never modify a byte slice while a string created from it with
unsafe.Stringis still reachable. -
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
mmapthat is later unmapped). -
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 infor rangeover astring(b)conversion, and for small results that do not escape.strings.Builderconverts 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
makeand byos.ReadFileare 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
s390xexists. -
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.uintptrcannot hold a negative number, so each constant expression is a compile error unlessSizeof(IndexHeader{})is exactly 16. Adding a field, or changing a field touint64, stops the build; with the widerOffsetofPaddedthe compiler reports16 - 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. -
Paddedshows why the assertion matters:binary.Sizesays the encoded form has 20 bytes (it ignores padding), whileunsafe.Sizeofsays 24. Casting a 20-byte buffer toPaddedwould read past the end of the data. -
parseUnsafechecks 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=checkptrreports 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/binaryhandles 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, usingmem,slotsor 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. -
Slotcontains 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/atomictyped 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
Source: cmd/compile: -d=checkptr
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.
Source: modernize analyzers
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
Source: structs: HostLayout
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:linknamereferences to internal symbols of the standard library (including the runtime) unless the symbol is marked with//go:linknameon 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=0disables 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:linknameto 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=0in 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
Source: A Quick Guide to Go’s Assembler
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-24defines the functionaddof the current package (the·is the middle dot that separates package and name);NOSPLITomits the stack-growth check,$0is the local frame size and24the 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 ass_base,s_lenands_cap. -
//go:noescapetells 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
Source: go vet: the asmdecl analyzer
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 |
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 |
Used by the standard library |
Yes. |
The Go distribution carries a private copy of part of it under |
Cost |
None. |
One more dependency to track with |
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
Source: golang.org/x/sys/unix
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(orunix.Syscall) directly, convert pointers touintptrin the argument list of the call (pattern 4). The compiler then keeps the referenced object alive and in place until the call returns. Storing theuintptrin a variable first is invalid, andgo vetreports it. -
An
Errnothat is zero is success; non-zero is an error, andErrnoimplementserrorandIs(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 rawSyscallnumbers there are not a dependable interface. Linux system call numbers are stable. -
A function in
osornetthat needs the file descriptor of an open file offersSyscallConn, whoseControlmethod runs a callback with the descriptor held open. Prefer it toFd()forioctl-style calls on files and sockets managed by Go. -
Windows has its own families:
syscall,golang.org/x/sys/windows, and theLazyDLLtypes 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 |
The safe copy, unless a profile proves otherwise; |
Read or write binary formats |
|
Reinterpret a float or integer |
|
Get a struct field by name |
|
Share a counter between goroutines |
|
Call C |
|
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; |
Hold a pointer without keeping an object alive |
The |
The decision can be drawn as a short flow:
If you do use unsafe:
-
Confine it to one small file or package, behind a safe API that checks its inputs (length, alignment, nil).
-
Write the invariant in a comment next to the conversion, and name what keeps the memory alive.
-
Add compile-time layout assertions (the
const _idiom above) for every struct whose layout matters. -
Test with
-race(which turns oncheckptr), rungo vet, and run a fuzz test on any parser that casts bytes. -
Keep a
uintptronly for printing or comparing addresses, never to hold a reference. -
Keep an ordinary reference alive across the unsafe use with
runtime.KeepAlivewhen the only other reference is auintptror a pointer into memory that the collector might otherwise reclaim. -
Do not build structures that store Go pointers in memory the garbage collector does not scan (mapped memory, C memory, byte buffers).
Pitfalls
-
Storing
uintptrinstead ofunsafe.Pointer. Auintptris a plain integer and neither keeps an object alive nor follows it when it moves. This is the number-oneunsafebug. -
Hiding pointers in byte slices. A
[]byteis 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
checkptrrejects it everywhere. -
Reading beyond the allocation.
unsafe.Slice(p, n)trustsn. The compiler cannot check it. -
Mutating the immutable. String data, constants, and read-only mapped memory.
-
Assuming layout. The size of
int,uintptrand pointers differs between platforms, padding is up to the compiler (useHostLayoutand assertions), andunsafe.Sizeofof 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
unsafebreaks when the runtime changes, and is also exempt from the compatibility promise. -
Believing silence from
go vetis proof. It is not, and neither ischeckptrpassing on one test run.
Version notes
-
Go 1.0:
unsafe.Pointer,Sizeof,AlignofandOffsetof. Thesyscallpackage is the system interface. -
Go 1.4: the
syscallpackage is frozen; new code is directed togolang.org/x/sys. -
Go 1.14: the
-d=checkptrcompiler instrumentation, enabled with-raceand-msan. -
Go 1.17:
unsafe.Addandunsafe.Slice; conversion from a slice to an array pointer. -
Go 1.20:
unsafe.SliceData,unsafe.Stringandunsafe.StringData. -
Go 1.21: the reflect slice and string header types are deprecated in favour of the
unsafefunctions. -
Go 1.23: the linker restricts
//go:linknamereferences to standard-library internals (-checklinkname=0to override); thestructspackage withHostLayout. -
Go 1.24:
//go:wasmexportfor WebAssembly (see WebAssembly and WASI). -
Go 1.27: the
unsafefuncsmodernizer ingo fix, which rewritesuintptrarithmetic tounsafe.Add. The release notes list no change to theunsafepackage itself. Examples were checked withgo 1.27ingo.mod.