How do string and []byte conversions work, and why can they be a performance concern?

5 minadvancedgostringsbyte-slicesperformance

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.