Strings, Bytes & Runes
Text in Go looks simple until you print a name in Hindi or a price with the ₹ sign and the lengths come out "wrong". The reason is that a Go string is a sequence of bytes, and many characters need more than one byte. Once you understand bytes and runes (characters), Go's string handling becomes clear and very fast.
In this lesson you will learn how strings store text, the difference between len and the number of characters, how to loop and index correctly, and the most useful functions of the strings package.
Strings are bytes in UTF-8
A Go string is an immutable (unchangeable) sequence of bytes. Go source files are UTF-8, so string literals are UTF-8 too. UTF-8 is the standard text encoding: English letters take 1 byte, the ₹ sign takes 3 bytes, and Devanagari letters such as न take 3 bytes each.
len(s) counts bytes, not characters. To count characters, use utf8.RuneCountInString from the unicode/utf8 package:
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
words := []string{"Pune", "नमस्ते", "₹500"}
for _, w := range words {
fmt.Println(w, len(w), utf8.RuneCountInString(w))
}
}Pune 4 4 नमस्ते 18 6 ₹500 6 4
"नमस्ते" is 6 Unicode code points, each 3 bytes long, so len reports 18. A rune is Go's name for one Unicode code point; the type rune is an alias for int32. A byte is an alias for uint8. (What a reader sees as one letter can sometimes be several code points, such as स plus the virama ्, but code points are what Go counts.)
Indexing, slicing and converting
Indexing a string with s[i] gives a byte, not a character. Slicing with s[a:b] also works in bytes. To work with characters, convert to []rune:
package main
import "fmt"
func main() {
s := "₹500"
fmt.Println(s[0], s[3])
fmt.Println(string(s[3]))
fmt.Printf("%q %q\n", s[:3], s[1:2])
r := []rune(s)
fmt.Println(len(r), string(r[0]))
b := []byte("Go")
fmt.Println(b, string(b))
}226 53 5 "₹" "\x82" 4 ₹ [71 111] Go
s[0] is 226, the first of the three bytes of ₹. s[3] is 53, the byte for the digit 5. The slice s[:3] holds exactly the three bytes of ₹, but s[1:2] cuts a character in half and gives a broken byte, which %q shows as "\x82". Converting to []rune decodes the characters, so r[0] is the whole ₹ sign. Converting to []byte gives the raw bytes.
Strings cannot be changed in place: s[0] = 'x' is a compile error. To "change" a string, build a new one.
Looping over characters
for i, r := range s decodes UTF-8 for you and gives each rune with its starting byte position, as you saw in for Loops & range. Use it whenever you work with characters. Here is a function that reverses text correctly by working on runes:
package main
import "fmt"
func reverse(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
func main() {
fmt.Println(reverse("Go₹"))
fmt.Println(reverse("Kabir"))
}₹oG ribaK
Reversing the bytes instead would scramble the three bytes of ₹ into invalid text. The loop uses two variables, i from the left and j from the right, and swaps until they meet.
The strings package
The strings package has dozens of ready-made functions. These are the ones you will use most:
package main
import (
"fmt"
"strings"
)
func main() {
email := " Meera.Nair@Example.com "
clean := strings.ToLower(strings.TrimSpace(email))
fmt.Println(clean)
user, domain, found := strings.Cut(clean, "@")
fmt.Println(user, domain, found)
fmt.Println(strings.HasSuffix(clean, ".com"), strings.Contains(clean, "nair"))
parts := strings.Split("rice,dal,,ghee", ",")
fmt.Println(len(parts), parts)
fmt.Println(strings.Join([]string{"Rohtak", "Haryana", "India"}, ", "))
fmt.Println(strings.ReplaceAll("98-76-54", "-", ""))
fmt.Println(strings.EqualFold("KOCHI", "kochi"))
}meera.nair@example.com meera.nair example.com true true true 4 [rice dal ghee] Rohtak, Haryana, India 987654 true
TrimSpaceremoves spaces, tabs and newlines from both ends;ToLowerandToUpperchange case.Cutsplits around the first separator and tells you whether it was found. It is cleaner thanIndexplus slicing.Splitkeeps empty pieces, which is why there are 4 parts and two spaces in the printout.Fieldssplits on any run of spaces and drops empty pieces.EqualFoldcompares strings ignoring case.
Building strings efficiently
Because strings are immutable, s += "x" in a loop creates a new string every time, copying all the text so far. For many pieces, use strings.Builder, which grows one buffer:
package main
import (
"fmt"
"strings"
)
func main() {
menu := []string{"idli", "vada", "dosa"}
var sb strings.Builder
for i, item := range menu {
if i > 0 {
sb.WriteString(" | ")
}
sb.WriteString(strings.ToUpper(item))
}
fmt.Println(sb.String(), sb.Len())
}IDLI | VADA | DOSA 18
The zero value of a strings.Builder is ready to use. For a simple list like this, strings.Join(menu, " | ") is even shorter; the Builder shines when each piece needs some logic.
Raw string literals use backticks instead of double quotes. Backslashes are not special inside them and they can span lines, which suits Windows paths and regular expressions: `C:\Users\Isha\notes.txt`.
Common mistakes
- Using
len(s)as the number of characters. It counts bytes; useutf8.RuneCountInStringfor characters. - Slicing text by character position.
s[:3]takes 3 bytes, which may cut a character in half. Convert to[]runefirst. - Building big strings with
+=in a loop. Usestrings.Builderorstrings.Join. - Converting a number with
string(n). It gives the character with coden; usestrconv.Itoa.
Key points
- A string is an immutable sequence of UTF-8 bytes;
lencounts bytes. - A
runeis one Unicode code point (int32); abyteis auint8. s[i]gives a byte, whilerange sand[]rune(s)give characters.- The
stringspackage covers trimming, splitting, joining, searching, replacing andCut. - Use
strings.Builderto build strings piece by piece.
Exercise
A delivery app in Kochi receives addresses typed by users, such as " 14, MG Road ,Ernakulam, Kerala ". Write a function cleanAddress(s string) []string that splits on commas, trims spaces from each part, and skips empty parts. Print the cleaned parts joined with " / ", and print how many characters (runes) the joined result has.
Show solution
package main
import (
"fmt"
"strings"
"unicode/utf8"
)
func cleanAddress(s string) []string {
var parts []string
for _, p := range strings.Split(s, ",") {
p = strings.TrimSpace(p)
if p != "" {
parts = append(parts, p)
}
}
return parts
}
func main() {
parts := cleanAddress(" 14, MG Road ,Ernakulam, Kerala ")
joined := strings.Join(parts, " / ")
fmt.Println(joined)
fmt.Println(utf8.RuneCountInString(joined))
}14 / MG Road / Ernakulam / Kerala 33
Each piece is trimmed before the empty check, so stray spaces never create blank parts. The text is plain English, so here the rune count equals the byte count.
Quick Quiz
1. What does len("₹10") return?
₹ takes 3 bytes in UTF-8 and each digit takes 1, so the string is 5 bytes long.
2. What type is s[0] when s is a string?
Indexing a string gives a single byte.
3. Which loop gives you whole characters from a string?
range over a string decodes UTF-8 and yields runes.
4. What does strings.Cut("key=value", "=") return?
Cut returns the text before and after the first separator, and whether it was found.
Learn Go with a mentor
Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
