String Type
How Go represents text with the immutable string type, covering UTF-8 encoding, byte-level indexing, rune iteration, and the most common mistakes beginners make when working with strings.
Go's string type is the language’s built‑in way to hold text. A string in Go is a read‑only slice of bytes. It is not an array of characters and it is not a mutable buffer — those two points alone explain most of the confusion newcomers face.
When you see a Go string, think of it as a frozen sequence of bytes that can represent any valid UTF-8 text. The language enforces immutability: once a string is created, its contents cannot be changed. Any operation that looks like it modifies a string actually creates a new one.
String Literals
Go gives you two ways to write a string literal, and the choice you make controls how escape characters and whitespace are treated.
Interpreted string literals are surrounded by double quotes ("). The compiler processes escape sequences like \n (newline), \t (tab), \\ (backslash), and \" (embedded double quote). You cannot split an interpreted literal across multiple lines in the source code — a newline inside the quotes is a compilation error.
greeting := "Hello, Gophers!\nWelcome to the world of Go."
path := "C:\\Users\\gopher\\Documents"
quote := "She said \"Go is fun\""
No single-character string type:
Go does not have a separate char type. A single character enclosed in double quotes is a string of length 1 (well, one or more bytes), and a single character in single quotes like 'A' is a rune — an alias for int32 that represents a Unicode code point. We'll explore runes shortly.
Immutability of Strings
Strings in Go are immutable. The bytes inside a string variable cannot be altered after the string is created.
s := "hello"
s[0] = 'H' // compilation error: cannot assign to s[0]
Trying to modify a character through indexing produces a compile-time error. The compiler sees the attempt to assign to an element of a string and rejects it immediately, which is far better than a runtime panic.
Immutability is enforced by the compiler:
This is not a runtime restriction you can bypass with unsafe tricks (you shouldn't). The string type’s immutability is built into the language specification. Attempting a reassignment like s[0] = 'H' fails to compile, not just fail at runtime.
Immutability is not a limitation; it is a design guarantee. Because strings never change, they are safe to share across goroutines without locks. The standard library can pass them around freely without defensive copies. When you need to build up a string incrementally, you reach for strings.Builder or a byte slice — not in‑place mutation.
Strings as Byte Sequences
Under the hood, a string stores a contiguous block of bytes. The built‑in function len() returns the number of bytes in the string, and indexing s[i] returns the byte at position i — not the character.
s := "café"
fmt.Println(len(s)) // 5, because 'é' is 2 bytes in UTF-8
fmt.Println(s[3]) // 169 (the first byte of 'é')
The word “café” contains 4 characters but occupies 5 bytes. Indexing s[3] gives you the raw byte value 0xA9 (169 in decimal), which is the first byte of the two‑byte UTF-8 encoding for ‘é’. The second byte sits at index 4.
Indexing a string does not give you a character:
Many programmers coming from languages where strings are arrays of characters expect s[3] to be 'é'. In Go, s[3] is a single byte. If you index into a multi‑byte code point, you’ll grab a fragment. That fragment is rarely useful on its own and will likely cause display issues or invalid UTF-8 if you try to print it.
This byte‑oriented model is what gives Go its honest relationship with UTF-8: it never hides the encoding from you, but it also means you must understand the encoding to work with text correctly.
UTF-8 Encoding and Runes
Go source code is defined to be UTF-8, and the string type stores arbitrary bytes, but by convention those bytes are UTF-8 encoded Unicode code points. UTF-8 is a variable‑width encoding: a single code point (character) can occupy anywhere from 1 to 4 bytes.
A rune in Go is a type alias for int32 that represents a single Unicode code point. When you see the word “rune,” think “code point.” The rune type exists so you have a clear way to talk about one logical character, as opposed to one byte.
The language provides two distinct ways to walk through a string: byte‑by‑byte with a traditional for loop, and rune‑by‑rune with range.
s := "Hello, 世界"
// Byte-level loop
for i := 0; i < len(s); i++ {
fmt.Printf("byte %d: %x\n", i, s[i])
}
// Rune-level loop with range
for i, r := range s {
fmt.Printf("rune at byte offset %d: %c (U+%04X)\n", i, r, r)
}
When you use range over a string, Go decodes UTF-8 on the fly and yields each rune along with the byte index where that rune starts. The second rune in "世界" starts at byte offset 3 because the first rune (世) occupies 3 bytes.
Range loops correctly handle multi-byte characters:
The range form over a string is the idiomatic, safe way to iterate through text. It automatically decodes UTF-8 and skips to the next complete code point. If the string contains invalid UTF-8 bytes, range replaces each invalid byte with the Unicode replacement character U+FFFD.
String Length vs. Character Count
A common point of confusion: len() returns the number of bytes, not the number of runes or “visible characters.”
import "unicode/utf8"
s := "café"
fmt.Println("bytes:", len(s)) // 5
fmt.Println("runes:", utf8.RuneCountInString(s)) // 4
To count the number of Unicode code points, use utf8.RuneCountInString from the unicode/utf8 standard library package. This function walks the entire string and counts valid UTF-8 sequences. If the string contains invalid bytes, those still count as one rune each (the replacement character).
len(s) is a byte count, not a character count:
When a function expects a length limit and you pass len(s), you are limiting by bytes. If the user types multi‑byte characters, your limit will cut off text in the middle of a code point. For character‑based limits, use utf8.RuneCountInString and slice based on rune positions instead.
String Concatenation and Building
The + operator concatenates two strings, producing a new string.
s := "Hello, " + "世界"
Because strings are immutable, repeated concatenation in a loop allocates a new string each iteration, copying all the accumulated bytes. For performance‑sensitive code that builds a large string incrementally, use strings.Builder.
var builder strings.Builder
for _, word := range []string{"Go", "is", "concise"} {
builder.WriteString(word)
builder.WriteByte(' ')
}
result := builder.String() // "Go is concise "
strings.Builder minimizes allocations by growing an internal byte slice as needed. It is the recommended tool in the standard library when you need to assemble a string from many parts.
strings.Builder is the idiomatic mutable buffer:
strings.Builder was introduced in Go 1.10 and is safe to use across all supported Go versions today. It implements io.Writer, so you can pass it to any function that writes bytes.
Comparing Strings
Strings are compared with the standard equality operators == and !=. These compare byte‑for‑byte, which in UTF-8 is the same as code‑point‑wise comparison for valid text.
a := "café"
b := "cafe\u0301" // 'e' + combining acute accent
fmt.Println(a == b) // false — different byte sequences, though visually similar
For lexicographic ordering, use strings.Compare (which returns -1, 0, 1) or the operators <, >, <=, >=. Sorting strings in Go is bytewise, which matches Unicode code‑point order for simple cases but does not handle locale‑specific ordering. For locale‑sensitive sorting, you need external packages.
Bytewise comparison can surprise with precomposed and decomposed forms:
Unicode allows the same visual character to be represented in different ways: “é” as a precomposed U+00E9 or as an ‘e’ followed by a combining acute accent U+0301. Bytewise comparison sees those as different strings, even though they render identically. If you need to normalize before comparison, use golang.org/x/text/unicode/norm.
Conversions Between Strings and Byte/Rune Slices
Since a string is an immutable byte sequence, you can convert it to a mutable []byte slice, modify the slice, and convert back to a new string.
s := "hello"
b := []byte(s)
b[0] = 'H'
s = string(b) // "Hello"
The conversion []byte(s) allocates a new byte slice and copies the string’s bytes into it. The string s remains untouched. When you call string(b), you get a new string containing a copy of the slice’s bytes at that moment.
A similar conversion exists for []rune:
s := "café"
r := []rune(s)
fmt.Println(len(r)) // 4
r[3] = 'è'
s = string(r) // "cafè"
Converting to []rune decodes the UTF-8 and stores each code point as an int32. This is often the clearest way to manipulate individual characters, regardless of their byte length, because indexing r[i] now gives you a full code point.
Conversions allocate and copy:
Both []byte(s) and []rune(s) copy the string’s content. For large strings, this can be expensive. If you only need to read or iterate, work directly with the string using range or utf8 functions — don’t convert prematurely.
Zero Value of a String
A string variable declared without an explicit initial value takes the zero value for the string type, which is the empty string "".
var s string
fmt.Println(s == "") // true
fmt.Println(len(s)) // 0
The empty string is a valid, length‑zero string. It is distinct from a nil string, which does not exist — you cannot assign nil to a string variable. (A *string pointer, however, can be nil.)
Common Mistakes with Strings
The following are the three mistakes that newcomers to Go make most often when they first start working with strings.
Mistake 1 - Using len() as a character count:
As shown earlier, len(s) returns the byte count. On strings containing non‑ASCII characters, the number is larger than the expected character count. Always use utf8.RuneCountInString for rune‑based length, or decide upfront whether your logic needs byte‑level or character‑level limits.
Mistake 2 - Indexing into a string to get a character:
s[i] returns the byte at position i, not the i‑th character. In a multi‑byte string, this may slice a code point in half. The compiler will not stop you; it will happily hand you a byte fragment. Use range or convert to []rune when you need the i‑th logical character.
Mistake 3 - Assuming all strings are valid UTF-8:
A Go string can hold arbitrary bytes, including sequences that are not valid UTF-8. If you receive a string from a network call, file read, or a cgo boundary, never assume it is well‑formed. The utf8.ValidString function checks validity, and range gracefully replaces invalid bytes with the replacement character.
Summary
The Go string type is a deliberate, lean design. It is an immutable sequence of bytes that, by strong convention, carries UTF-8 encoded Unicode text. This design pushes encoding awareness to the surface: you cannot ignore multi‑byte characters, because the language gives you byte‑level indexing but rune‑level iteration.
The key insight to take away is that a string is not an array of characters — it is a frozen byte sequence with a rich set of operations that respect Unicode, provided you use them correctly. When in doubt, reach for range to walk through text, and lean on the unicode/utf8 and strings standard library packages for the rest.
Understanding strings at this level unlocks several deeper topics: working with []byte and []rune for mutation, normalizing text for comparison, and building efficient string assembly pipelines with strings.Builder. The next sections on constants and type conversions will build on the type foundations you’ve just seen.