Strings are immutable byte sequences
You are parsing a configuration file and find a typo in a key name. You write configKey[3] = 'p' to fix it. The compiler rejects the line. You are used to languages where strings are mutable arrays of characters, or at least flexible objects. In Go, strings are stone. You cannot modify a string in place. You cannot change a character, trim a character from the end, or append a character without creating a new string value.
This immutability is a feature, not a limitation. It makes strings safe to share across goroutines without locks. It allows the compiler to optimize string handling aggressively. It also means your code must be explicit about when data is copied. When you need to change text, you copy it to a mutable buffer, make the changes, and create a new string.
The header under the hood
A Go string is not just the text. It is a small struct containing a pointer to the underlying bytes and the length of the sequence. The type looks like this in memory:
type stringHeader struct {
Data uintptr
Len int
}
The Data field points to a read-only array of bytes. The Len field tells you how many bytes are valid. When you assign a string to a variable, you are copying this header, not the data. When you pass a string to a function, you copy the header. This makes passing strings extremely cheap: it is just two machine words, regardless of whether the string is one character or one megabyte.
Because the data is read-only, multiple string headers can point to the same underlying bytes safely. If two goroutines hold different string variables that reference the same data, neither can corrupt the other's view. The compiler also interns small string literals, meaning identical literals in your code often share the same memory.
Convention aside: strings are cheap to pass by value. You will sometimes see code that passes *string to avoid copying. This is almost always a mistake. Passing a pointer adds a layer of indirection, risks nil pointer panics, and provides no performance benefit because the string header is already small. Pass strings by value.
Converting to and from byte slices
To modify text, you must convert the string to a []byte slice. This conversion allocates a new slice and copies all the bytes from the string. You can then mutate the slice freely. When you are done, you convert the slice back to a string, which allocates a new string and copies the bytes again.
Here is the standard pattern for mutating a string:
package main
import "fmt"
func main() {
// String literals are immutable.
s := "hello"
// Converting to []byte allocates a new slice and copies the data.
// This is required because strings cannot be modified in place.
b := []byte(s)
// The slice is mutable. We can change individual bytes.
b[0] = 'H'
// Converting back to string allocates a new string and copies the slice.
// The original string s remains unchanged.
newS := string(b)
fmt.Println(newS)
}
The compiler enforces immutability strictly. If you try to assign to a string index, you get an error. The compiler rejects s[0] = 'H' with cannot assign to s[0]. This error message is direct: the left-hand side is not assignable. You cannot bypass this without using unsafe pointers, which breaks memory safety guarantees and should be avoided in normal code.
The conversion copies data for a reason. If string(b) did not copy, modifying the slice b would also modify the string, violating immutability. Go guarantees that a string value never changes after creation. This guarantee allows the runtime to optimize string storage and enables safe sharing.
Building strings efficiently
Concatenating strings with += works, but it has a hidden cost. Each concatenation creates a new string, copies the existing content, appends the new content, and discards the old string. If you build a string in a loop, the cost grows quadratically. A loop that appends 10,000 characters copies the growing buffer 10,000 times, resulting in roughly 50 million byte copies.
Use strings.Builder to construct strings from multiple parts. Builder maintains an internal buffer that grows as needed. It writes directly into the buffer without creating intermediate strings. When you are finished, you call String() to get the result.
Here is a realistic example of building a log entry:
package main
import (
"fmt"
"strings"
)
// BuildLogMessage creates a formatted log line.
// Using strings.Builder avoids repeated allocations during construction.
func BuildLogMessage(level, source, msg string) string {
// Builder grows a buffer internally.
// WriteString appends to the buffer without copying previous content.
var b strings.Builder
b.WriteString("[")
b.WriteString(level)
b.WriteString("] ")
b.WriteString(source)
b.WriteString(": ")
b.WriteString(msg)
// String() returns the final string, copying the buffer once.
return b.String()
}
func main() {
log := BuildLogMessage("ERROR", "disk-monitor", "usage exceeds 90%")
fmt.Println(log)
}
strings.Builder is the standard tool for string assembly. It is used internally by fmt.Sprintf and io.WriteString. When you see a loop that builds a string, reach for Builder. For one-off concatenations of a few values, += is fine and more readable. The compiler can even optimize simple concatenation chains, but it cannot optimize loops.
Convention aside: gofmt handles the formatting of your code. When you write the Builder calls, gofmt will align the arguments and structure the code consistently. Trust the tool. Do not argue about indentation or spacing; let gofmt decide. Focus your energy on logic, not formatting.
UTF-8, bytes, and runes
Go strings are UTF-8 encoded byte sequences. UTF-8 is a variable-width encoding. ASCII characters use one byte. Characters from other scripts use two, three, or four bytes. This means the length of a string in bytes is not the same as the number of characters.
Indexing a string returns a byte, not a character. If you have a string with accented characters or emojis, s[0] gives you the first byte of the first character. If the character is multi-byte, s[0] is just a fragment. Accessing s[1] might give you the second byte of the same character, not the second character.
package main
import "fmt"
func main() {
// The string contains a multi-byte character.
s := "café"
// len returns the number of bytes, not characters.
// 'é' is two bytes in UTF-8, so the length is 5.
fmt.Println(len(s))
// Indexing returns bytes.
// s[3] is the first byte of 'é', not the character itself.
fmt.Printf("byte at index 3: %d\n", s[3])
// To iterate over characters, use range.
// Range decodes UTF-8 and yields runes (Unicode code points).
for i, r := range s {
fmt.Printf("index %d, rune %c\n", i, r)
}
}
The range loop over a string decodes the UTF-8 sequence and yields rune values. A rune is an alias for int32 and represents a Unicode code point. The loop variable i is the byte index, and r is the character. This is the safe way to process text character by character.
If you need random access to characters by index, convert the string to a []rune slice. This allocates a slice of integers and decodes all characters upfront. It is expensive for large strings but convenient when you need to slice or modify by character index.
Pitfall: treating len(s) as the character count leads to bugs. If you use len(s) to allocate a buffer for characters, you will allocate too little space for non-ASCII text. If you use s[len(s)-1] to get the last character, you might get a trailing byte of a multi-byte character. Always use range or utf8.RuneCountInString when you care about characters.
Pitfalls and compiler errors
Strings in Go have a few traps that catch developers new to the language. The compiler helps with some, but runtime panics can occur with others.
The most common error is trying to mutate a string. The compiler catches this immediately. You get cannot assign to s[0] or cannot take the address of s[0]. These errors force you to convert to a slice if mutation is needed.
Another error occurs when you try to use a string where a slice is expected. Go does not implicitly convert between string and []byte. If a function expects []byte and you pass a string, the compiler rejects it with cannot use s (variable of type string) as []byte value in argument. You must write the conversion explicitly: []byte(s). This explicitness makes the allocation cost visible in the code.
Runtime panics happen when you misuse byte indexing. If you calculate a substring using character counts but slice using byte indices, you might split a multi-byte character in half. The resulting string is invalid UTF-8. Functions that expect valid UTF-8 may panic or produce garbage output. Always slice strings at byte boundaries that align with character boundaries, or use []rune for character-based slicing.
Convention aside: error handling in Go is verbose by design. When you work with strings that come from external sources, you often validate them. The pattern if err != nil { return err } is standard. The community accepts the boilerplate because it makes the unhappy path visible. Do not hide errors or use panic for validation failures. Return the error and let the caller decide.
Decision matrix
Strings, byte slices, builders, and rune slices serve different purposes. Pick the right tool based on your needs.
Use a string when you need a read-only text value that is safe to share across goroutines and cheap to pass to functions. Strings are the default choice for text data in Go.
Use a []byte slice when you need to mutate the content in place or interface with binary data and I/O operations. Byte slices are mutable and integrate with the io package.
Use strings.Builder when you are constructing a string from many parts to avoid quadratic allocation costs. Builder is the efficient way to assemble text in loops or complex formatting.
Use a []rune slice when you need to access or modify individual Unicode characters by index, accepting the overhead of UTF-8 decoding. Rune slices are convenient for character-level manipulation but expensive for large strings.
Strings are immutable. Mutate the slice, not the string. Indexing gives bytes, not characters. Range over runes. Pass strings by value. Builder for building. Concatenation for one-offs.