How do string and []byte conversions work, and why can they be a performance concern?
Quick Answer
Go strings are immutable byte sequences, while []byte is mutable, so converting between them ([]byte(s) or string(b)) requires the runtime to copy the underlying bytes — you can't share memory between a string and a byte slice safely, since a byte slice's contents could change out from under a string that's supposed to be immutable. This copy is O(n) in the length of the data, so converting a large string to []byte (or back) repeatedly, especially in a hot loop, is a real, measurable cost. The compiler optimizes a few specific patterns to avoid the copy, most notably []byte(s) used directly as a map key lookup or in a for range over the string, but general-purpose conversions always copy.
Detailed Answer
The immutability of string is exactly why converting to and from []byte isn't free.
Why a copy is required
s := "hello"
b := []byte(s) // copies s's bytes into a new, mutable byte slice
b[0] = 'H'
fmt.Println(s) // still "hello" — s was never touched, because b is an independent copy
If []byte(s) shared memory with s instead of copying, mutating b would silently break s's immutability guarantee, which every other piece of code relying on strings being immutable depends on.
Where this becomes a real performance cost
func buildMessage(parts []string) string {
var buf []byte
for _, p := range parts {
buf = append(buf, []byte(p)...) // converts and copies on every iteration
}
return string(buf) // another copy, back to string
}
Doing this in a hot loop, especially with many parts or large strings, adds real allocation and copying overhead. The idiomatic fix is strings.Builder, which avoids intermediate []byte conversions entirely:
func buildMessage(parts []string) string {
var b strings.Builder
for _, p := range parts {
b.WriteString(p) // no conversion, writes directly into an internal buffer
}
return b.String()
}
The one place the compiler skips the copy
m := map[string]int{"key": 1}
b := []byte("key")
v := m[string(b)] // compiler special-cases this: no allocation for the conversion
The compiler recognizes string(b) used directly as a map key lookup and avoids the allocation, since the converted string never needs to outlive the lookup itself. This optimization is narrow and specific — general conversions elsewhere in your code still copy.
The practical guidance
Avoid unnecessary string/[]byte round-trips in hot paths. Prefer strings.Builder or bytes.Buffer for building up data incrementally, and reach for the standard library's optimized helpers (like strconv functions) instead of manual conversions where possible.