Unsafe Code

Updated

September 13, 2026

Unsafe Code

The boring default is never to import unsafe. The one use this book will show is unsafe.Sizeof (and the idea of Alignof) so you can see how big a value is. That is a measurement. It is not a licence to poke memory.

Mental model

Go’s type system does not let you treat an arbitrary integer as a pointer. Package unsafe does. The compiler and the garbage collector have a contract with unsafe.Pointer. If you violate it, you do not get a nice error. You get a binary that is wrong on the next toolchain release.

unsafe.Sizeof(x) returns the size in bytes of x’s representation as a uintptr. It does not allocate. It does not follow pointers: the size of a string is the header (pointer plus length), not the bytes of the text.

unsafe.Alignof(x) is the alignment the compiler uses for that type. This chapter does not call it in code. You can see alignment effects with Sizeof on structs that include padding.

We will not take unsafe.Pointer to a uintptr and back. That is how people write exploits and also how people write “clever” code that breaks when the GC moves. Do not.

Worked examples

Sizes below assume a typical 64-bit machine (amd64 or arm64). On 32-bit, int and headers shrink. Measure on the machine you ship.

Case 1: Size of the types you already use

Save as sizes.go:

// sizes.go
package main

import (
    "fmt"
    "unsafe"
)

type Ticket struct {
    ID    int
    Table int
}

func main() {
    var n int
    var s string
    var t Ticket
    var p *Ticket
    fmt.Println("int", unsafe.Sizeof(n))
    fmt.Println("string", unsafe.Sizeof(s))
    fmt.Println("Ticket", unsafe.Sizeof(t))
    fmt.Println("*Ticket", unsafe.Sizeof(p))
}

Run:

go run sizes.go

Typical output on 64-bit:

int 8
string 16
Ticket 16
*Ticket 8

string is 16 because it is a pointer and a length, each 8 bytes. The characters live elsewhere. Ticket is two ints. A pointer is one machine word.

Case 2: Padding is visible as a larger Sizeof

Save as padding.go. Both structs hold one int64 and two bools. Field order changes the size because the compiler inserts padding to align int64.

// padding.go
package main

import (
    "fmt"
    "unsafe"
)

type Waste struct {
    A bool
    B int64
    C bool
}

type Tight struct {
    B int64
    A bool
    C bool
}

func main() {
    fmt.Println("Waste", unsafe.Sizeof(Waste{}))
    fmt.Println("Tight", unsafe.Sizeof(Tight{}))
}

Run:

go run padding.go

Typical output on 64-bit:

Waste 24
Tight 16

Waste is 1 + 7 pad + 8 + 1 + 7 pad = 24. Tight is 8 + 1 + 1 + 6 pad = 16. That is interesting if you have millions of these in a slice. It is not a reason to reorder a desk Ticket with three fields.

Case 3: A slice header is not the backing array

Save as slice_size.go:

// slice_size.go
package main

import (
    "fmt"
    "unsafe"
)

func main() {
    small := []int{1, 2, 3}
    large := make([]int, 10_000)
    fmt.Println("small header", unsafe.Sizeof(small))
    fmt.Println("large header", unsafe.Sizeof(large))
    fmt.Println("one int", unsafe.Sizeof(small[0]))
}

Run:

go run slice_size.go

Typical output on 64-bit:

small header 24
large header 24
one int 8

Both headers are 24 bytes (pointer, length, capacity). Sizeof does not report the 10_000 integers. Do not use it to estimate memory of a slice; use len × element size plus the header, and remember capacity.

Case 4: Field alignment and padding with Offsetof

Save as alignof.go. Why did Waste take 24 bytes in Case 2 while Tight took 16? Hardware CPUs read memory in naturally aligned boundaries (e.g. an 8-byte int64 must sit at a memory address divisible by 8). unsafe.Alignof reports the required alignment, and unsafe.Offsetof shows the exact byte offset of each field within the struct.

// alignof.go
package main

import (
    "fmt"
    "unsafe"
)

type Ticket struct {
    Active bool
    Table  int64
    ID     int32
}

func main() {
    var t Ticket
    fmt.Println("size of Ticket:", unsafe.Sizeof(t))
    fmt.Println("align of bool:", unsafe.Alignof(t.Active))
    fmt.Println("align of int64:", unsafe.Alignof(t.Table))
    fmt.Println("offset of Active:", unsafe.Offsetof(t.Active))
    fmt.Println("offset of Table:", unsafe.Offsetof(t.Table))
    fmt.Println("offset of ID:", unsafe.Offsetof(t.ID))
}

Run:

go run alignof.go

Typical output on 64-bit:

size of Ticket: 24
align of bool: 1
align of int64: 8
offset of Active: 0
offset of Table: 8
offset of ID: 16

Active starts at byte 0 and takes 1 byte. Because Table requires 8-byte alignment, the compiler leaves bytes 1 through 7 unused as padding. Table occupies bytes 8 through 15. ID occupies bytes 16 through 19. Finally, 4 trailing padding bytes are added so that an array of Ticket structs preserves the 8-byte alignment for the next element (total 24 bytes).

The trap

Importing unsafe so you can convert a []byte to a string without copying looks fast and is a contract with the compiler you probably do not want. This chapter will not show that conversion.

The boring path copies. Save as copy_string.go:

// copy_string.go
package main

import "fmt"

func main() {
    b := []byte("soup")
    s := string(b)
    b[0] = 'S'
    fmt.Println(s)
    fmt.Println(string(b))
}

Run:

go run copy_string.go

Output:

soup
Soup

s is independent of b because string(b) copied. That is the behaviour you want at the desk. A unsafe.Pointer cast that aliases the same bytes will make s change when b changes — or worse, when the GC reclaims b. Do not go looking for that spell.

unsafe.Pointer is a contract with the compiler. If you are not prepared to read the current unsafe package docs and the Go release notes every six months, you are not prepared to use it.

The boring rule

  • Do not import unsafe in desk code.
  • unsafe.Sizeof, Alignof, and Offsetof are diagnostic rulers. Use them in a throwaway program when you are curious about memory layout.
  • Do not reorder fields for speed until a profile says a giant slice of that struct is hot.
  • Do not use unsafe.Pointer. Not to dodge a copy, not to call C, not to “inspect” a slice.
  • If a library requires you to pass unsafe.Pointer, read why, wrap it in one file, and keep it off the rest of the API.

Try this

  1. In sizes.go, add a bool field to Ticket (first, then last). Print Sizeof each time. On 64-bit you will likely see padding.
  2. In alignof.go, reorder the fields in Ticket to put Table int64 first, then ID int32, then Active bool. Print unsafe.Sizeof(t) and observe how the size drops from 24 to 16 bytes.
  3. Print unsafe.Sizeof([3]int{}) and compare it to unsafe.Sizeof([]int{}). The array is three ints; the slice is a header.
  4. Run sizes.go with GOARCH=386 if you have a C compiler and cgo, or just read go env GOARCH and accept that 32-bit numbers differ. Do not invent a Pointer cast to “normalise” them.