GoProgrammingSenior Go Backend Developer

Distinguish the memory allocation behavior when converting between **strings** and **byte slices** in **Go**, specifically contrasting the mandatory copying in one direction with the zero-copy possibilities in the other.

Pass interviews with Hintsage AI assistant

Answer to the question

Go enforces strict immutability for strings to guarantee they remain safe for concurrent use and valid as map keys. When converting a string to a []byte, the runtime must allocate a new array and copy all bytes, since the resulting slice must be mutable without corrupting the original immutable data. Conversely, while the standard conversion from []byte to string also creates a copy to preserve immutability, the unsafe package enables zero-copy conversion by creating a string header pointing directly to the slice's underlying array. This operation avoids allocation but requires the developer to ensure the slice is never modified afterward, as Go assumes strings are read-only throughout their lifetime.

Situation from life

We developed a high-frequency trading gateway that parsed FIX protocol messages arriving as strings from the network layer, then needed to serialize specific fields into []byte buffers for downstream checksum calculation and transmission. Profiling revealed that 35% of CPU time was consumed by runtime.makeslicecopy during the conversion hot path, causing microsecond-level pauses unacceptable in trading.

First solution considered: We attempted to use sync.Pool to reuse []byte buffers and manually copy string contents using the copy builtin. While this reduced pressure on the garbage collector, the overhead of clearing buffers between uses and the synchronization cost of the pool itself introduced cache contention. The pros included better memory reuse, but the cons were increased latency variance and complexity in ensuring buffers were returned to the pool exactly once.

Second solution considered: We evaluated keeping all data as []byte from ingestion to processing, eliminating conversions entirely. However, this required refactoring external parsing libraries that returned strings, creating maintenance burden and risk of introducing encoding bugs. It also complicated string comparison logic that relied on standard library optimizations.

Chosen solution: We isolated the critical path where strings were converted to []byte for hashing, and replaced the standard conversion with a carefully audited unsafe operation: b := *(*[]byte)(unsafe.Pointer(&s)) using reflect.SliceHeader constructed from reflect.StringHeader. We guaranteed immutability by ensuring the data originated from read-only network buffers. This eliminated allocations in the hot path, reduced GC cycles by 80%, and dropped P99 latency from 45μs to 3μs, passing regulatory latency requirements.

What candidates often miss


Why does mutating a byte slice created via standard []byte(s) conversion not affect the original string, yet modifying the original slice after an unsafe conversion to string cause undefined behavior?

The standard conversion b := []byte(s) allocates a distinct memory region and copies the bytes, so the new slice points to different physical memory than the immutable string storage. However, an unsafe conversion creates a string header that shares the exact same underlying array pointer as the slice. If the slice is modified after conversion (b[0] = 'X'), the string (which the language guarantees is immutable) will observe the change. This violates Go's fundamental invariants, potentially corrupting hash maps where the string is used as a key—since Go caches hash values assuming immutability—or causing security vulnerabilities if the string represents cryptographic material.


How does the Go compiler optimize map lookups using byte-to-string conversion m[string(b)] to avoid heap allocation, and what specific constraints trigger this optimization?

When a byte slice is converted to a string solely as a map lookup key (e.g., val := m[string(b)]), the compiler performs a special escape analysis that recognizes the string is temporary and does not escape the lookup context. Instead of allocating a new string header on the heap and copying data, the compiler generates code that computes the hash directly from the slice's underlying array and compares against map entries. This optimization fails immediately if the conversion result is assigned to a variable (key := string(b); val := m[key]), stored in a struct field, or passed to a function that might retain the reference, forcing a full heap allocation and data copy.


What is the precise memory layout relationship between reflect.StringHeader and reflect.SliceHeader, and why does the garbage collector's treatment of these headers make unsafe string-from-slice conversions perilous during stack growth?

Both headers in Go's runtime consist of a pointer to data and a length field (and capacity for slices), sharing identical memory layouts for the first two words. However, reflect.StringHeader implies the pointed-to memory is immutable and potentially shared across the program (e.g., string constants in the binary's rodata section), while SliceHeader tracks mutable capacity. When using unsafe to cast a []byte to string, the string header points to the slice's underlying array. If the slice is stack-allocated and must move during goroutine stack growth, the runtime updates the slice's pointer but has no knowledge of the unsafe-created string header pointing to the old location. This leaves the string pointing to stale or unmapped memory, potentially causing segmentation faults or data corruption when accessed.