Strings in Go: bytes, runes and the strings package

In this lesson you’ll learn how Go strings really work: what’s inside them, why len("héllo") is 6, how to loop over characters correctly, and which tools from the strings, strconv and fmt packages you’ll use every day.

Two kinds of string literals

Go has two ways to write a string in your code. An interpreted string literal uses double quotes. Inside it, a backslash starts an escape sequence, like \n for a newline, \t for a tab and \" for a double quote.

A raw string literal uses backticks. Everything between the backticks is taken literally: backslashes are just backslashes, and the string can span several lines. Raw strings are handy for file paths on Windows, regular expressions and blocks of text.

package main

import "fmt"

func main() {
	interpreted := "Tab:\tdone\nQuote: \"hi\""
	raw := `C:\Users\erik\go
No escapes here: \n stays \n`

	fmt.Println(interpreted)
	fmt.Println(raw)
}Code language: Go (go)
Tab:	done
Quote: "hi"
C:\Users\erik\go
No escapes here: \n stays \nCode language: plaintext (plaintext)

Strings are immutable

A Go string is a read-only sequence of bytes. Once a string exists, you can’t change it. You can build a new string from it, and you can assign a new string to the same variable, but you can’t modify the bytes in place. The compiler stops you if you try:

package main

import "fmt"

func main() {
	s := "hello"
	s[0] = 'H'
	fmt.Println(s)
}Code language: Go (go)
./main.go:7:2: cannot assign to s[0] (neither addressable nor a map index expression)Code language: plaintext (plaintext)

The fix is to make a new string, for example s = "H" + s[1:]. The + operator joins (concatenates) strings, and s[1:] is everything from byte 1 onward. Because strings never change, Go can share them freely and copying a string is cheap: only a pointer and a length are copied, not the bytes.

Bytes, runes and UTF-8

Go source code is UTF-8, so string literals are UTF-8 too. UTF-8 stores plain ASCII characters (English letters, digits, punctuation) in one byte each, and other characters in two, three or four bytes. That’s why len, which counts bytes, can surprise you:

package main

import (
	"fmt"
	"unicode/utf8"
)

func main() {
	s := "héllo"
	fmt.Println(len(s))
	fmt.Println(utf8.RuneCountInString(s))
	fmt.Println(len("世界"))
}Code language: Go (go)
6
5
6Code language: plaintext (plaintext)

The word “héllo” has five characters, but the é takes two bytes, so the string is six bytes long. Go calls a single Unicode character (a code point) a rune. The type rune is another name for int32, and the type byte is another name for uint8. A rune literal uses single quotes: 'é'.

The string “héllo” as runes and bytes The string héllo has 5 runes stored in 6 bytes. The rune é takes two bytes, c3 and a9, at indexes 1 and 2. Ranging over the string yields the byte offsets 0, 1, 3, 4 and 5. runes ‘h’ ‘é’ one rune, two bytes ‘l’ ‘l’ ‘o’ bytes 68 c3 a9 6c 6c 6f index 0 1 2 3 4 5 len(s) == 6 range gives i = 0, 1, 3, 4, 5
Five runes, six bytes. The é is stored as two bytes, so the byte index jumps from 1 to 3 when you range over the string.

Indexing gives you bytes

Indexing a string with s[i] gives you the byte at position i, not the character. Slicing with s[start:end] also works on byte positions. For ASCII text that’s the same thing. For anything else, it isn’t:

package main

import "fmt"

func main() {
	s := "héllo"
	fmt.Println(s[0], s[1], s[2])
	fmt.Println(string(s[0]))
	fmt.Println(s[1:3])
	fmt.Printf("%q\n", s[0:2])
}Code language: Go (go)
104 195 169
h
é
"h\xc3"Code language: plaintext (plaintext)

s[0] is the byte 104, which is the letter h. Bytes 1 and 2 together make up é, so s[1:3] prints it correctly. s[0:2] cuts the é in half. I printed it with %q (more on that below), which shows the leftover byte as \xc3: half a character, which is not valid UTF-8. Printed normally, it would show up as the replacement character �.

Looping over runes with range

When you want characters, loop with for ... range. On a string, range decodes UTF-8 for you and gives you two values per step: the byte offset where the rune starts, and the rune itself. (The for loop lesson covers loops in full; this is all you need for now.)

package main

import "fmt"

func main() {
	for i, r := range "héllo" {
		fmt.Println(i, string(r), r)
	}
}Code language: Go (go)
0 h 104
1 é 233
3 l 108
4 l 108
5 o 111Code language: plaintext (plaintext)

Notice that the index jumps from 1 to 3: the é occupies bytes 1 and 2. If you need to work with characters by position, convert the string to a slice of runes with []rune(s). Then len counts characters and indexing gives runes:

package main

import "fmt"

func main() {
	runes := []rune("héllo")
	fmt.Println(len(runes))
	fmt.Println(string(runes[1]))

	runes[0] = 'H'
	fmt.Println(string(runes))
}Code language: Go (go)
5
é
HélloCode language: plaintext (plaintext)

A slice of runes is a copy, so you’re allowed to change it. Converting it back with string(runes) makes a new string. Slices get their own lesson in stage 3.

Convert a number to a string with strconv (below), not with string(n). string(65) treats 65 as a rune and gives you "A", not "65". go vet warns about this mistake.

The strings package

Strings have no methods of their own in Go. Instead, the standard library’s strings package has functions that take a string and return a result. Because strings are immutable, functions like ToUpper and Replace return a new string and leave the original alone. Here are the ones I reach for most:

package main

import (
	"fmt"
	"strings"
)

func main() {
	s := "  Go is fun, Go is fast  "

	fmt.Println(strings.TrimSpace(s))
	fmt.Println(strings.Contains(s, "fun"))
	fmt.Println(strings.HasPrefix("gopher.png", "go"))
	fmt.Println(strings.HasSuffix("gopher.png", ".png"))
	fmt.Println(strings.ToUpper("hello"))
	fmt.Println(strings.ToLower("HELLO"))
	fmt.Println(strings.Replace(s, "Go", "Rust", 1))
	fmt.Println(strings.ReplaceAll(s, "Go", "Rust"))
	fmt.Println(strings.Index("chicken", "ken"))
	fmt.Println(strings.Count("cheese", "e"))
	fmt.Println(strings.Repeat("ab", 3))
}Code language: Go (go)
Go is fun, Go is fast
true
true
true
HELLO
hello
  Rust is fun, Go is fast
  Rust is fun, Rust is fast
4
3
abababCode language: plaintext (plaintext)

The last argument of Replace is how many replacements to make; ReplaceAll replaces every match. Index returns the byte offset of the first match, or -1 if there is none.

Split, Fields and Join

Split cuts a string at every separator and returns a slice of the pieces. Fields splits on any run of whitespace, which is what you usually want for words. Join does the opposite of Split: it glues a slice of strings together with a separator.

package main

import (
	"fmt"
	"strings"
)

func main() {
	parts := strings.Split("red,green,blue", ",")
	fmt.Println(len(parts), parts)

	words := strings.Fields("  one   two\tthree\n")
	fmt.Printf("%q\n", words)

	fmt.Println(strings.Join(words, "-"))
}Code language: Go (go)
3 [red green blue]
["one" "two" "three"]
one-two-threeCode language: plaintext (plaintext)

Cut

strings.Cut splits a string around the first occurrence of a separator. It returns three values: the part before, the part after, and a boolean that tells you whether the separator was found. It’s perfect for key=value pairs:

package main

import (
	"fmt"
	"strings"
)

func main() {
	key, value, found := strings.Cut("name=Erik", "=")
	fmt.Println(key, value, found)

	key, value, found = strings.Cut("no separator", "=")
	fmt.Printf("%q %q %v\n", key, value, found)
}Code language: Go (go)
name Erik true
"no separator" "" falseCode language: plaintext (plaintext)

Functions returning more than one value are normal in Go. You’ll write your own in the functions lesson. The full list of functions is in the strings documentation; it’s worth a skim.

Building strings with strings.Builder

Joining two or three strings with + is fine. But every + creates a new string, so adding to a string over and over in a loop copies the bytes again and again. When you build a string piece by piece, use a strings.Builder. Its zero value is ready to use:

package main

import (
	"fmt"
	"strings"
)

func main() {
	var b strings.Builder
	for i := range 3 {
		fmt.Fprintf(&b, "line %d\n", i+1)
	}
	b.WriteString("done")
	b.WriteByte('!')
	b.WriteRune('✓')

	fmt.Println(b.String())
	fmt.Println(b.Len())
}Code language: Go (go)
line 1
line 2
line 3
done!✓
29Code language: plaintext (plaintext)

fmt.Fprintf works like Printf but writes into whatever you give it, here the builder. The &b passes a pointer to the builder, as you saw in the variables and pointers lesson. Len counts bytes again: the check mark takes three.

Converting numbers with strconv

Go never converts between numbers and strings automatically. "Age: " + 42 doesn’t compile. The strconv package does the conversions: Itoa turns an int into a string (“integer to ASCII”) and Atoi goes the other way.

package main

import (
	"fmt"
	"strconv"
)

func main() {
	s := "Age: " + strconv.Itoa(42)
	fmt.Println(s)

	n, err := strconv.Atoi("123")
	fmt.Println(n+1, err)

	n, err = strconv.Atoi("12a")
	fmt.Println(n, err)
}Code language: Go (go)
Age: 42
124 <nil>
0 strconv.Atoi: parsing "12a": invalid syntaxCode language: plaintext (plaintext)

Turning any int into text always works, so Itoa returns just a string. Parsing can fail, so Atoi returns two values: the number and an error. When parsing works, the error is nil (nothing). When it fails, you get a description of what went wrong and the number is 0. In real code you check the error before using the number:

package main

import (
	"fmt"
	"strconv"
)

func main() {
	n, err := strconv.Atoi("forty-two")
	if err != nil {
		fmt.Println("not a number:", err)
		return
	}
	fmt.Println("double:", n*2)
}Code language: Go (go)
not a number: strconv.Atoi: parsing "forty-two": invalid syntaxCode language: plaintext (plaintext)

This if err != nil pattern is everywhere in Go. The next lesson explains if, and the errors lesson explains errors properly. For floats and booleans, strconv has ParseFloat, ParseBool, FormatFloat and friends.

Formatting with fmt.Sprintf

For anything beyond a simple conversion, fmt.Sprintf is the most convenient tool. It works like fmt.Printf, but returns the result as a string instead of printing it. The format string contains verbs starting with %, and each verb is replaced by the next argument:

package main

import "fmt"

func main() {
	name := "Erik"
	age := 42
	price := 9.5

	s := fmt.Sprintf("%s is %d and paid $%.2f", name, age, price)
	fmt.Println(s)

	fmt.Printf("%v | %v | %v\n", name, age, price)
	fmt.Printf("%q\n", "say \"hi\"")
	fmt.Printf("%T %T %T\n", name, age, price)
	fmt.Printf("%x %X\n", 255, 255)
	fmt.Printf("%x\n", "hé")
	fmt.Printf("%5d|%-5d|%05d\n", 42, 42, 42)
	fmt.Printf("%c %U\n", 'é', 'é')
	fmt.Printf("100%%\n")
}Code language: Go (go)
Erik is 42 and paid $9.50
Erik | 42 | 9.5
"say \"hi\""
string int float64
ff FF
68c3a9
   42|42   |00042
é U+00E9
100%Code language: plaintext (plaintext)

Here are the verbs you’ll use most:

VerbPrintsExample output
%vAny value in its default format9.5
%dAn integer in decimal42
%sA stringErik
%qA quoted string (or rune), with escapes"say \"hi\""
%.2fA float with 2 decimals9.50
%TThe type of the valuefloat64
%xHexadecimal (numbers, or the bytes of a string)ff
%%A literal percent sign%

A number between % and the verb sets a minimum width, - pads on the right instead of the left, and 0 pads with zeros. %q is my favorite for debugging: it makes empty strings and stray spaces visible. Use Sprintf to get a string, Printf to print, and Fprintf to write somewhere else. If you pass the wrong type for a verb, go vet tells you. The fmt documentation lists every verb and flag.

Practice

1. Count the letters. Write a program that prints both the number of bytes and the number of runes in "Grüße, 世界", then prints each rune on its own line together with its byte offset.

Show a solution
package main

import (
	"fmt"
	"unicode/utf8"
)

func main() {
	s := "Grüße, 世界"
	fmt.Println("bytes:", len(s))
	fmt.Println("runes:", utf8.RuneCountInString(s))
	for i, r := range s {
		fmt.Printf("%2d %q\n", i, r)
	}
}Code language: Go (go)
bytes: 15
runes: 9
 0 'G'
 1 'r'
 2 'ü'
 4 'ß'
 6 'e'
 7 ','
 8 ' '
 9 '世'
12 '界'Code language: plaintext (plaintext)

2. Parse a setting. Given the string " port = 8080 ", use strings.Cut, strings.TrimSpace and strconv.Atoi to get the key and the port number as an int, then print port is 8080, next is 8081 using fmt.Sprintf.

Show a solution
package main

import (
	"fmt"
	"strconv"
	"strings"
)

func main() {
	line := "  port = 8080 "

	key, value, found := strings.Cut(line, "=")
	if !found {
		fmt.Println("no = in line")
		return
	}
	key = strings.TrimSpace(key)
	value = strings.TrimSpace(value)

	port, err := strconv.Atoi(value)
	if err != nil {
		fmt.Println("bad port:", err)
		return
	}

	msg := fmt.Sprintf("%s is %d, next is %d", key, port, port+1)
	fmt.Println(msg)
}Code language: Go (go)
port is 8080, next is 8081Code language: plaintext (plaintext)

Key takeaways

  • A string is an immutable sequence of bytes, usually UTF-8. Use double quotes for escapes and backticks for raw text.
  • len(s) and s[i] work with bytes. for i, r := range s and []rune(s) work with runes (characters).
  • The strings package has the everyday tools: Contains, HasPrefix, Split, Fields, Join, TrimSpace, Replace, Cut. They return new strings.
  • Build strings in a loop with strings.Builder, not repeated +.
  • Convert numbers with strconv.Itoa and strconv.Atoi (check its error), and format anything with fmt.Sprintf.