Strings, Bytes & Runes

Go · Lesson 16 of 32 6 min read Updated

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:

Go
package main

import (
	"fmt"
	"unicode/utf8"
)

func main() {
	words := []string{"Pune", "नमस्ते", "₹500"}
	for _, w := range words {
		fmt.Println(w, len(w), utf8.RuneCountInString(w))
	}
}
Output
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:

Go
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))
}
Output
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:

Go
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"))
}
Output
₹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:

Go
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"))
}
Output
meera.nair@example.com
meera.nair example.com true
true true
4 [rice dal  ghee]
Rohtak, Haryana, India
987654
true
  • TrimSpace removes spaces, tabs and newlines from both ends; ToLower and ToUpper change case.
  • Cut splits around the first separator and tells you whether it was found. It is cleaner than Index plus slicing.
  • Split keeps empty pieces, which is why there are 4 parts and two spaces in the printout. Fields splits on any run of spaces and drops empty pieces.
  • EqualFold compares 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:

Go
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())
}
Output
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; use utf8.RuneCountInString for characters.
  • Slicing text by character position. s[:3] takes 3 bytes, which may cut a character in half. Convert to []rune first.
  • Building big strings with += in a loop. Use strings.Builder or strings.Join.
  • Converting a number with string(n). It gives the character with code n; use strconv.Itoa.

Key points

  • A string is an immutable sequence of UTF-8 bytes; len counts bytes.
  • A rune is one Unicode code point (int32); a byte is a uint8.
  • s[i] gives a byte, while range s and []rune(s) give characters.
  • The strings package covers trimming, splitting, joining, searching, replacing and Cut.
  • Use strings.Builder to 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
Go
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))
}
Output
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?

2. What type is s[0] when s is a string?

3. Which loop gives you whole characters from a string?

4. What does strings.Cut("key=value", "=") return?

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