What is a string in Go? Is it mutable?
A string in Go is an immutable sequence of bytes. Not characters, but bytes. Most often, these bytes represent text encoded in UTF-8. However, Go does not validate this in any way: a string can contain any bytes, including invalid text or binary data.
s := "Hello" // a string containing text
b := "\x00\x01\xFF" // a string containing arbitrary bytes — also validHow a String Works Under the Hood
Internally, a Go string can be represented as a structure with two fields:
type stringHeader struct {
Data unsafe.Pointer // pointer to the bytes in memory
Len int // length in bytes
}Therefore, a string variable itself occupies 16 bytes on a 64-bit system: 8 bytes for the pointer and 8 bytes for the length. The actual bytes of the string are stored separately in memory.
Visually:
s := "Hello"
s ─── ┌──────────────────────┐ ┌─────────────────┐
│ Data: 0xc000010050 ─┼────────▶│ H │ e │ l │ l │ o │
│ Len: 5 │ └─────────────────┘
└──────────────────────┘ (5 bytes in memory)This is important: when you pass a string, only this small structure is copied. The actual string data is not copied.
Immutability
Strings in Go cannot be modified after they are created. This is their main characteristic.
s := "hello"
s[0] = 'H' // compilation error: cannot assign to s[0]Compiler message:
cannot assign to s[0] (strings are immutable)What you can do is assign a new string to the variable:
s := "hello"
s = "Hello" // this works — the variable now refers to another string
// the old "hello" string remains unchanged
// and may later be removed by the garbage collectorDo not confuse these two ideas:
- a variable can be reassigned;
- a string itself cannot be modified.
The string’s bytes remain unchanged. Only the string referenced by the variable changes.
What Happens When You “Modify” a String?
s := "hello"
s = s + " world" // a NEW string, "hello world", is created
// the old "hello" string is a separate value in memoryEvery operation that appears to modify a string actually creates a new string.
Why Strings Are Immutable
1. Safety in Concurrent Code
Multiple goroutines can safely read the same string at the same time. This does not cause data races and does not require mutexes.
s := "config"
go func() {
fmt.Println(s)
}()
go func() {
fmt.Println(s)
}()2. Cheap Copying and Passing
When you pass a string to a function, only its small header is copied, not the string’s bytes:
func process(s string) {
// Only the string header is copied.
// The actual bytes remain in the same place in memory.
}
bigString := strings.Repeat("a", 10_000_000) // 10 MB
process(bigString) // cheap: the 10 MB of data is not copied3. Strings Can Safely Be Used as Map Keys
If strings were mutable, a key in map[string]int could change after insertion and break the map’s internal structure.
Immutability guarantees that a string key remains unchanged.
4. Substrings Can Share Memory
When you create a substring using s[1:4], the new string may use the same bytes in memory with a different starting position and length.
s := "hello world"
sub := s[6:] // "world"s ─▶ Data: 0xc0000a0000, Len: 11 ─┐
│
▼
┌──────────────────────────┐
│ h e l l o w o r l d │
└──────────────────────────┘
▲
│
sub ─▶ Data: 0xc0000a0006, Len: 5 ────────┘This is safe because the underlying string data cannot be modified.
What You Can Do with a String
Read by Index — Returns a Byte, Not a Character
s := "hello"
fmt.Println(s[0]) // 104 — the byte value of 'h'
fmt.Printf("%c\n", s[0]) // h — the same byte formatted as a characterRemember from the previous question: s[0] returns a byte, not a character.
For a Russian string, indexing returns part of a UTF-8-encoded letter:
s := "Привет"
fmt.Println(s[0]) // 208 — the first byte of П, not the complete letterConcatenation
a := "hello"
b := "world"
c := a + " " + b // creates a new string: "hello world"Slicing a String
s := "hello world"
sub := s[0:5] // "hello"
sub2 := s[6:] // "world"
sub3 := s[:5] // "hello"String slicing works with byte indexes.
For UTF-8 text, slicing at an incorrect byte position may split a character:
s := "Привет"
fmt.Println(s[:3]) // "П�" — the string was cut in the middle of рTo slice by Unicode code points, first convert the string to []rune:
s := "Привет"
runes := []rune(s)
fmt.Println(string(runes[:2])) // "Пр"Iteration
s := "hello"
// Iteration over bytes
for i := 0; i < len(s); i++ {
fmt.Println(s[i])
}
// Iteration over Unicode code points
// Go automatically decodes UTF-8
for i, r := range s {
fmt.Printf("position %d: character %c\n", i, r)
}Length
s := "Привет"
fmt.Println(len(s)) // 12 — length in BYTES
fmt.Println(utf8.RuneCountInString(s)) // 6 — number of RUNESComparison
Strings can be compared with regular comparison operators. Comparison is performed byte by byte in lexicographical order:
"apple" == "apple" // true
"apple" < "banana" // true
"abc" < "abcd" // trueWhat You Cannot Do
s := "hello"
s[0] = 'H' // cannot modify a byte
s = append(s, 'X') // append works with slices, not stringsHow to “Modify” a String Through Conversion
To modify individual bytes or Unicode code points, convert the string to []byte or []rune, modify the slice, and then convert it back to a string.
Using []byte — for ASCII or Byte-Level Changes
s := "hello"
b := []byte(s) // creates a copy of the bytes
b[0] = 'H'
s = string(b) // creates a new string
fmt.Println(s) // HelloUsing []rune — for Unicode Text
s := "привет"
r := []rune(s) // creates a slice of Unicode code points
r[0] = 'П'
s = string(r)
fmt.Println(s) // ПриветEach conversion from string to []byte or []rune, and then back to string, normally creates new data in memory. This can be expensive for large strings.
Efficient String Construction
Because every + operation creates a new string, repeated concatenation inside a loop can be inefficient:
// Inefficient for large amounts of data
s := ""
for i := 0; i < 10_000; i++ {
s += "x" // creates a new, longer string on every iteration
}For efficient string construction, use strings.Builder:
import "strings"
var builder strings.Builder
for i := 0; i < 10_000; i++ {
builder.WriteString("x")
}
s := builder.String()strings.Builder uses an internal byte buffer and avoids creating unnecessary intermediate strings.
Useful Standard Library Functions
These functions return new values and do not modify the original string:
import "strings"
strings.ToUpper("hello") // "HELLO"
strings.ToLower("HELLO") // "hello"
strings.Replace("hello", "l", "L", -1) // "heLLo"
strings.Split("a,b,c", ",") // ["a", "b", "c"]
strings.Join([]string{"a", "b"}, "-") // "a-b"
strings.Contains("hello", "ell") // true
strings.TrimSpace(" hi ") // "hi"
strings.Repeat("ab", 3) // "ababab"Important Practical Consequences
1. A Substring May Keep the Original String in Memory
Because a substring may share the same underlying bytes, a large original string may remain in memory as long as a small substring refers to it:
func getName(filePath string) string {
fileContent := readHugeFile(filePath) // a 100 MB string
return fileContent[:10] // only 10 bytes are returned,
// but the entire original string may remain in memory
}One solution is to explicitly copy the substring:
func getName(filePath string) string {
fileContent := readHugeFile(filePath)
return strings.Clone(fileContent[:10])
}After cloning, the returned string no longer needs to reference the original large string.
2. Converting Between string and []byte Usually Copies Data
s := "hello"
b := []byte(s) // copies the bytes
s2 := string(b) // creates a string from the bytesIn performance-critical code, such allocations may matter. The unsafe package can sometimes avoid copying, but it should be used only when truly necessary because incorrect use can break memory safety.
3. == Compares String Contents
In Go, == compares the bytes contained in strings, not their memory addresses:
a := "hello"
b := "hello"
fmt.Println(a == b) // trueSummary
What a string is in Go:
- A sequence of bytes, not characters.
- Commonly contains UTF-8 text, but it may contain any bytes.
- Conceptually stores a pointer to the data and its length.
- Its length is measured in bytes.
Immutability:
- A string cannot be modified after creation.
- A variable can be reassigned to another string.
- To modify bytes or Unicode code points, convert the string to
[]byteor[]rune, modify the resulting slice, and convert it back. - Use
strings.Builderto efficiently construct large strings.
Why strings are immutable:
- Safe concurrent reading by multiple goroutines.
- Cheap passing and copying of string values.
- Safe use as map keys.
- Substrings may share memory without risking modification.