Why You Should Almost Never Use unsafe in Go

Avoid using the unsafe package in Go to prevent memory corruption and security risks, reserving it only for critical performance optimizations where no safe alternative exists.

The zero-allocation trap

You are profiling a hot path in your service. The memory allocator is showing up as a bottleneck. You find a blog post from 2018 that shows how to convert a byte slice to a string without copying. The benchmark claims zero allocations. You copy the code. It uses unsafe.

You run your tests. They pass. You run the code locally. It works. You deploy to production. Under load, your logs start printing garbage characters. Or worse, the program crashes with a segmentation fault that points to a line of code that looks perfectly innocent. You didn't touch the memory. The garbage collector did.

Go is designed to be safe. The compiler checks types. The runtime checks bounds. The garbage collector tracks pointers. The unsafe package exists to break all of those rules. It gives you direct access to memory addresses and type layouts. It also removes the safety net. When you use unsafe, you are telling the compiler and the runtime to stop protecting you.

What unsafe actually does

Go's type system is strict. You cannot cast an int to a string. You cannot take the address of a function. You cannot perform pointer arithmetic. These restrictions exist to prevent entire classes of bugs. Buffer overflows, use-after-free errors, and type confusion attacks are rare in Go because the language makes them impossible to write in safe code.

The unsafe package bypasses these checks. It provides a type called unsafe.Pointer that can point to any memory address. It allows you to cast between any two pointer types. It lets you treat a byte slice as a string, or a struct as an array of bytes.

Think of Go's safety features like a building inspector. The inspector checks every load-bearing wall before you move in. Using unsafe is like telling the inspector to look the other way while you weld a steel beam to a drywall partition. The wall might hold. It might even hold for a long time. But the moment someone leans on it, or the building settles, or the inspector comes back for a surprise check, the structure fails.

The danger is not just that your code crashes. The danger is that your code corrupts memory silently. A string might contain data from a previous request. A map might overwrite its own keys. These bugs are non-deterministic. They depend on memory layout, allocation timing, and garbage collection cycles. They are nearly impossible to reproduce in a test environment.

The pointer cast footgun

The most common reason developers reach for unsafe is to avoid copying data. Strings in Go are immutable. When you convert a []byte to a string, the standard library copies the bytes to a new backing array. This ensures the string cannot be modified. If you have a large buffer and you do this conversion in a tight loop, you generate a lot of garbage.

The temptation is to skip the copy. You can cast the slice header to a string header using unsafe.

Here is the pattern that looks tempting but breaks the runtime.

import "unsafe"

// BadBytesToString converts a slice to a string without copying.
// This is unsafe because the string may alias mutable memory.
func BadBytesToString(b []byte) string {
    // Cast the slice header to a string header via pointer.
    // The slice and string have the same memory layout:
    // a pointer to data and a length.
    // This bypasses the compiler's check that strings are immutable.
    return *(*string)(unsafe.Pointer(&b))
}

This code compiles. It runs. It returns a string that points to the same memory as the slice. The problem is that the Go runtime assumes strings are immutable. The garbage collector uses this assumption to optimize. If the GC sees a string, it knows the data inside will not change. It might move the string's backing array, or it might decide the array is dead and free it.

When you create a string from a mutable slice using unsafe, you create an alias. The slice is mutable. The string is supposed to be immutable. The runtime does not know about this alias. If the slice is modified, the string changes too. If the slice is discarded, the string might point to freed memory.

The compiler rejects this pattern if you try to do it without unsafe. You get an error like cannot convert b (variable of type []byte) to type string. The compiler is saving you from yourself.

The uintptr trap

The most subtle bug in unsafe code involves uintptr. unsafe.Pointer is a typed pointer. The garbage collector tracks it. If you hold a unsafe.Pointer to an object, the GC knows the object is alive.

uintptr is just an integer. It holds a memory address. The GC does not track it. If you convert a unsafe.Pointer to a uintptr, store it in a variable, and then the GC runs, the object might move or be freed. Your uintptr now points to garbage.

This pattern is a silent killer.

import "unsafe"

// BadPointerToInt converts a pointer to an integer and back.
// This loses GC tracking and causes use-after-free bugs.
func BadPointerToInt(p *int) int {
    // Convert pointer to integer.
    // The GC no longer tracks this address.
    addr := uintptr(unsafe.Pointer(p))

    // Simulate work that might trigger a GC.
    // If the GC runs here, p might be moved or freed.
    _ = make([]byte, 1024*1024)

    // Convert back to pointer.
    // This pointer is likely invalid.
    recovered := (*int)(unsafe.Pointer(addr))

    return *recovered
}

The code above crashes with signal SIGSEGV: segmentation fault or runtime error: invalid memory address or nil pointer dereference. The crash happens because the GC moved the integer p to a new location on the heap, or freed it entirely, while the uintptr variable still held the old address.

The rule is strict. Never store a uintptr across a call that might allocate or trigger a GC. Keep the conversion to uintptr and back to unsafe.Pointer as close together as possible. If you need to store an address, keep it as a unsafe.Pointer.

When unsafe is acceptable

You should almost never use unsafe. The standard library provides safe alternatives for almost every use case. strings.Builder handles concatenation efficiently. bytes.Buffer handles binary data. The encoding packages handle serialization.

There are rare cases where unsafe is necessary. These cases usually involve interoperability with C, or implementing low-level libraries where allocation overhead is unacceptable.

If you must use unsafe, follow these rules.

Isolate the code. Wrap unsafe operations in small, well-tested functions. Do not scatter unsafe casts throughout your application. Keep the unsafe surface area minimal.

Verify the lifetime. Ensure that the memory you are pointing to stays alive for as long as you need it. If you are converting a slice to a string, ensure the slice is not modified or discarded while the string is in use.

Profile first. Do not use unsafe because you think it will be faster. Use it only after profiling shows that the safe version is a bottleneck, and benchmarking proves that the unsafe version is actually faster on your target hardware. The compiler and runtime are highly optimized. unsafe code often ends up being slower due to cache misses or GC pressure.

Here is a realistic example of using unsafe to interface with a C library. This is one of the few valid use cases.

import (
    "unsafe"
    "C"
)

// CFunc is a placeholder for a C function that expects a raw pointer.
// In real code, this would be declared with //go:cgo_export_static or similar.
// For this example, we assume a C function that reads a buffer.
//go:cgo_import_static C.some_c_function
var C.some_c_function uintptr

// CallCFunc calls a C function with a byte slice.
// We pass the slice's data pointer directly to avoid copying.
func CallCFunc(data []byte) {
    // Get the pointer to the slice's backing array.
    // This is safe as long as data is not nil.
    ptr := unsafe.Pointer(unsafe.SliceData(data))

    // Pass the pointer and length to the C function.
    // The C function must not hold the pointer after this call returns.
    // If it does, we must ensure data stays alive.
    _ = ptr
    _ = len(data)
}

The code above uses unsafe.SliceData (available in Go 1.20+) to get a pointer to the slice's backing array. This is safer than the old pointer cast trick because it explicitly expresses the intent. The comment emphasizes the lifetime constraint. The C function must not store the pointer. If it does, the Go slice might be GC'd or moved, and the C code will crash.

Pitfalls and compiler errors

The compiler does not catch unsafe bugs. It only checks that you are using the unsafe package correctly. If you forget to import unsafe, you get undefined: unsafe. If you try to cast incompatible types without unsafe, you get cannot convert ... to type ....

The runtime errors are worse.

Use-after-free errors manifest as signal SIGSEGV: segmentation fault. The program tries to read or write memory that has been freed. The crash is immediate and fatal.

Data races manifest as silent corruption. If two goroutines access the same memory through unsafe pointers without synchronization, you get a data race. The race detector might catch it, but it often misses unsafe races because the race detector relies on the compiler's instrumentation, which unsafe bypasses.

String mutation is a common source of subtle bugs. If you cast a string to []byte and modify it, you corrupt the string cache. Other parts of your program might hold references to the same string. They will see the modified data. This breaks the assumption of immutability and leads to logic errors that are impossible to trace.

The compiler error loop variable i captured by func literal is unrelated to unsafe, but it highlights Go's strictness. Go 1.22+ fixed loop variable capture to prevent bugs. unsafe code often relies on old assumptions about memory layout and variable capture. Be careful when mixing unsafe with closures and goroutines.

Decision matrix

Use unsafe when you are writing a low-level library that interfaces with C and cannot afford the allocation overhead of C.GoString.

Use unsafe when you need to inspect memory layout for serialization and the standard library does not provide a way.

Use unsafe when you have profiled your application, identified a specific allocation as the bottleneck, and verified that the unsafe version is actually faster on your target hardware.

Use standard library functions when you are building application logic.

Use strings.Builder when you are concatenating strings in a loop.

Use bytes.Buffer when you are manipulating binary data.

Use encoding/json or encoding/binary when you are serializing data.

Where to go next

Unsafe code is a maintenance tax. Every line of unsafe code requires more testing, more documentation, and more careful review. It breaks the promise of Go's safety. It makes your code harder to read and harder to change.

If you are not writing a low-level library, you do not need unsafe. The standard library is fast enough. The garbage collector is smart enough. Trust the tooling.