A string in Go is an immutable, read-only slice of bytes, while a []byte is a mutable, resizable slice of bytes. The distinction might seem minor, but it has significant performance implications depending on how you use them.
Let’s see string in action. Imagine you’re reading a configuration file.
package main
import (
"fmt"
"io/ioutil"
)
func main() {
// Simulate reading a config file
configData := []byte("port=8080\ndb_host=localhost")
err := ioutil.WriteFile("config.txt", configData, 0644)
if err != nil {
panic(err)
}
// Reading the file into a string
content, err := ioutil.ReadFile("config.txt")
if err != nil {
panic(err)
}
configString := string(content) // This conversion can be expensive if content is large
fmt.Println("Config loaded as string:", configString)
// Now, let's say we need to modify it (this is where string's immutability bites)
// To "modify" a string, you actually create a new one.
newConfigString := configString + "\nnew_setting=true" // Allocates new memory and copies data
fmt.Println("Modified config string:", newConfigString)
// If we had used []byte from the start:
configBytes, err := ioutil.ReadFile("config.txt")
if err != nil {
panic(err)
}
fmt.Println("Config loaded as []byte:", string(configBytes)) // Print as string for readability
// Modifying []byte is efficient
configBytes = append(configBytes, []byte("\nnew_setting=true")...) // Appends in place if capacity allows, otherwise reallocates
fmt.Println("Modified config []byte:", string(configBytes))
}
The core problem string vs. []byte performance addresses is how Go handles immutability and memory allocation when you need to build, modify, or process sequences of bytes.
When you read data from a file, network connection, or any I/O source, it typically comes back as a []byte. Converting this []byte to a string involves allocating new memory and copying the byte data. This is because strings are immutable; once created, their content cannot change. If you later need to append to or modify a string, Go must create an entirely new string with the updated content, again involving memory allocation and copying.
Conversely, []byte is a mutable slice. When you append data to a []byte using append(), Go first checks if the underlying array has enough capacity. If it does, the new data is simply added to the existing array, and the slice header is updated. This is a very fast, in-place operation. Only when the underlying array is full does append() allocate a new, larger array and copy the existing data, plus the new data, into it.
This fundamental difference means that if your workflow involves frequent modifications, concatenations, or building up data byte by byte, []byte will almost always be more performant due to fewer allocations and copies. Operations like parsing, serializing, or manipulating raw network packets are prime candidates for []byte.
However, string has its own advantages. When you just need to read data and perform read-only operations, like searching for substrings or printing, using a string can be simpler and sometimes more efficient if you don’t need to modify it. Go’s string operations are highly optimized. Furthermore, strings are often used as map keys, and Go’s string implementation is designed for efficient hashing and comparison.
The crucial point is when you convert a []byte to a string or vice versa. The conversion string(myBytes) allocates and copies. The conversion []byte(myString) might not allocate if the string’s underlying data is accessible and no modifications are intended later. However, to be safe and guarantee mutability, it’s best to assume []byte(myString) also involves a copy for general use.
The real performance killer is repeatedly converting between the two types within a loop. If you read a []byte, convert it to a string for a read-only operation, then convert it back to []byte to append, you’re doing two expensive allocations and copies per iteration. It’s far better to stick with []byte for the entire sequence if modifications are involved.
If you have a string and need to perform efficient modifications, the idiomatic Go way is to convert it to a []byte once, perform all your modifications on the []byte, and then convert it back to a string once at the very end if needed.
The strings.Builder type offers a more optimized way to build strings incrementally than repeated string concatenation. It internally uses a []byte buffer and avoids intermediate string allocations. When you call WriteString() or Write() on a strings.Builder, it appends to its internal byte slice. Only when you call String() is the final string created. This is often the best of both worlds when you know you’re building a string but want to avoid the overhead of manual []byte management for simple cases.
The next performance bottleneck you’ll encounter is often related to how Go handles string comparisons and hashing for map lookups, especially with very long strings or when dealing with a massive number of keys.