A Go string is a read-only row of bytes, usually UTF-8. Once you see that, len counting bytes, indexing giving a byte, range giving runes, and a slice that splits a character in half all make sense.
Text in Go looks simple until the first accented letter or emoji shows up. Then len gives a number you didn’t expect, and a slice of a string prints a strange diamond.
This post explains what a Go string really holds, how bytes and runes relate to it, and the strings and strconv packages you’ll use every day. Every program below was run on Go 1.26, and its output is pasted from the run.
A string is a row of bytes
A Go string is an immutable sequence of bytes. Go doesn’t force those bytes to be UTF-8, but string literals in Go source are UTF-8, and nearly all text you’ll handle is too.
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
for _, s := range []string{"hello", "héllo", "日本語", "go🚀"} {
fmt.Println(s, len(s), utf8.RuneCountInString(s))
}
}
It prints:
hello 5 5
héllo 6 5
日本語 9 3
go🚀 6 3
len counts bytes, not characters. "hello" is plain ASCII, so each letter is one byte and the two numbers agree. é takes two bytes, so "héllo" has five letters and six bytes. Each of the three Japanese characters takes three bytes. The rocket takes four.
utf8.RuneCountInString counts runes instead, which is usually closer to what people mean by “characters”. It has to walk the whole string to do that, so it costs time in proportion to the length. len is instant, because the byte count is stored with the string.
Explain it like I’m ten
Picture a string of beads on a thread. Each bead is a byte.
Plain English letters are small, and each one takes a single bead. An é needs two beads. A Japanese character needs three. An emoji needs four. The beads for one letter always sit next to each other, and the first bead of each letter has a special shape, so you can tell where a new letter starts.
len counts beads. range, which comes up in a moment, counts letters: it picks up all the beads that belong together and hands you the whole letter.
The precise version
Unicode gives every character a number called a code point, written like U+00E9 for é. Go’s name for a code point is a rune, and rune is another name for int32, as the part on values and types showed.
UTF-8 is the rule for turning a code point into bytes. It uses 1 to 4 bytes depending on how big the number is:
| Code points | Bytes | Examples |
|---|---|---|
| U+0000 to U+007F | 1 | ASCII: a, 7, { |
| U+0080 to U+07FF | 2 | é, ñ, Greek, Cyrillic |
| U+0800 to U+FFFF | 3 | 日, most other scripts |
| U+10000 to U+10FFFF | 4 | emoji such as 🚀 |
The first byte of each encoded rune says how many bytes follow. The following bytes all start with the bits 10, so they can never be mistaken for the start of a rune. That’s why Go can find rune boundaries without a lookup table.
Where the analogy breaks: what a person sees as one character can be several code points, so “counting letters” with runes isn’t always what a reader would count. The section on emoji below shows a real case.
Indexing gives you a byte
Indexing a Go string with s[i] returns the byte at position i, not the i-th character:
package main
import "fmt"
func main() {
s := "héllo"
fmt.Println(s[0], s[1], s[2])
fmt.Printf("%c %c\n", s[0], s[1])
fmt.Printf("% x\n", s)
}
It prints:
104 195 169
h Ã
68 c3 a9 6c 6c 6f
s[0] is 104, the byte for h. But é is stored as two bytes, c3 and a9, so s[1] is 195, just the first half of it.
Printing that half with %c shows Ã. That’s a surprise the first time. %c treats 195 as the code point U+00C3, which happens to be Ã. The byte was never meant to stand alone, so Go printed a different letter entirely.
The % x verb, with a space between % and x, prints every byte in hex. It’s the quickest way to see what a string really holds.
for range gives you runes
A for range loop over a string decodes UTF-8 as it goes, and gives you each rune together with the byte offset where it starts:
package main
import "fmt"
func main() {
for i, r := range "hé日🚀!" {
fmt.Printf("byte %d: %c (U+%04X)\n", i, r, r)
}
}
It prints:
byte 0: h (U+0068)
byte 1: é (U+00E9)
byte 3: 日 (U+65E5)
byte 6: 🚀 (U+1F680)
byte 10: ! (U+0021)
Watch the offsets jump: 0, 1, 3, 6, 10. The index isn’t a counter of characters. It’s where each rune starts in the bytes, and the gap to the next one is how many bytes that rune took.
So use s[i] when you care about bytes, such as parsing ASCII protocols. Use range when you care about characters.
Slicing a string can cut a character in half
Slicing a string, s[low:high], works on byte positions, just like indexing. Go doesn’t check that the cut lands on a rune boundary:
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
s := "héllo"
bad := s[:2]
fmt.Printf("%q len=%d valid=%v\n", bad, len(bad), utf8.ValidString(bad))
for i, r := range bad {
fmt.Printf("byte %d: %c (U+%04X)\n", i, r, r)
}
good := s[:3]
fmt.Printf("%q len=%d valid=%v\n", good, len(good), utf8.ValidString(good))
r := []rune(s)
fmt.Println(string(r[:2]))
}
It prints:
"h\xc3" len=2 valid=false
byte 0: h (U+0068)
byte 1: � (U+FFFD)
"hé" len=3 valid=true
hé
s[:2] keeps h and the first byte of é. Nothing panics, and nothing warns you. The result simply isn’t valid UTF-8 any more. %q shows the stray byte as \xc3.
When range meets a byte that doesn’t start a valid rune, it gives you U+FFFD, the Unicode replacement character, and moves on by one byte. On screen that’s the � diamond. When you see it in a web page or a log, a string was cut or decoded in the wrong place somewhere upstream.
s[:3] cuts after the whole é, so it’s fine. If you want “the first two characters” and don’t want to count bytes, convert to []rune first, as the last line does.
[]byte and []rune conversions copy
Converting a string to []byte or []rune gives you a new slice with its own copy of the data, which is why you can change it:
package main
import "fmt"
func main() {
s := "日本語"
b := []byte(s)
r := []rune(s)
fmt.Println(len(b), b)
fmt.Println(len(r), r)
b[0] = 'X'
r[0] = '月'
fmt.Println(s, string(r))
}
It prints:
9 [230 151 165 230 156 172 232 170 158]
3 [26085 26412 35486]
日本語 月本語
[]byte(s) holds the nine UTF-8 bytes. []rune(s) holds three code points, each an int32. Changing either slice leaves s untouched, because each conversion copied the data into a new array. Converting back with string(...) copies again.
Those copies cost memory and time in proportion to the length. For a short string in a request handler, that doesn’t matter. Inside a loop over a large file it can, and that’s where the bytes package, near the end of this post, helps. A []rune also takes 4 bytes per rune, so []rune of mostly ASCII text is about four times bigger than the string.
The compiler does skip the copy in a few cases it can prove are safe, such as string(b) used only as a map key. Don’t write code that depends on that.
Strings can’t be changed
A Go string is read-only, so you can’t assign to one of its bytes:
package main
import "fmt"
func main() {
s := "hello"
s[0] = 'H'
fmt.Println(s)
}
The build fails with:
./main.go:7:2: cannot assign to s[0] (neither addressable nor a map index expression)
The message doesn’t say “strings are immutable”. It says s[0] isn’t addressable, which is Go’s way of saying there’s no box there you’re allowed to write into.
Immutability is what makes strings cheap to pass around. A string value is just a pointer to the bytes and a length. Copying it, slicing it or passing it to a function never copies the bytes, because nobody can change them underneath you.
To get a changed string, you build a new one:
package main
import "fmt"
func main() {
s := "hello"
t := "H" + s[1:]
b := []byte(s)
b[0] = 'J'
u := string(b)
fmt.Println(s, t, u)
}
It prints:
hello Hello Jello
t joins a new first letter to a slice of the old string. u goes through a []byte copy, changes it, and converts back. Either way s still says hello.
Building strings: + in a loop and strings.Builder
Joining strings with + in a loop and using strings.Builder give the same result, but they do very different amounts of work:
package main
import (
"fmt"
"strings"
)
func main() {
words := []string{"strings", "are", "immutable"}
s := ""
for i, w := range words {
if i > 0 {
s += " "
}
s += w
}
var sb strings.Builder
for i, w := range words {
if i > 0 {
sb.WriteByte(' ')
}
sb.WriteString(w)
}
fmt.Fprintf(&sb, " (%d words)", len(words))
fmt.Println(s)
fmt.Println(sb.String())
fmt.Println(strings.Join(words, " "))
}
It prints:
strings are immutable
strings are immutable (3 words)
strings are immutable
All three are correct. The difference is in the copying. Because a string can’t change, every s += w makes a brand new string and copies everything built so far into it. Building a 10,000-byte string one byte at a time that way copies 1 + 2 + … + 10,000 bytes, which is 50,005,000 bytes, to produce 10,000.
strings.Builder keeps a growing []byte inside, which grows the way the part on slices showed. String() hands that buffer back as a string without copying it again. A Builder also works as an io.Writer, so fmt.Fprintf can write straight into it.
For a handful of pieces, + is fine and reads better. When you’re joining a slice you already have, strings.Join is shortest. In a loop with many pieces, use a Builder.
A tour of the strings package
The strings package covers most of the text jobs you’d otherwise write loops for. Here are the ones you’ll reach for first, on something that looks like a line of an HTTP request:
package main
import (
"fmt"
"strings"
)
func main() {
line := " GET /users/42 HTTP/1.1 "
clean := strings.TrimSpace(line)
fmt.Printf("%q\n", clean)
fmt.Println(strings.Contains(clean, "/users"), strings.HasPrefix(clean, "GET "))
fmt.Printf("%q\n", strings.Split("a,b,,c", ","))
fmt.Printf("%q\n", strings.Fields(line))
parts := strings.Fields(clean)
fmt.Println(strings.Join(parts, " | "))
fmt.Println(strings.Replace("a-b-c", "-", "+", 1), strings.ReplaceAll("a-b-c", "-", "+"))
fmt.Println(strings.ToUpper("héllo, 日本"))
key, value, found := strings.Cut("Content-Type: text/html", ": ")
fmt.Printf("%q %q %v\n", key, value, found)
_, _, found = strings.Cut("no colon here", ": ")
fmt.Println(found)
}
It prints:
"GET /users/42 HTTP/1.1"
true true
["a" "b" "" "c"]
["GET" "/users/42" "HTTP/1.1"]
GET | /users/42 | HTTP/1.1
a+b-c a+b+c
HÉLLO, 日本
"Content-Type" "text/html" true
false
A few details are worth knowing:
TrimSpaceremoves whitespace from both ends only. The spaces in the middle stay.Splitkeeps empty pieces."a,b,,c"has an empty string between the two commas.Fieldssplits on any run of whitespace and never returns empty pieces, so it’s the better choice for words.Replacetakes a count.-1means all, andReplaceAllis the readable way to say that.ToUpperunderstands Unicode, soébecameÉ. Japanese has no upper case, so it stayed the same.Cut, added in Go 1.18, splits around the first match and tells you whether it found one. It replaces a lot ofIndexarithmetic and two-elementSplitcalls.
None of these change line or clean. Every function that “modifies” a string returns a new one.
Converting numbers with strconv
The strconv package converts between strings and numbers, and its parsing functions return an error, because text from outside your program often isn’t a number:
package main
import (
"fmt"
"strconv"
)
func main() {
s := strconv.Itoa(42)
fmt.Printf("%q\n", s)
n, err := strconv.Atoi("123")
fmt.Println(n, err)
n, err = strconv.Atoi("12a")
fmt.Println(n, err)
f, err := strconv.ParseFloat("3.25", 64)
fmt.Println(f, err)
fmt.Println(strconv.Quote("tab\there, \"quotes\", é"))
fmt.Println(strconv.QuoteToASCII("é🚀"))
}
It prints:
"42"
123 <nil>
0 strconv.Atoi: parsing "12a": invalid syntax
3.25 <nil>
"tab\there, \"quotes\", é"
"\u00e9\U0001f680"
Itoa turns an int into its decimal text. Atoi goes the other way. On bad input it returns 0 and an error that names the function and the input, which makes a good log line as it is. Check that error every time you parse a query parameter or a form field. The part on errors covers what to do with it.
ParseFloat takes the bit size, 64 for a float64. Quote wraps a string in double quotes and escapes what needs escaping, the same way %q does. QuoteToASCII also escapes everything outside ASCII.
One trap: if n is an int holding 42, string(n) doesn’t give "42". It treats the number as the code point U+002A, which is *. The compiler accepts it, but go vet stops you with conversion from int to string yields a string of one rune, not a string of digits. strconv.Itoa is what you meant.
Raw string literals
A raw string literal is written between backticks, and Go takes everything inside exactly as it is: backslashes stay backslashes, and newlines stay newlines.
package main
import (
"fmt"
"regexp"
)
func main() {
interpreted := "C:\\temp\\new\n"
raw := `C:\temp\new\n`
fmt.Print(interpreted)
fmt.Println(raw)
re := regexp.MustCompile(`^\d{3}-\d{4}$`)
fmt.Println(re.MatchString("555-1234"))
usage := `Usage:
tool [flags]
tool help`
fmt.Println(usage)
}
It prints:
C:\temp\new
C:\temp\new\n
true
Usage:
tool [flags]
tool help
In the double-quoted string, \\ means one backslash and \n means a newline. In the backtick string, \n is just a backslash and an n.
Reach for backticks when the text is full of backslashes or spans several lines: regular expressions, Windows paths, SQL, JSON in a test, or a usage message. The one thing a raw string can’t contain is a backtick.
When a character is more than one rune
A rune is one code point, but what a person sees as one character can be built from several. Emoji with a skin tone and letters with a separate accent mark are the common cases:
package main
import "fmt"
func main() {
wave := "👋🏽"
fmt.Println(wave, len(wave), len([]rune(wave)))
for i, r := range wave {
fmt.Printf("byte %d: U+%X\n", i, r)
}
composed := "caf\u00e9"
decomposed := "cafe\u0301"
fmt.Println(composed, decomposed, composed == decomposed)
fmt.Println(len([]rune(composed)), len([]rune(decomposed)))
}
It prints:
👋🏽 8 2
byte 0: U+1F44B
byte 4: U+1F3FD
café café false
4 5
The waving hand is one symbol on screen, but it’s two runes: the hand, U+1F44B, and a skin-tone modifier, U+1F3FD. Each takes four bytes, so len is 8 and []rune has length 2.
The two cafés look identical, but they aren’t equal. The first uses the single code point é. The second is a plain e followed by U+0301, a combining accent that sits on the letter before it. So one has four runes and the other has five.
This is the edge of what the standard library does for you. Counting characters the way a reader sees them, or treating the two cafés as equal, needs Unicode normalisation and grapheme segmentation. Neither is in the standard library. Normalisation lives in the golang.org/x/text/unicode/norm package, maintained by the Go team outside the standard library. For most programs, knowing that runes aren’t quite characters is enough to avoid the bug.
The bytes package mirrors strings
The bytes package has the same functions as strings, but for []byte, so you can work on data from a file or a network connection without converting it to a string and back:
package main
import (
"bytes"
"fmt"
"strings"
)
func main() {
data := []byte(" hello, world ")
fmt.Printf("%q\n", bytes.TrimSpace(data))
fmt.Println(bytes.Contains(data, []byte("world")), strings.Contains(string(data), "world"))
fmt.Printf("%q\n", bytes.ToUpper(data))
var buf bytes.Buffer
buf.WriteString("status: ")
buf.WriteString("ok")
fmt.Println(buf.String(), buf.Len())
}
It prints:
"hello, world"
true true
" HELLO, WORLD "
status: ok 10
bytes.TrimSpace, bytes.Contains and bytes.ToUpper do what their strings twins do. bytes.Buffer is like strings.Builder, but you can also read back out of it. Most I/O in Go deals in []byte, so you’ll meet this package again when the series gets to HTTP handlers.
What to remember
- A string is an immutable row of bytes, usually UTF-8.
lencounts bytes, andutf8.RuneCountInStringcounts runes. s[i]ands[low:high]work on bytes. A slice can cut a rune in half, and the broken byte shows up as�.for rangeover a string decodes runes and gives you the byte offset where each one starts.[]byte(s),[]rune(s)andstring(...)each copy the data.- You can’t change a string. Build a new one, and use
strings.Builderwhen you’re adding many pieces in a loop. - Parse numbers with
strconvand check the error.string(n)on anintgives a rune, not digits. - A rune is a code point, not always a character a reader sees. An emoji with a skin tone is two runes.
Go strings hold bytes. Characters are something you decode from them.