Skip to main content

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 valid

How 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 collector

Do not confuse these two ideas:

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 memory

Every 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 copied

3. 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 character

Remember 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 letter

Concatenation

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 RUNES

Comparison

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"     // true

What You Cannot Do

s := "hello"

s[0] = 'H'         // cannot modify a byte
s = append(s, 'X') // append works with slices, not strings

How 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) // Hello

Using []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 bytes

In 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) // true

Summary

What a string is in Go:

Immutability:

Why strings are immutable: