Can You Do Pointer Arithmetic in Go

Go forbids direct pointer arithmetic for safety, requiring the unsafe package for manual memory offset calculations.

The compiler blocks pointer arithmetic to protect the garbage collector

You are parsing a binary file format. The header is 16 bytes. You have a pointer to the start of the buffer. In C, you would cast that pointer to a byte pointer, add 16, and cast it back to your struct type. You try the same trick in Go. You write ptr + 16. The compiler rejects the code immediately.

Go does not allow pointer arithmetic. This restriction feels like a wall when you are used to low-level languages, but it exists for a reason. Go treats pointers as opaque handles to memory. The garbage collector relies on pointers to track live objects. If you could add an integer to a pointer, you could create a pointer that points to the middle of an object or to random memory. The collector would get confused. It might free memory you are using, or it might keep garbage alive forever.

Think of a pointer like a tag attached to a piece of luggage. The baggage handler moves the luggage around the terminal. The tag stays attached. You can read the tag to find the luggage. You cannot do math on the tag to find a different piece of luggage. The handler might move the luggage to a new location at any time. If you write down the shelf number on a scrap of paper, the handler does not know about the paper. When the luggage moves, the number on the paper becomes useless. Go pointers are tags. uintptr is the scrap of paper.

The compiler rejects arithmetic to keep the garbage collector sane.

Why the compiler stops you

Go pointers are typed. A *int points to an integer. A *byte points to a byte. The compiler knows the size of the underlying type. If you add 1 to a *int, should the address increase by 1 byte or by the size of an integer? In C, it increases by the size of the integer. Go refuses to guess. It also refuses to let you manipulate addresses directly because the runtime manages memory layout.

If you try to add an integer to a pointer, the compiler produces a clear error. The message is invalid operation: p + 1 (mismatched types *int and untyped int). Go does not define arithmetic operators for pointer types. This is a hard rule. You cannot overload operators or use macros to bypass it.

To perform pointer arithmetic, you must use the unsafe package. The unsafe package exposes uintptr, an integer type large enough to hold a pointer value. You can convert a pointer to uintptr, perform math, and convert back. This path is explicit. The compiler requires you to acknowledge that you are crossing a safety boundary.

The syntax for unsafe conversions is deliberately verbose. You must write uintptr(unsafe.Pointer(p)) instead of uintptr(p). The compiler rejects direct conversion with cannot convert p (variable of type *int) to type uintptr. This extra step acts as a speed bump. It forces you to acknowledge that you are converting a pointer to an integer. The community accepts the boilerplate because it makes unsafe operations visible in code reviews.

Minimal example: converting and adding

The code below shows how to advance a pointer by a byte offset. The function takes a pointer and an offset. It converts the pointer to uintptr, adds the offset, and converts the result back to unsafe.Pointer.

package main

import (
	"fmt"
	"unsafe"
)

// AdvancePointer moves a pointer forward by offset bytes.
// This is unsafe and should only be used when you control the memory layout.
func AdvancePointer(p unsafe.Pointer, offset uintptr) unsafe.Pointer {
	// Convert to uintptr to perform addition.
	// uintptr is an integer, so the GC does not track it.
	addr := uintptr(p) + offset

	// Convert back immediately.
	// The GC does not see uintptr. If the object moves while the address
	// sits in an integer variable, you get a dangling pointer.
	return unsafe.Pointer(addr)
}

func main() {
	var x int = 10
	p := &x

	// Get the address of x.
	// unsafe.Pointer is a generic pointer type.
	base := unsafe.Pointer(p)

	// Advance by 8 bytes.
	// This points past x. The result is garbage unless x is part of a larger struct.
	next := AdvancePointer(base, 8)

	fmt.Printf("Base: %p, Next: %p\n", base, next)
}

The function AdvancePointer demonstrates the pattern. The conversion to uintptr happens, the math happens, and the conversion back happens immediately. The variable addr holds the integer address. If a function call occurred between the addition and the return, the garbage collector or stack copier might move the underlying object. The integer would still hold the old address. The code avoids this by keeping the window of exposure minimal.

uintptr is an integer. The GC does not see it. Treat it like a live wire.

The stack copier trap

Go manages stack frames dynamically. When a function calls another function, the runtime might need to grow the stack. If the current stack is too small, the runtime allocates a larger stack and copies the data over. Pointers are updated automatically. Integers are not.

If you store a pointer address in a uintptr, the stack copier does not know it is a pointer. It leaves the integer alone. After the copy, the integer points to the old stack location. The data has moved. You now have a dangling pointer. This bug is hard to reproduce because it depends on stack pressure. It might work in tests and crash in production under load.

The code below illustrates the risk. The function captureAddress stores a uintptr in a global variable. If the stack grows after the capture, the global variable points to stale memory.

package main

import (
	"fmt"
	"unsafe"
)

var staleAddr uintptr

// CaptureAddress stores the address of a local variable in a global uintptr.
// This is dangerous because the stack may move the variable later.
func CaptureAddress(x int) {
	// Convert the local pointer to uintptr.
	// The stack copier will not update staleAddr if the stack moves.
	staleAddr = uintptr(unsafe.Pointer(&x))
}

func main() {
	x := 42
	CaptureAddress(x)

	// Force a stack growth by calling a function that uses a lot of stack space.
	// This is a contrived example to demonstrate the mechanism.
	forceStackGrowth()

	// Dereferencing staleAddr is undefined behavior.
	// The value at that address may be garbage or cause a crash.
	fmt.Printf("Stale address: %x\n", staleAddr)
}

// forceStackGrowth allocates a large array on the stack to trigger a copy.
func forceStackGrowth() {
	var buf [1 << 20]byte
	buf[0] = 1
}

The function CaptureAddress writes the address of x to staleAddr. The variable x lives on the stack. When forceStackGrowth runs, it allocates a large array. The runtime detects that the stack is full and copies the stack frame to a new location. The address of x changes. The value in staleAddr does not change. The program now holds a pointer to memory that no longer contains x. Dereferencing staleAddr leads to undefined behavior.

Stack copiers move data. Integers stay put. Dangling pointers follow.

Realistic example: parsing binary data

Real code uses pointer arithmetic for binary parsing or custom memory allocators. You might have a buffer of bytes and need to interpret chunks as different structs. Go 1.17 introduced unsafe.Slice, which creates a slice from a pointer and a length. This is safer than manual casting because the slice header tracks the length and capacity. You can use unsafe.Slice to treat a pointer as a slice of bytes or structs. This reduces the need for manual pointer arithmetic. You can index the slice normally. The compiler still checks bounds on the slice.

The code below shows how to parse a header from a byte slice using unsafe.Slice. The function checks the length, creates a slice of headers, and returns the first element. This avoids manual pointer math and casting.

package main

import (
	"fmt"
	"unsafe"
)

// Header represents a 16-byte binary header.
type Header struct {
	ID   uint32
	Size uint32
	Tag  uint32
	Seq  uint32
}

// ParseHeader extracts a Header from a byte slice.
// This assumes the Header struct has no padding and matches the binary layout exactly.
func ParseHeader(data []byte) *Header {
	// Check bounds to avoid reading past the slice.
	// The header is 16 bytes.
	if len(data) < 16 {
		return nil
	}

	// Create a slice of Header from the byte slice.
	// unsafe.Slice interprets the bytes as a sequence of Header structs.
	// The length is calculated by dividing the byte length by the header size.
	headers := unsafe.Slice((*Header)(unsafe.Pointer(&data[0])), len(data)/16)

	// Return the first header.
	// The slice bounds checking ensures we do not access invalid memory.
	return &headers[0]
}

func main() {
	// Simulate a binary buffer.
	data := []byte{
		0x01, 0x00, 0x00, 0x00, // ID
		0x10, 0x00, 0x00, 0x00, // Size
		0xAB, 0xCD, 0xEF, 0x00, // Tag
		0x05, 0x00, 0x00, 0x00, // Seq
	}

	header := ParseHeader(data)
	if header != nil {
		fmt.Printf("ID: %d, Size: %d, Tag: %d, Seq: %d\n", header.ID, header.Size, header.Tag, header.Seq)
	}
}

The function ParseHeader uses unsafe.Slice to create a slice of Header structs. The pointer to the first byte is cast to *Header. The length is computed based on the size of the header. The resulting slice allows normal indexing. If you try to access an index out of bounds, the runtime panics. This is safer than manual pointer arithmetic because the slice enforces bounds. The code also checks the length upfront to ensure there is enough data.

The community treats unsafe as a last resort. Code that uses unsafe requires extra scrutiny in code reviews. Document why unsafe is necessary. If you can use a slice or a standard library function instead, do it.

Pitfalls and alignment

Pointer arithmetic with unsafe introduces several risks. The compiler cannot check alignment. If you cast a pointer to a struct, the address must be aligned for that struct. If the address is misaligned, the program may crash at runtime with a segmentation fault. The compiler cannot check alignment when you use unsafe. You must ensure the address is a multiple of the struct's alignment requirement.

Another risk is endianness. Binary data may use little-endian or big-endian byte order. The unsafe cast interprets bytes according to the machine's native endianness. If the data format differs from the machine, the values will be wrong. Use the encoding/binary package for portable binary parsing. The package handles endianness explicitly.

The garbage collector also has limitations. If you store a pointer inside a struct field that is not a pointer type, the GC will not see it. For example, if you store a uintptr in a struct, the GC does not know it points to an object. The object may be collected while the struct still holds the address. Always use unsafe.Pointer for fields that hold pointers. Convert to uintptr only for temporary calculations.

The worst goroutine bug is the one that never logs. The most dangerous unsafe bug is the one that works until the GC runs.

When to use pointer arithmetic

Use a slice when you need to access a sequence of elements. Slices provide bounds checking and length tracking. They are the standard way to handle buffers in Go.

Use the encoding/binary package when you need to read or write standard binary formats. The package handles endianness and type conversion safely without pointer manipulation.

Use unsafe.Slice when you need to interpret a byte buffer as a slice of structs. This function creates a slice header from a pointer and length. It preserves bounds checking and reduces the need for manual casting.

Use unsafe pointer arithmetic when you are implementing a custom memory allocator or parsing a binary format that requires zero-copy access to structs. Only use this when performance profiling shows that allocation is a bottleneck and you can guarantee memory safety manually.

Use reflect when you need to inspect types or values dynamically. Reflection is slower than unsafe but it respects type safety and works with the garbage collector correctly.

Use slices for data. Use unsafe for escape velocity.

Where to go next