Go unsafe: When to Use It, and Why It’s Dangerous
Grace Collins
Solutions Engineer · Leapcell

Go's unsafe Package: The "Double-Edged Sword" That Breaks Type Safety—Do You Really Know How to Use It?
In the world of Go, "type safety" is a core feature emphasized repeatedly—the compiler acts like a strict doorman, preventing you from force-converting an int
pointer to a string
pointer and forbidding arbitrary modifications to a slice’s underlying capacity. However, there is one package that deliberately "challenges the rules": unsafe.
Many Go developers feel a mix of curiosity and awe toward unsafe
: they’ve heard it can drastically boost code performance, yet also cause programs to crash unexpectedly in production; they know it can bypass language restrictions, but remain unclear on the underlying principles. Today, we’ll lift the veil on unsafe
completely—from its principles to practical use cases, from risks to best practices—to help you master this "dangerous yet fascinating" tool.
I. First, Understand: The Core Principles of the unsafe Package
Before diving into unsafe
, we must clarify a fundamental premise: Go’s type safety is essentially a "compile-time constraint." When code runs, the binary data in memory has no inherent "type"—int64
and float64
are both 8-byte memory blocks; the only difference lies in how the compiler interprets them. The role of unsafe
is to bypass compile-time type checks and directly manipulate how these memory blocks are "interpreted."
1.1 Two Core Types: unsafe.Pointer vs. uintptr
The unsafe
package itself is extremely small, with only two core types and three functions. The most critical of these are unsafe.Pointer
and uintptr
—they form the foundation of understanding unsafe
. Let’s start with a comparison table:
Feature | unsafe.Pointer | uintptr |
---|---|---|
Type Nature | Universal pointer type | Unsigned integer type |
GC Tracking | Yes (pointed object managed by GC) | No (only stores address value) |
Arithmetic Support | Not supported | Supported (± offsets) |
Core Purpose | "Transfer station" for pointers of different types | Memory address calculation |
Safety Risk | Lower (if rules are followed) | Higher (prone to "loss of reference") |
In simple terms:
unsafe.Pointer
is a "legitimate wild pointer": it holds a memory address and is tracked by the GC, ensuring the pointed object is not accidentally reclaimed.uintptr
is a "pure number": it merely stores a memory address as an integer, and the GC ignores it entirely—this is also the most common pitfall when usingunsafe
.
Here’s a concrete example:
package main import ( "fmt" "unsafe" ) func main() { // 1. Define an int variable x := 100 fmt.Printf("x's address: %p, value: %d\n", &x, x) // 0xc0000a6058, 100 // 2. Convert *int to unsafe.Pointer (legal transfer) p := unsafe.Pointer(&x) // 3. Convert unsafe.Pointer to *float64 (bypass type check) fPtr := (*float64)(p) *fPtr = 3.14159 // Directly modify the int variable's memory to a float64 value // 4. Convert unsafe.Pointer to uintptr (only stores address as a number) addr := uintptr(p) fmt.Printf("addr's type: %T, value: %#x\n", addr, addr) // uintptr, 0xc0000a6058 // 5. x's memory has been modified—interpretation varies by type fmt.Printf("Reinterpret x: %d\n", x) // 1074340345 (binary result of float64 → int) fmt.Printf("Interpret via fPtr: %f\n", *fPtr) // 3.141590 }
In this code:
unsafe.Pointer
acts like a "translator," enabling conversions between*int
and*float64
.uintptr
only stores the address as a number—it cannot directly point to an object nor is it protected by the GC.
1.2 The Four Core Capabilities of unsafe (Must Remember)
The official Go documentation explicitly defines 4 legal uses of unsafe.Pointer
. These are your "safety red lines" when using unsafe
—any operation beyond these bounds is undefined behavior (it may work today but crash after a Go version upgrade):
- Convert pointers of any type: e.g.,
*int
→unsafe.Pointer
→*string
. This is the most common use case, directly breaking type constraints. - Convert to/from uintptr: Arithmetic operations on memory addresses (e.g., offset calculations) are only possible via
uintptr
. - Compare with nil:
unsafe.Pointer
can be compared tonil
to check for a null address (e.g.,if p == nil { ... }
). - Serve as a map key: Though rarely used,
unsafe.Pointer
supports being amap
key (since it is comparable).
A critical note on point 2: uintptr must be used "immediately". Because uintptr
is not tracked by the GC, if you store it and later convert it back to unsafe.Pointer
, the memory it points to may have already been reclaimed by the GC—this is the most common mistake for beginners.
1.3 Underlying Support: Go’s Memory Layout Rules
unsafe
works because Go’s memory layout follows fixed rules. Whether it’s a struct
, slice
, or interface
, their in-memory structures are deterministic. Mastering these rules allows you to precisely manipulate memory with unsafe
.
(1) Memory Alignment of struct
Struct fields are not packed tightly; instead, "padding bytes" are added according to an "alignment coefficient" to improve CPU access efficiency. For example:
type SmallStruct struct { a bool // 1 byte b int64 // 8 bytes } // Calculate memory size: 1 + 7 (padding) + 8 = 16 bytes fmt.Println(unsafe.Sizeof(SmallStruct{})) // 16 // Calculate offset of field b: 1 (size of a) + 7 (padding) = 8 fmt.Println(unsafe.Offsetof(SmallStruct{}.b)) // 8
If we reorder the fields, memory usage does not halve (contrary to intuition):
type CompactStruct struct { b int64 // 8 bytes a bool // 1 byte } // Is it 8 + 1 = 9? No—alignment coefficient is 8, so padding is added to 16 bytes. fmt.Println(unsafe.Sizeof(CompactStruct{})) // 16
Go’s alignment rules:
- The offset of each field must be an integer multiple of the field’s type size.
- The total size of the struct must be an integer multiple of the largest field’s type size.
For smaller types:
type TinyStruct struct { a bool // 1 byte b bool // 1 byte } // Size is 2 (largest field is 1 byte; 2 is an integer multiple of 1, no padding needed) fmt.Println(unsafe.Sizeof(TinyStruct{})) // 2
unsafe.Offsetof
and unsafe.Sizeof
are tools to get struct field offsets and type sizes—never hardcode offsets (e.g., directly writing 8
or 16
). Cross-platform differences (32-bit/64-bit) or Go version upgrades may change memory layouts.
(2) Memory Structure of slice
A slice is a "wrapper" consisting of a pointer to an underlying array, plus two int
values (len
and cap
). Its memory structure can be represented by a struct:
type sliceHeader struct { Data unsafe.Pointer // Pointer to the underlying array Len int // Slice length Cap int // Slice capacity }
This is why unsafe
can directly modify a slice’s len
and cap
:
package main import ( "fmt" "unsafe" ) func main() { s := []int{1, 2, 3} fmt.Printf("Original slice: %v, len: %d, cap: %d\n", s, len(s), cap(s)) // [1 2 3], 3, 3 // 1. Convert the slice to a sliceHeader header := (*struct { Data unsafe.Pointer Len int Cap int })(unsafe.Pointer(&s)) // 2. Directly modify len and cap (requires sufficient underlying array space) header.Len = 5 // Dangerous! Accessing s[3] or s[4] will cause out-of-bounds if the array is too small header.Cap = 5 // 3. The slice's len and cap are now modified fmt.Printf("Modified slice: %v, len: %d, cap: %d\n", s, len(s), cap(s)) // [1 2 3 0 0], 5, 5 // Note: s[3] and s[4] are uninitialized memory in the underlying array (zero value for int is 0) }
The risk here is clear: if the underlying array’s actual length is smaller than the Len
you set, accessing elements beyond the original array will trigger a memory out-of-bounds error—one of the most dangerous scenarios for unsafe
, with no compiler warnings.
(3) Memory Structure of interface
Interfaces in Go fall into two categories: empty interfaces (interface{}
) and non-empty interfaces (e.g., io.Reader
). Their memory structures differ:
- Empty interface (
emptyInterface
): Contains type information (_type
) and a value pointer (data
). - Non-empty interface (
nonEmptyInterface
): Contains type information, a value pointer, and a method table (itab
).
unsafe
can parse the underlying data of an interface:
package main import ( "fmt" "unsafe" ) type MyInterface interface { Do() } type MyStruct struct { Name string } func (m MyStruct) Do() {} func main() { // Non-empty interface example var mi MyInterface = MyStruct{Name: "test"} // Parse non-empty interface structure: itab (method table) + data (value pointer) type nonEmptyInterface struct { itab unsafe.Pointer data unsafe.Pointer } ni := (*nonEmptyInterface)(unsafe.Pointer(&mi)) // Parse MyStruct pointed to by data ms := (*MyStruct)(ni.data) fmt.Println(ms.Name) // test // Empty interface example var ei interface{} = 100 type emptyInterface struct { typ unsafe.Pointer data unsafe.Pointer } eiPtr := (*emptyInterface)(unsafe.Pointer(&ei)) // Parse the int value pointed to by data num := (*int)(eiPtr.data) fmt.Println(*num) // 100 }
While this approach bypasses reflection (reflect
) to directly access interface values, it is extremely risky—if the interface’s actual type does not match the type you parse, the program will crash immediately.
II. When Should You Use unsafe? 6 Typical Scenarios
Now that we understand the principles, let’s explore practical applications. unsafe
is not a "silver bullet" but a "scalpel"—it should only be used when you explicitly need performance optimization or low-level operations, and no safe alternatives exist. Below are 6 of the most common legal use cases:
2.1 Scenario 1: Binary Parsing/Serialization (50%+ Performance Boost)
When parsing network protocols or file formats (e.g., TCP headers, binary logs), the encoding/binary
package requires field-by-field reading, resulting in low performance. unsafe
allows direct conversion of []byte
to a struct
, skipping the parsing process.
For example, parsing a simplified TCP header (ignoring endianness for now to focus on memory conversion):
package main import ( "fmt" "unsafe" ) // TCPHeader Simplified TCP header structure type TCPHeader struct { SrcPort uint16 // Source port (2 bytes) DstPort uint16 // Destination port (2 bytes) SeqNum uint32 // Sequence number (4 bytes) AckNum uint32 // Acknowledgment number (4 bytes) DataOff uint8 // Data offset (1 byte) Flags uint8 // Flags (1 byte) Window uint16 // Window size (2 bytes) Checksum uint16 // Checksum (2 bytes) Urgent uint16 // Urgent pointer (2 bytes) } func main() { // Simulate binary TCP header data read from the network (16 bytes total) data := []byte{ 0x12, 0x34, // SrcPort: 4660 0x56, 0x78, // DstPort: 22136 0x00, 0x00, 0x00, 0x01, // SeqNum: 1 0x00, 0x00, 0x00, 0x02, // AckNum: 2 0x50, // DataOff: 8 (simplified to 1 byte) 0x02, // Flags: SYN 0x00, 0x0A, // Window: 10 0x00, 0x00, // Checksum: 0 0x00, 0x00, // Urgent: 0 } // Safe approach: Parse with encoding/binary (field-by-field reading) // var header TCPHeader // err := binary.Read(bytes.NewReader(data), binary.BigEndian, &header) // if err != nil { ... } // Unsafe approach: Direct conversion (no copying, no parsing) // Precondition 1: data length >= sizeof(TCPHeader) (16 bytes) // Precondition 2: Struct memory layout matches binary data (note alignment and endianness) if len(data) < int(unsafe.Sizeof(TCPHeader{})) { panic("data too short") } header := (*TCPHeader)(unsafe.Pointer(&data[0])) // Access fields directly fmt.Printf("Source Port: %d\n", header.SrcPort) // 4660 fmt.Printf("Destination Port: %d\n", header.DstPort) // 22136 fmt.Printf("Sequence Number: %d\n", header.SeqNum) // 1 fmt.Printf("Flags: %d\n", header.Flags) // 2 (SYN) }
Performance Comparison: Parsing TCPHeader
1 million times with encoding/binary
takes ~120ms; direct conversion with unsafe
takes ~40ms—a 3x performance boost. However, two preconditions must be met:
- The binary data length must be at least the size of the struct to avoid memory out-of-bounds.
- Handle endianness (e.g., network byte order is big-endian, while x86 uses little-endian—byte order conversion is required, otherwise field values will be incorrect).
2.2 Scenario 2: Zero-Copy Conversion Between string and []byte (Avoid Memory Waste)
string
and []byte
are the most commonly used types in Go, but conversions between them ([]byte(s)
or string(b)
) trigger memory copying. For large strings (e.g., 10MB logs), this copying wastes memory and CPU.
unsafe
enables zero-copy conversion because their underlying structures are highly similar:
string
:struct { data unsafe.Pointer; len int }
[]byte
:struct { data unsafe.Pointer; len int; cap int }
Implementation of zero-copy conversion:
package main import ( "fmt" "unsafe" ) // StringToBytes Converts string to []byte (zero-copy) func StringToBytes(s string) []byte { // 1. Parse the string's header strHeader := (*struct { Data unsafe.Pointer Len int })(unsafe.Pointer(&s)) // 2. Construct the slice's header sliceHeader := struct { Data unsafe.Pointer Len int Cap int }{ Data: strHeader.Data, Len: strHeader.Len, Cap: strHeader.Len, // Cap equals Len to prevent modifying the underlying array during slice expansion } // 3. Convert to []byte and return return *(*[]byte)(unsafe.Pointer(&sliceHeader)) } // BytesToString Converts []byte to string (zero-copy) func BytesToString(b []byte) string { // 1. Parse the slice's header sliceHeader := (*struct { Data unsafe.Pointer Len int Cap int })(unsafe.Pointer(&b)) // 2. Construct the string's header strHeader := struct { Data unsafe.Pointer Len int }{ Data: sliceHeader.Data, Len: sliceHeader.Len, } // 3. Convert to string and return return *(*string)(unsafe.Pointer(&strHeader)) } func main() { // Test string → []byte s := "hello, unsafe!" b := StringToBytes(s) fmt.Printf("b: %s, len: %d\n", b, len(b)) // hello, unsafe!, 13 // Test []byte → string b2 := []byte("go is awesome") s2 := BytesToString(b2) fmt.Printf("s2: %s, len: %d\n", s2, len(s2)) // go is awesome, 12 // Risk Warning: Modifying b will alter s (violates string immutability) b[0] = 'H' fmt.Println(s) // Hello, unsafe! (Undefined behavior—may vary across Go versions) }
Risk Warning: This scenario has a critical flaw—string
is immutable in Go. If you modify the converted []byte
, you directly alter the underlying array of the string
, breaking Go’s language contract and potentially causing unpredictable bugs (e.g., if multiple strings share the same underlying array, modifying one affects all).
Best Practice: Use this only for "read-only" scenarios (e.g., converting a large string
to []byte
to pass to a function requiring a []byte
parameter without modifying it). If modification is needed, use explicit copying with []byte(s)
.
V. Conclusion: A Rational View of unsafe
By now, you should have a comprehensive understanding of unsafe
: it is neither a "monster" nor a "performance" (magic tool), but a low-level utility requiring careful use.
Three final takeaways:
-
unsafe
is a "scalpel," not a "Swiss Army knife": Use it only for explicit low-level operations with no safe alternatives—never as a routine tool. -
Understanding principles is prerequisite to safe use: If you don’t grasp the difference between
unsafe.Pointer
anduintptr
, or Go’s memory layout, avoidunsafe
. -
Safety always trumps performance: In most cases, Go’s safe APIs are performant enough. If
unsafe
is necessary, ensure proper encapsulation, testing, and risk control.
If you’ve used unsafe
in projects, feel free to share your use cases and pitfalls! If you have questions, leave them in the comments.
Leapcell: The Best of Serverless Web Hosting
Finally, I recommend the best platform for deploying Go services: Leapcell
🚀 Build with Your Favorite Language
Develop effortlessly in JavaScript, Python, Go, or Rust.
🌍 Deploy Unlimited Projects for Free
Only pay for what you use—no requests, no charges.
⚡ Pay-as-You-Go, No Hidden Costs
No idle fees, just seamless scalability.
🔹 Follow us on Twitter: @LeapcellHQ