The String Immutability Gotcha in Go

Go strings are immutable, so you must convert them to byte slices to modify their contents.

The String Immutability Gotcha in Go

You write a function to sanitize a log line. You loop over the string, replacing sensitive tokens with asterisks. You reach for s[i] = '*' and the compiler yells at you. You've hit the immutability wall. In Python or JavaScript, strings often behave like mutable sequences in your head, or at least allow easy modification. In Go, strings are immutable by design. The bytes inside a string never change. This is not a suggestion. The compiler enforces it, and the runtime relies on it for safety and performance.

Think of a Go string like a printed receipt. You can read the total, pass the receipt to someone else, or use it as a key in a drawer. You cannot cross out the total and write a new number. If you need a different total, you print a new receipt. The old one stays exactly as it was. This guarantee lets the runtime share string data across goroutines without locks. It also means every "modification" is actually a copy.

Under the hood, a string is a header containing a pointer to the data and a length. The compiler hides this structure, but it's always there. When you pass a string to a function, you copy the header, not the data. This makes passing strings cheap. The data itself is read-only.

Convention aside: never pass a *string. Strings are cheap to pass by value. A pointer to a string adds indirection without saving memory. The only time you need a pointer is when you must distinguish between "not set" and "empty string", and even then, a custom type is usually better. The community treats *string as a code smell.

The minimal mutation pattern

Here's the simplest attempt to mutate a string. The compiler rejects the direct assignment and shows the correct path.

package main

import "fmt"

func main() {
    s := "hello"
    
    // Direct mutation fails. The compiler enforces immutability.
    // s[0] = 'H' // Error: cannot assign to s[0]
    
    // Convert to []byte to get a mutable copy.
    b := []byte(s)
    b[0] = 'H'
    
    // Convert back to string. This creates a new immutable string.
    fmt.Println(string(b))
}

The compiler rejects s[0] = 'H' with cannot assign to s[0]. The fix is to convert to a byte slice, modify, and convert back. This works, but it allocates memory twice. []byte(s) copies the data. string(b) copies it again. For a single character change, you've paid the price of two allocations.

Strings are receipts. Print a new one to change the total.

What happens at runtime

When you call []byte(s), the runtime allocates a new slice on the heap, copies every byte from the string into the slice, and returns the slice. You modify the slice. Then string(b) allocates a new string header, copies the bytes from the slice into a new read-only buffer, and returns the string. The original string remains untouched. The garbage collector has to clean up the intermediate slice and the new string data.

This copy-on-modify behavior is the cost of immutability. It keeps data safe, but it can hurt performance if you modify strings repeatedly. If you are building a string from many parts, repeated concatenation or conversion creates a chain of allocations. Each step copies all the data accumulated so far. The time complexity grows quadratically with the number of parts.

Building strings efficiently

Here's a realistic example of constructing a string. Using strings.Builder avoids the repeated allocations that plague naive concatenation.

package main

import (
    "fmt"
    "strings"
)

// BuildQuery constructs a URL query string from a map.
// strings.Builder accumulates parts without copying until the end.
func BuildQuery(params map[string]string) string {
    var b strings.Builder
    // Pre-allocate space to reduce resizing overhead.
    // Estimate based on map size and average key/value length.
    b.Grow(len(params) * 20)
    
    first := true
    for k, v := range params {
        if !first {
            b.WriteByte('&')
        }
        b.WriteString(k)
        b.WriteByte('=')
        b.WriteString(v)
        first = false
    }
    
    // .String() creates the final immutable string from the buffer.
    return b.String()
}

func main() {
    params := map[string]string{
        "q":   "golang",
        "page": "1",
    }
    fmt.Println(BuildQuery(params))
}

strings.Builder holds a mutable byte slice internally. It grows the slice as needed and only allocates the final string when you call .String(). This reduces allocations to a single copy at the end. The Grow method is optional but helpful. It tells the builder to reserve space upfront, avoiding small incremental allocations as the buffer expands.

Convention aside: the community prefers strings.Builder over bytes.Buffer for string construction. bytes.Buffer works, but strings.Builder is optimized for the string use case. It avoids an extra conversion step at the end and has a cleaner API for string operations. Use strings.Builder when you are building strings. Use bytes.Buffer when you are building arbitrary binary data.

Mutation is allocation. Count the copies before you optimize.

UTF-8 and the byte trap

Strings in Go are UTF-8 encoded. A character is not always one byte. An emoji or accented character can take two, three, or four bytes. If you index a string with s[0], you get a byte, not a character. Modifying that byte can break the UTF-8 sequence.

If you break the encoding, functions like fmt.Println might print replacement characters or panic. The compiler won't stop you from indexing bytes. You have to handle UTF-8 yourself. To work with characters, convert to []rune. This decodes the UTF-8 and gives you a slice of Unicode code points. Modifying a rune slice is safe for character-level operations, but it still allocates.

package main

import "fmt"

func main() {
    s := "café"
    
    // s[3] is the first byte of 'é', not the character itself.
    // Modifying it breaks the UTF-8 encoding.
    // b := []byte(s)
    // b[3] = 'x' // Creates invalid UTF-8
    
    // Convert to []rune to work with characters.
    runes := []rune(s)
    runes[3] = 'é' // Safe character replacement
    
    fmt.Println(string(runes))
}

The []rune conversion allocates a slice and decodes the string. Each rune takes four bytes in memory, regardless of the original encoding. This is fine for small strings or character manipulation, but it uses more memory than the original UTF-8 bytes. If you need to iterate over characters without modification, use a range loop. It yields runes directly without allocating a slice.

UTF-8 is bytes. Convert to runes before you index.

The unsafe conversion trap

Go 1.20 introduced unsafe.String and unsafe.Slice. These functions convert between strings and byte slices without copying. This is powerful for performance, but it breaks the immutability guarantee if you are not careful.

If you convert a []byte to a string using unsafe.String, the string points to the same memory as the slice. If you modify the slice later, the string changes. This is undefined behavior. The runtime assumes strings are immutable. It might cache the string data or share it. Mutating it can crash your program or corrupt data in other goroutines.

Only use unsafe.String when you can prove the underlying bytes will never change. If the bytes come from a buffer that gets reused, do not use unsafe.String. The compiler won't warn you. The error will manifest as a random crash or data corruption hours later.

The worst goroutine bug is the one that never logs.

When to use what

Use a string when you need a read-only sequence of bytes, especially for keys in maps or identifiers. Use []byte when you need to modify the data in place or pass it to a function that expects a mutable buffer. Use strings.Builder when you are constructing a string from multiple parts in a loop or function. Use []rune when you need to iterate over or modify individual characters in a string that contains non-ASCII text. Use unsafe.String only when you have a performance-critical path and can guarantee the underlying bytes will remain frozen for the lifetime of the string.

Trust the compiler. It refuses mutation to keep your data safe.

Where to go next