Go is a statically typed language, which means type safety is checked and enforced at compile time. The compiler makes sure you can’t mix up types or misuse values, that’s one of the reasons Go programs are so stable and reliable.
But Go also has a small, dangerous, and extremely powerful back door called theunsafe
package.
This package lets you step outside the type system and talk directly to memory, no safety checks, no compiler protection. It exists mainly for the Go runtime itself, but developers can use it too, at their own risk.
When you useunsafe
, you’re saying, “I know what I’m doing,” and if you’re wrong, the crash (or silent corruption) is on you. In return, you get access to things like raw pointers, custom memory layouts, and performance tricks that would otherwise be impossible in pure Go.
Similar concepts exist in other statically typed languages such as Java (via sun.misc.Unsafe
) and Rust (with its unsafe
blocks). They all serve the same purpose — to give developers a way to break safety rules when they absolutely need to, usually for performance or low-level operations like system calls and pointer manipulation.
By the end of this article, you’ll understand:
- Why the runtime needs the
unsafe
package - What the
unsafe
package really provides - How it allows you to reinterpret memory and manipulate addresses
- How to use it safely and what
uintptr
actually is - Where
unsafe
lives in Go’s source code and how it’s implemented - And finally, how to do some fun (and slightly dangerous 😄) things using unsafe
⚙️ Why the Runtime Needs Unsafe
Power
At first glance, Go looks like the kind of language that would never need something like unsafe
.It’s memory-safe, garbage-collected, and statically typed, three things that usually keep you far away from raw memory operations.
So why does the Go runtime rely so heavily on it?
The answer is simple: because safety itself requires control.
The runtime, the layer that manages goroutines, memory allocation, garbage collection, and scheduling, sits closer to the hardware than any other part of Go. It can’t build itself using only the safe abstractions that ordinary Go code depends on, because those abstractions depend on the runtime(classic chicken-and-egg problem😅).To create and manage the safety guarantees that the rest of the language enjoys, the runtime sometimes has to step outside those guarantees. That’s where unsafe
comes in.
Here are a few concrete examples:
Memory layout control – The runtime needs to know exactly how structs, slices, and objects are arranged in memory to implement the garbage collector and scheduler efficiently. Go’s regular reflection APIs aren’t low-level enough for that.
Direct pointer manipulation: When the runtime needs to link objects in memory (like connecting goroutine stacks or marking heap objects), it can’t afford the overhead or indirection of safe pointer conversions.
Interacting with CPU or OS boundaries: Certain operations, such as reading atomic counters or interacting with system-specific memory regions, require direct access to raw addresses.
Performance-critical code: In parts of the runtime that run constantly — like garbage collection or goroutine scheduling — every nanosecond matters. Eliminating even a small layer of abstraction can make a big difference.
In all these cases, unsafe acts as the bridge between Go’s managed world and the machine’s raw memory. It’s what allows the runtime to bend the rules temporarily so that those same rules can be enforced safely everywhere else.You can think of it like the runtime reaching beneath the language’s safety net to make sure the net itself doesn’t tear.
Of course, this power comes with serious responsibility.
When something goes wrong inside the runtime, a bad pointer conversion, a misaligned atomic operation, or an incorrect memory assumption, the entire Go process can crash instantly. That’s why unsafe and other internal packages are tightly restricted to the runtime and standard library. They exist to make Go possible, not for day-to-day application development.
But it’s important to note that the unsafe package isn’t hidden or private, it’s part of Go’s public standard library, and that’s intentional.
The Go team designed it this way so that developers could still use it in rare cases where direct memory access or type manipulation is absolutely necessary.
Many real-world projects already rely on unsafe for performance-critical or low-level work (as discussed in this research )
🧠 Core Functions and Types in the unsafe
Package
The unsafe
package is one of the smallest in the Go standard library, yet it’s also one of the most powerful and dangerous.
It doesn’t import anything, it doesn’t depend on the runtime, and it contains only a handful of functions and types. But those few tools give you direct access to the raw memory that Go normally protects you from.
🧩 The Core Types
There are only two key types in unsafe
:
-
unsafe.Pointer
: a special kind of pointer that can point to anything. It’s not tied to any specific type, and Go lets you convert between unsafe.Pointer and other pointer types freely.
Example:
var f float64 = 3.14
p := unsafe.Pointer(&f)
i := (*uint64)(p)
fmt.Println(*i)
Here, we’re reinterpreting the memory of a float64
as a uint64
, just reading the same bits differently.
-
uintptr
— an integer large enough to hold a pointer value(8 byte or 4 byte ...). It’s used when you need to perform arithmetic on addresses (for example, offsetting a pointer manually). But unlike unsafe.Pointer, a uintptr doesn’t keep the memory alive, the garbage collector treats it as a number, not a reference. So converting between them requires caution:
Look at this example:
var a int = 5
var aPtr = &a
var a int = 5
aPtr := uintptr(unsafe.Pointer(&a))
In the first case, the GC knows there’s a pointer to a
, so it won’t collect it, But in the second case, aPtr
is just an integer — a
can be garbage collected.
As the Go documentation warns, you should not store a uintptr
in a variable and use it later.
Instead, use it directly, like this:
p := unsafe.Pointer(uintptr(unsafe.Pointer(&arr[0])) + unsafe.Sizeof(arr[0])*i)
⚖️ The Utility Functions
unsafe
also includes a few key functions that reveal how Go’s runtime lays things out in memory:
Sizeof(x)
→ returns the size in bytes of the value x.Alignof(x)
→ tells you the alignment requirements of a variable.Offsetof(x)
→ gives you the byte offset of a struct field.
Together, these functions let you understand and control how Go arranges your data in memory.
New APIs in the newer Go versions:
-
Add(ptr, len)
→ adds len to the ptr and returns the updated ptr -
Slice(ptr, len)
→ returns a slice that underlying array starts at ptr and len and cap is the len argument -
SliceData(slice)
→ returns the ptr to the underlying array of the slice argument -
String(ptr, len)
→ returns a string that underlying bytes starts at ptr and length is the len argument -
StringData(string)
→ returns a ptr to the underlying bytes of the string argument
🕵️♂️ Where Does unsafe Actually Live?
If you explore the Go source code, you’ll find the unsafe
package in the root of the src
directory. Inside it, there’s only one file — unsafe.go
— and its content (as of Go 1.25.0) looks like this:
package unsafe
type ArbitraryType int
type IntegerType int
type Pointer *ArbitraryType
func Sizeof(x ArbitraryType) uintptr
func Offsetof(x ArbitraryType) uintptr
func Alignof(x ArbitraryType) uintptr
func Add(ptr Pointer, len IntegerType) Pointer
func Slice(ptr *ArbitraryType, len IntegerType) []ArbitraryType
func SliceData(slice []ArbitraryType) *ArbitraryType
func String(ptr *byte, len IntegerType) string
func StringData(str string) *byte
So… where are the implementations? 🤔
There’s no .s
(Go assembly) file in the unsafe directory, and no //go:linkname
directives either.
How does Go even make this work if there’s no implementation file or linker trick? (check here if you don't know what I am talking about)
The answer is: unsafe
is special to the compiler.
It has hardcoded logic that recognizes the unsafe
package and its functions.
When the compiler sees calls like unsafe.Sizeof()
, it doesn’t look for an implementation — it directly generates the corresponding code itself.
In other words, the compiler contains special-case logic such as:
“If this is unsafe.Sizeof, compute the size at compile time.”
“If this is unsafe.Pointer, treat it as a type conversion.”
These functions are compiler intrinsics — they don’t execute at runtime, they’re evaluated during compilation.
For example, inside src/cmd/compile/internal/typecheck/universe.go
, you can see how the unsafe
functions are registered alongside the built-in functions (len
, recover
, new
, panic
, etc.) and other predeclared names in Go (_
, true
, false
, nil
, and so on).
It is done by calling the function, called NewBuiltin
(for the functions), which is documented as:
// NewBuiltin returns a new Name representing a builtin function,
// either predeclared or from package unsafe.
So the compiler knows exactly what each of these functions means — not because they’re implemented in Go or assembly, but because they’re baked directly into the compiler itself.
For more information about it, you may check:
cmd/compile/internal/ssagen/ssa.go
cmd/compile/internal/typecheck/universe.go
cmd/compile/internal/ir/name.go
😈 Fun with unsafe
: Dangerous but Cool
I love playing with unsafe
. It helps me understand Go’s memory model better — and honestly, it’s just fun. Sometimes it even leads to real-world performance optimizations that are impossible with normal Go code.
The unsafe
package is fascinating in every language, but I especially enjoy how Rust handles it — its unsafe
blocks let you do things like memory unions
, where two variables can share the exact same memory space(like C/C++), something that is not possible in Go as Dave Cheney explained, but unsafe
still gives us plenty of ways to peek behind the curtain.
Some fun and useful experiments you can try:
- Access the fields of a struct by manually moving the Pointer
Explore built-in Go types and peek inside their internals (like inspecting the buckets of a map)
Or some common optimizations like convert
string
tobytes
, which normally cause allocation, but withunsafe
, you can do it without triggering any allocations
Let's see some examples:
1- Accessing the fields of a struct with unsafe
type Car struct {
Name string
year int
}
func main() {
c := Car{
Name: "BMW",
year: 2023,
}
cPTR := unsafe.Pointer(&c)
name := *(*string)(cPTR)
fmt.Println("Name:", name)
year := *(*int)(unsafe.Add(cPTR, unsafe.Offsetof(Car{}.year)))
fmt.Println("Year:", year)
*(*int)(unsafe.Add(cPTR, unsafe.Offsetof(Car{}.year))) = 2024
fmt.Println("updated car", c)
}
Notes:
- In
name := *(*string)(cPTR)
we are saying that thecPTR
is actually*string
and then dereference it with*
, so it is not something really hard, it is just a simple type cast forunsafe.Pointer
. -
Structs
in Go are nothing but their fields, so when we take theunsafe.Pointer
of the struct, we are already pointing at the first field. - Each type has a size, read here, but for structs, it’s safer to use
unsafe.Offsetof()
because of potential padding. - In this example, we could skip
unsafe.Offsetof()
since the layout is predictable on a 64-bit system: thestring
takes 16 bytes (two machine words), and theint
takes 8 bytes, so there’s no padding - meaning we could do with just 16 instead ofunsafe.Offsetof(Car{}.year)
.
2- Explore the builtin Go types
y := map[string]string{
"s": "s",
"rr": "rr",
}
yPTR := (*unsafe.Pointer)(unsafe.Pointer(&y))
count := *(*int64)(*yPTR)
flag := *(*uint8)(unsafe.Add(*yPTR, unsafe.Offsetof(hmap{}.flags)))
B := *(*uint8)(unsafe.Add(*yPTR, unsafe.Offsetof(hmap{}.B)))
bucketsPTR := *(*unsafe.Pointer)(unsafe.Add(*yPTR, unsafe.Offsetof(hmap{}.buckets)))
oldBucketsPTR := *(*unsafe.Pointer)(unsafe.Add(*yPTR, unsafe.Offsetof(hmap{}.oldbuckets)))
fmt.Println("flags:", flag)
fmt.Println("B:", B)
fmt.Println("buckets:", bucketsPTR)
fmt.Println("oldbuckets:", oldBucketsPTR)
fmt.Println("count:", count)
key := unsafe.Add(bucketsPTR, 8)
value := unsafe.Add(bucketsPTR, 24)
key2 := unsafe.Add(bucketsPTR, 40)
value2 := unsafe.Add(bucketsPTR, 56)
*(*string)(value2) = "changed"
fmt.Println(*(*string)(key), *(*string)(value))
fmt.Println(*(*string)(key2), *(*string)(value2))
A little simplified version of hmap
looks like this:
type hmap struct {
count int
flags uint8
B uint8
noverflow uint16
hash0 uint32
buckets unsafe.Pointer
oldbuckets unsafe.Pointer
nevacuate uintptr
clearSeq uint64
}
Notes
- We are exploring Maps here (no_swiss).
- Maps are pointers to the
hmap
struct, so in the lineyPTR := (*unsafe.Pointer)(unsafe.Pointer(&y))
we are telling that theyPTR
is actually a pointer to theunsafe.Pointer
, because they
is a pointer itself and the&y
is something like a pointer-to-pointer. - In line
count := *(*int64)(*yPTR)
we are already at the field count. - We move until we get to the
buckets
where the data is actually stored. - Each Bucket has a
tophash
that helps it search faster (it filters candidate buckets before comparing keys), thetophash
is[8]uint8
, so to reach the first key we shouldkey := unsafe.Add(bucketsPTR, 8)
. - Each
string
is 16bytes, so for the first value we should move (8+16)24bytes and same for others. - As you see, we can also edit the map, and we can also do other funny things like accessing the old buckets to see the overflows and more, maybe change the map to the threshold and see what is going on in the buckets, discussed about it here
I suggest you do the same things with the channels
, there you can access the goroutines and many more fun things.
3- A common simple optimization is to convert string
to bytes
, which has one allocation normally
stringTest := "hello"
stringTestByte := *(*[]byte)(unsafe.Pointer(&stringTest))
fmt.Println(stringTestByte) // Same output as the next line
fmt.Println([]byte(stringTest)) // But this version allocates
As explained here, strings
are pointers to underlying bytes
+ len
, so when we take the unsafe.Pointer
of the string, in the StringHeader
struct we are actually pointing at the first field, which is the underlying bytes
( here you can move 8 more and get the len
, I know you are the best at doing these now 😉)
The unsafe
package is both powerful and risky — a bit like playing with fire 🔥. It’s great for learning, debugging, squeezing out performance, and some low-level codes — but keep it out of production code unless you really know what you’re doing.
Just remember: with unsafe
, you’re responsible for safety now.
Top comments (0)