There’s a moment in every senior Go engineer’s career when you stare at a CPU flamegraph, see a hotspot you can’t optimize anymore, and your brain whispers:
“Maybe… maybe I should try unsafe?”
Then your rational side immediately screams:
“NO! YOU’RE GOING TO SUMMON DEMONS!”
And honestly? Both voices are right.
unsafe is one of the most misunderstood tools in Go. It's neither evil nor magical. It's just… dangerous. Like a chainsaw. If you know exactly what you're doing, you can cut through impossible performance bottlenecks. If not — you can chop off your system’s stability and memory safety.
This article is about how to use unsafe properly in production Go services — where it can help, when it should be avoided, and what patterns actually make sense.
1. Why Unsafe Exists (And Why It's Not Actually “Illegal”)
Many languages have an escape hatch:
- Rust:
unsafe - Java: sun.misc.Unsafe
- C#: pointers
- C/C++: raw memory
In Go, unsafe lets you:
- circumvent the type system
- reinterpret memory
- manipulate pointers
- access memory layout directly
This makes it possible to:
- eliminate allocations
- reuse buffers
- perform zero-copy conversions
- manually pack/unpack data structures
- implement extremely fast serializers
- interoperate with C
And that’s the key:
unsafe trades safety for raw, unfiltered performance.
2. The Three Core Tools of Unsafe
Go exposes three extremely powerful weapons:
1. unsafe.Pointer
A pointer to arbitrary memory.
This is the gateway to everything dangerous.
2. uintptr
An integer representation of an address.
Used for pointer arithmetic.
3. Sizeof / Alignof / Offsetof
Used to inspect memory layout of structs.
Everything else is a combination of these primitives.
3. When Unsafe Becomes a Real Production Tool
Let’s be honest: 95% of Go developers should never touch unsafe.
But in high-performance systems, unsafe becomes a viable, even necessary tool.
Unsafe is justified when:
- performance bottleneck is memory copying
- you need zero-copy serialization
- converting []byte <-> string is too expensive
- reflection code is too slow
- high-throughput systems require tight memory control
- marshaling/unmarshaling is killing p95 latency
- you must interoperate with a C library
- GC overhead is too high for an object type
Unsafe is never “first choice”.
It’s the last resort that saves the day.
4. The Most Useful Unsafe Pattern: Zero-Copy Conversions
The classic example:
string → []byte without allocation
func StringToBytes(s string) []byte {
return unsafe.Slice(unsafe.StringData(s), len(s))
}
And the opposite:
[]byte → string without allocation
func BytesToString(b []byte) string {
return unsafe.String(&b[0], len(b))
}
Why this matters:
- standard conversion makes a copy (O(n))
- unsafe version is O(1)
- zero allocations
- zero garbage
This is a HUGE win if you process thousands of payloads per second.
5. The Second Killer Pattern: Manual Memory Layout Access
Example: reading a struct header directly.
type Header struct {
Size uint32
Flags uint16
TypeId uint16
}
func HeaderFromBytes(buf []byte) *Header {
return (*Header)(unsafe.Pointer(&buf[0]))
}
That’s:
- zero-copy
- instant reinterpretation
- highly performant
Works great in binary protocols, network frames, and log formats.
6. The Third Killer Pattern: Struct Packing
If you need tight memory packing — unsafe lets you control padding.
Example:
type Packed struct {
A uint8
B uint8
C uint16
}
You can inspect:
unsafe.Sizeof(Packed{})
unsafe.Alignof(Packed{})
Then rearrange fields to minimize struct size.
In highload systems processing millions of objects, even a 4–8 byte reduction per object matters.
7. Where Unsafe Goes Wrong (And How People Blow Up Services)
Most unsafe disasters fall into five categories.
1. Using zero-copy string <-> []byte on mutable data
If you do:
s := BytesToString(buf)
buf[0] = 'X'
You just mutated the string.
Strings are supposed to be immutable.
Your service is now haunted.
2. Pointer arithmetic without bounds checking
ptr := uintptr(unsafe.Pointer(&arr[0]))
ptr += 1000 // oops
Go will not stop you.
You’re now reading memory you don't own.
3. Breaking GC invariants
Go’s garbage collector expects:
- object graphs to be valid
- pointer references to be safe
Unsafe code can create “dangling pointers”, causing:
- crashes
- memory corruption
- random behavior
4. Using unsafe across Go versions
Go does NOT guarantee:
- struct field alignment
- padding
- memory layout consistency
- internal representation of strings/slices
Using unsafe ties your code to a specific Go version.
5. Mixing unsafe with reflection
Reflection + unsafe = instant footgun.
The type system has no idea what you’re doing.
8. How to Use Unsafe Safely (Yes, This Is Possible)
Here is the safety checklist we use in production.
Rule 1 — Keep unsafe in isolated helper functions
Never put unsafe logic inline.
Always hide it behind a well-named function like:
BytesToStringUnsafeZeroCopyMarshalParseHeaderFromBytes
This lets you:
- audit unsafe code
- minimize exposure
- add tests
- wrap with comments
- prevent accidental misuse
Rule 2 — Document every unsafe function
Every unsafe block must have a comment:
- why unsafe is needed
- what invariants must be true
- what callers must guarantee
- what memory ownership looks like
Future you will thank you.
Rule 3 — NEVER use unsafe on mutable data without copying
Zero-copy conversions are safe only when data is immutable.
Rule 4 — Benchmark before and after
Unsafe is a surgical tool.
If unsafe doesn’t help, don’t use it.
Rule 5 — Fuzz test everything around unsafe
Unsafe code is extremely sensitive to unexpected inputs.
Fuzz tests help expose edge cases quickly.
Rule 6 — Avoid relying on memory layout unless absolutely required
Prefer:
- code-generated serializers
- binary encoding libraries
- protobuf/flatbuffers
Unsafe struct reinterpretation should be a last resort.
9. Real Example: Unsafe in a Production Go Service
We had an event ingestion service doing:
- 120k messages/sec
- each 600–900 bytes
- heavy JSON unmarshaling
- constant []byte <-> string conversions
We replaced:
string(b)
with the unsafe version.
Results:
- CPU usage dropped ~17%
- Allocations dropped 40%
- GC pauses reduced noticeably
- p95 went from 8.2ms → 5.4ms
- p99 became much more stable
No other optimization delivered as much for so little code change.
10. When Unsafe Is the RIGHT Choice
Unsafe is appropriate when:
- performance is critical
- data is immutable
- pointers won’t outlive the buffer
- memory ownership is clear
- you’re working with binary protocols
- you’ve proven reflection is too slow
- you need zero-copy transformations
Unsafe is not appropriate when:
- you don’t understand Go’s memory model deeply
- the service is not performance-critical
- correctness matters more than speed
- the team maintaining the code is junior-heavy
- you can use a binary format instead
11. Key Takeaways (You MUST Know These)
- Unsafe is not evil — it’s dangerous.
- It enables optimizations impossible with pure Go.
- It bypasses safety guarantees of the language.
- If used wrong, it destroys stability.
- If used right, it delivers extreme performance wins.
- Always isolate unsafe code and document invariants.
- Benchmark before and after.
Unsafe is a powerful tool — but only if you respect it.
12. If You Want to Go Deeper
These Educative courses shaped how I think about performance and low-level Go behavior:
Worth it if you’re pushing Go systems to their limits.
Top comments (0)