Strings, Bytes and Runes

A Go string is a read-only sequence of bytes, and by convention those bytes are UTF-8 text. Most of the time you don't need to think about that — but as soon as text contains accents, other alphabets or emoji, the difference between a byte and a character matters. This lesson explains bytes, runes and UTF-8, and how to index, slice, iterate and compare strings correctly.

Strings are bytes

len returns the number of bytes, and indexing returns a single byte (a uint8). For plain ASCII text — English letters, digits, punctuation — each character is one byte, so everything lines up:

GO
package main

import "fmt"

func main() {
	s := "Gopher"
	fmt.Println(len(s), s[0], s[1])
	fmt.Println(string(s[0]), s[1:4])
	fmt.Printf("%T\n", s[0])
}
Output
6 71 111
G oph
uint8

s[0] is 71, the byte value of "G". Slicing with s[1:4] gives bytes 1 up to (not including) 4, as a new string.

UTF-8 and multi-byte characters

UTF-8 stores each Unicode character in one to four bytes. Characters beyond ASCII take two or more, so byte counts and indexes no longer match character counts:

GO
package main

import (
	"fmt"
	"unicode/utf8"
)

func main() {
	s := "Café ☕"
	fmt.Println(len(s), utf8.RuneCountInString(s))
	fmt.Println(s[3], s[3:5])
	fmt.Printf("% x\n", s)
}
Output
9 6
195 é
43 61 66 c3 a9 20 e2 98 95

The string has 6 characters but 9 bytes: é is two bytes (c3 a9) and ☕ is three. s[3] is just the first half of é. Use utf8.RuneCountInString to count characters, and be careful when slicing text that might not be ASCII — cutting through the middle of a character produces invalid UTF-8.

Runes

A rune is an alias for int32 that holds one Unicode code point. Rune literals use single quotes. Ranging over a string decodes it rune by rune; converting a string to []rune decodes all of it at once:

GO
package main

import "fmt"

func main() {
	letter := 'é'
	fmt.Println(letter, string(letter))
	fmt.Printf("%c %U %T\n", letter, letter, letter)

	word := "naïve"
	for i, r := range word {
		fmt.Printf("%d:%c ", i, r)
	}
	fmt.Println()

	runes := []rune(word)
	fmt.Println(len(runes), string(runes[2]))
	fmt.Println(string(runes[:3])) // the first three characters, safely
}
Output
233 é
é U+00E9 int32
0:n 1:a 2:ï 4:v 5:e
5 ï
naï

Converting to []rune is the simple way to index characters by position or take the first n characters. It copies the text, so for long strings that you only scan once, prefer range.

Strings are immutable

You can't change a string in place — s[0] = 'J' doesn't compile. Build a new string instead, or convert to a byte or rune slice, modify that, and convert back:

GO
package main

func main() {
	s := "hello"
	s[0] = 'J'
}
Compiler error
./main.go:5:2: cannot assign to s[0] (neither addressable nor a map index expression)
GO
package main

import "fmt"

func main() {
	s := "hello"
	b := []byte(s)
	b[0] = 'J'
	fmt.Println(string(b), s)

	t := "J" + s[1:]
	fmt.Println(t)
}
Output
Jello hello
Jello

Because strings are immutable, they're cheap to pass around and slicing a string doesn't copy anything — both strings share the same bytes.

Escape sequences

EscapeMeaning
\n \t \rNewline, tab, carriage return
\\ \" \'Backslash, double quote (in strings), single quote (in runes)
\x41A byte given in hex
\u00e9 \U0001F600A Unicode code point (4 or 8 hex digits)
GO
package main

import "fmt"

func main() {
	fmt.Println("Tab:\t|", "Quote: \"", "Hex: \x47\x6f", "Unicode: \u00e9 \U0001F642")
	fmt.Println(`Raw: \n stays as backslash-n`)
}
Output
Tab:	| Quote: " Hex: Go Unicode: é 🙂
Raw: \n stays as backslash-n

Comparing and joining

== compares strings exactly, byte for byte, and </> compare byte by byte, which for ASCII is alphabetical order with capitals first. For case-insensitive comparison use strings.EqualFold. The + operator joins strings:

GO
package main

import (
	"fmt"
	"strings"
)

func main() {
	fmt.Println("go" == "Go", strings.EqualFold("go", "GO"))
	fmt.Println("apple" < "banana", "Banana" < "apple")

	greeting := "Hello"
	greeting += ", " + "world"
	fmt.Println(greeting)
}
Output
false true
true true
Hello, world

Joining with + in a big loop creates a new string every time; lesson 22 shows the efficient way.

Invalid UTF-8

A string can hold any bytes, not only valid UTF-8 — for example, data read from a binary file. Ranging over invalid bytes produces the replacement character U+FFFD. You can check with utf8.ValidString, or clean a string with strings.ToValidUTF8:

GO
package main

import (
	"fmt"
	"strings"
	"unicode/utf8"
)

func main() {
	bad := "ok\xffok"
	fmt.Println(utf8.ValidString(bad), utf8.ValidString("Café"))
	for _, r := range bad {
		fmt.Printf("%U ", r)
	}
	fmt.Println()
	fmt.Println(strings.ToValidUTF8(bad, "?"))
}
Output
false true
U+006F U+006B U+FFFD U+006F U+006B
ok?ok

Try it yourself

Reverse a string character by character. Try it with plain ASCII, then with accents and emoji — reversing the bytes would break those, but reversing runes works.

Try it yourself · main.go
Edit the code, copy it into main.go in a new folder and run it with go run main.go (Go 1.27 or later). How to install Go
Output
desserts
🎉 !élO¡
Next: the strings package.