ZDE

Breaking the Glass: A Deep Dive into Go's Unsafe Package (Part 1)

December 16, 2025 · #Go · #Unsafe · #Memory · #Performance

Part 1 of the unsafe-package series.

Go is famous for being a “nanny” language. It has a Garbage Collector (GC) that cleans up your mess, a strict type system that prevents you from adding apples to oranges, and boundary checks that stop you from reading memory you don’t own.

But sometimes, the nanny is too slow. Or maybe you need to talk to the kernel, or parse a massive binary file without copying data.

In those moments, you have to break the glass. You have to import unsafe.

This is Part 1 of a deep dive into Go’s most dangerous package. Today, we aren’t doing any math. We are just going to meet the characters and understand the single most important rule that separates a working program from a random segfault: Garbage Collector Visibility.

The Three Musketeers (of Memory)

To understand unsafe, you need to understand the three distinct ways Go lets you talk about memory.

1. The Good Citizen: *T

This is your standard pointer (*int, *string, *MyStruct).

2. The Shapeshifter: unsafe.Pointer

This is Go’s version of void* in C.

3. The Mask: uintptr

This is where people get hurt. uintptr is an integer type (like uint or int), but it’s large enough to hold a memory address (64 bits on a 64-bit machine).

The “Invisible” Object Problem

Here is the mental model you must adopt: The Garbage Collector is a graph walker.

When the GC wakes up, it starts at your “roots” (global variables, stack frames) and walks down every pointer it finds, marking objects as “Alive.”

The Code That Crashes

Imagine you have a struct called User. You want to get the memory address of that user to do some cool math later.

// ❌ THIS IS A BUG
func main() {
    u := &User{Name: "Alice"}

    // 1. Convert to unsafe.Pointer, then to uintptr (just a number)
    addr := uintptr(unsafe.Pointer(u))

    // ... lots of other code happens here ...
    // The GC runs here. It sees 'u' is no longer used.
    // It looks at 'addr', but 'addr' is just a number, not a pointer.
    // The GC deletes the User object at 0x123456 to free up memory.

    // 2. Convert back to pointer
    ptr := (*User)(unsafe.Pointer(addr))
    
    // BOOM. 'ptr' now points to garbage memory.
    fmt.Println(ptr.Name) 
}

In the example above, between step 1 and step 2, the variable u is no longer needed by the program. The only thing “holding onto” that memory address is addr. But addr is a uintptr. The GC ignores it. The object gets deleted. When you cast it back in step 2, you are pointing at dead memory.

The Golden Rule of Conversion

Because of this GC behavior, there is a strict set of rules for converting between these types.

  1. ***T unsafe.Pointer**: Safe(ish). You are just changing the type, but the GC still tracks the object.
  2. **unsafe.Pointer uintptr**: DANGEROUS.

You are allowed to convert unsafe.Pointer to uintptr, do math, and convert back to unsafe.Pointer, but it must happen in a single step.

Go recognizes this specific pattern and treats it as an atomic operation, ensuring the GC doesn’t sweep the object away in the middle of your math.

// ✅ THIS IS SAFE
// The compiler knows not to run GC between the conversion and the addition(this is a simplified statement)
ptr := unsafe.Pointer(uintptr(unsafe.Pointer(u)) + offset)

Summary

Now that we know the characters and the danger of the “Invisible Object,” we are ready to do what we actually came here for: Pointer Arithmetic.

In Part 2, we will learn how to use uintptr to iterate over arrays, access struct fields without field names, and modify private memory.

← All posts