The zero-copy string problem
You are building a high-performance parser for a binary protocol. Every packet arrives as a []byte. Your logic needs to extract fields as string values to match against a dictionary or route to a handler. The naive approach converts each field with string(data). Profiling shows 40% of CPU time spent in runtime.makeslice and runtime.memmove. You are copying memory you do not need to copy. The garbage collector wakes up just to clean up temporary allocations created to satisfy the type system.
You need a way to tell Go, "Trust me, this byte slice is a string, and I promise not to mutate it." You want to reinterpret the memory without paying the allocation tax. That is what unsafe.String and unsafe.Slice do. They let you bridge the gap between mutable slices and immutable strings by sharing the underlying buffer.
Reinterpreting memory safely
Go draws a hard line between []byte and string. A slice is mutable; a string is immutable. This distinction lets the compiler optimize string handling and prevents accidental mutation. The type system enforces this boundary. Converting a slice to a string normally copies the data so the new string can be immutable even if the original slice changes.
Sometimes you know more than the compiler. You have a buffer of bytes that represents text, and you want to treat it as a string without copying. unsafe.String and unsafe.Slice bridge that gap. They do not convert data. They change the lens through which Go looks at the same bytes.
Think of a dual-sided card. One side says "Mutable Data," the other says "Read-Only Text." The card itself does not change. You just flip it over and agree to read only the text side. If you try to write on the text side, you break the contract. unsafe functions let you flip the card. They also let you write on the text side if you are reckless, which is why the package is named unsafe.
Minimal example
Here is the minimal pattern. You start with a byte slice, create a string view, and then create a slice view back. The code demonstrates the zero-copy nature and the shared memory risk.
package main
import (
"fmt"
"unsafe"
)
func main() {
// Source data lives on the stack or heap depending on escape analysis.
data := []byte("hello world")
// unsafe.String creates a string header pointing to the same memory as data.
// No allocation occurs. The string is a view, not a copy.
s := unsafe.String(unsafe.SliceData(data), len(data))
// unsafe.Slice creates a slice header pointing to the same memory.
// The slice is mutable, so you must ensure the underlying data allows mutation.
b := unsafe.Slice(unsafe.StringData(s), len(s))
// Modifying the slice modifies the underlying bytes.
// This changes the string view too, which violates string immutability guarantees.
b[0] = 'H'
// The string reflects the mutation because they share memory.
fmt.Println(s) // Output: Hello world
}
The code uses unsafe.SliceData to extract the raw pointer from a slice and unsafe.StringData to extract the raw pointer from a string. These helper functions return *byte. unsafe.String takes a pointer and a length to construct a string. unsafe.Slice takes a pointer and a length to construct a slice.
How the headers work
Under the hood, a Go string is a two-word header. It contains a pointer to the data and an integer for the length. A slice header is similar but adds a capacity field. unsafe.String constructs this header manually. It takes a pointer and a length and packs them into a string value. The result is a string that points to the memory address you provided.
The garbage collector treats this string like any other string. It sees the pointer in the header and marks the underlying memory as reachable. If the original byte slice is discarded, the memory stays alive as long as the string exists. This is the safety net. The string keeps the data alive. However, the string does not keep the slice header alive. It only keeps the data buffer alive. If you hold a string created from a slice, and the slice variable goes out of scope, the data remains valid.
unsafe.Slice works similarly. It builds a slice header with the pointer and length you provide. By default, it sets the capacity equal to the length. If you need a different capacity, you can use the three-argument form unsafe.Slice(ptr, len, cap). This is useful when you have a large buffer and want to create a slice view that respects the buffer's total capacity.
Convention aside: unsafe code requires discipline. The community expects unsafe operations to be wrapped in small, well-documented functions. Do not scatter unsafe calls across a large codebase. Isolate the unsafe logic so it can be audited. The compiler does not check unsafe operations for safety. It assumes you know what you are doing. If you make a mistake, the compiler will not warn you.
Realistic zero-copy parsing
A realistic use case is zero-copy parsing in a network server. You receive a chunk of data from a connection. You want to extract headers as strings to pass to a router, but you do not want to allocate for every header. The headers are short-lived, but the volume is high.
package main
import (
"fmt"
"unsafe"
)
// ParseHeader extracts a key-value pair from raw bytes without allocation.
// It assumes the input is valid UTF-8 and does not validate content.
func ParseHeader(raw []byte) (key, value string) {
// Find the colon separator.
// In production, use bytes.IndexByte for performance.
colon := -1
for i, b := range raw {
if b == ':' {
colon = i
break
}
}
if colon < 0 {
return "", ""
}
// Create string views for key and value.
// These strings share memory with raw.
// If raw is modified later, these strings become corrupted.
key = unsafe.String(unsafe.SliceData(raw), colon)
value = unsafe.String(unsafe.SliceData(raw[colon+1:]), len(raw)-colon-1)
return key, value
}
func main() {
// Simulate a network buffer.
buf := []byte("Content-Type: application/json")
// Extract headers without copying.
k, v := ParseHeader(buf)
fmt.Printf("Key: %s, Value: %s\n", k, v)
}
The ParseHeader function returns strings that point directly into buf. No allocation happens during parsing. The strings are valid as long as buf is valid and not mutated. If buf is reused for the next packet, k and v become garbage. You must ensure the lifetime of the strings is shorter than the lifetime of the buffer.
Convention aside: Public names start with a capital letter. Private names start lowercase. ParseHeader is public because it is exported. If this function were internal to a package, it would be parseHeader. The receiver name convention does not apply here since there are no methods, but if you added a method to a parser type, the receiver would be (p *Parser), not (this *Parser).
Pitfalls and silent corruption
unsafe code bypasses the type system. The compiler will not save you. The biggest risk is lifetime. If you create a string from a stack-allocated byte slice and return the string, the string points to stack memory that gets reused.
func bad() string {
b := []byte("stack data")
return unsafe.String(unsafe.SliceData(b), len(b))
}
This function returns a string pointing to stack memory. Once bad returns, that stack frame is gone. The string is dangling. Accessing it reads garbage or crashes. The compiler might catch this with escape analysis in some cases, but unsafe suppresses many checks. If the slice escapes to the heap, the code is safe. If it stays on the stack, it is unsafe. You cannot rely on the compiler to warn you.
Mutation is another trap. Strings are immutable. If you create a string via unsafe, and then mutate the underlying bytes, you break the invariant. Code that relies on string immutability will break silently. For example, if you use the string as a map key, and then mutate the underlying buffer, the map entry becomes corrupted. The key in the map points to the same memory. The hash changes. Lookups fail.
The compiler rejects direct string mutation with cannot assign to string, but unsafe lets you bypass this. If you have a slice and a string pointing to the same buffer, modifying the slice modifies the string. This is a data race in disguise. Two goroutines might read the string while a third mutates the slice. The result is undefined behavior.
Error handling: unsafe.String does not check for nil pointers. If you pass a nil pointer to unsafe.String with a non-zero length, you create a string pointing to nil. Accessing the string causes a panic with runtime error: invalid memory address or nil pointer dereference. The compiler does not check nil. You must validate pointers manually.
unsafe.String also does not check UTF-8 validity. If you pass binary data, you get a string that is not valid UTF-8. Functions like strings.Contains might behave unexpectedly. The string is just bytes, but higher-level code might assume text. This leads to logical corruption.
The worst unsafe bug is the one that never logs. Corruption happens silently. Data looks wrong, but the program does not crash. Debugging requires tracing memory lifetimes and mutation paths.
Unsafe is a contract, not a feature. You hold the liability.
When to use unsafe
Use string(data) when you need a safe, independent copy of the data. The copy protects you from lifetime issues and mutation. The allocation cost is the price of safety.
Use unsafe.String when you have a large byte buffer and need to pass substrings to APIs that require strings, and you can guarantee the buffer will not be mutated or freed while the string is in use. This is common in zero-copy parsers and high-throughput protocols.
Use unsafe.Slice when you have a string or array and need a mutable slice view for performance-critical processing, ensuring the data allows mutation. This is useful for in-place transformations where allocation is prohibited.
Use unsafe.StringData or unsafe.SliceData when you need the raw pointer for FFI calls or low-level memory manipulation. These functions extract the pointer from the header without creating a new view.
Use bytes.Equal or bytes.Compare when comparing data, avoiding string conversion entirely if possible. Comparing bytes is often faster and safer than converting to strings.
Safety has a cost. Performance has a cost. Pick the cost you can manage.