Dev Skills

Strings, bytes, and runes

Why len("héllo") is 6, and the string operations you will use every day.

Topic 13 of 98 · 5 min to read · 50 min to build
After this topic you can
  • Explain why len("héllo") is 6 and not 5.
  • Write a function that works per character with []rune or range instead of bytes.
  • Fix a string-building loop that uses += by switching to strings.Builder or strings.Join.

What a string is

A string in Go is a read-only sequence of bytes, almost always holding text encoded as UTF-8. The two important words are *bytes* and *read-only*.

Bytes, not characters. In plain English text one character is one byte, so the difference never shows. Add an accent or an emoji and one character becomes two, three or four bytes. That is why len on a string does not tell you how many characters it has.

Read-only. A string can never be changed after it is made. Joining two strings does not extend one of them; it builds a third and copies both in. That is fine once and expensive in a loop.

A string is immutable bytes

Take a five-character word with one accent. Most beginner string bugs come from treating its bytes as characters.

strings.go
s := "héllo"
len(s) // 6 — BYTES. é takes two bytes in UTF-8.
utf8.RuneCountInString(s) // 5 — actual characters
s[1] // 195 — a byte, not 'é'
string(s[1]) // "Ã" — byte 195 read as code point U+00C3, not 'é'
for i, r := range s { // range decodes UTF-8 for you
fmt.Printf("%d: %c\n", i, r) // i is 0, 1, 3, 4, 5 — é takes two bytes
}
rs := []rune(s) // convert when you need character indexing
fmt.Println(string(rs[1])) // "é"
Watch out: Indexing a string gives you a byte. s[0:3] slices bytes and can cut a character in half. If you are doing anything per-character, convert to []rune first, or use range, which decodes for you.

Immutable means concatenation allocates

builder.go
// SLOW — every += allocates a whole new string. O(n²).
out := ""
for _, x := range items {
out += x
}
// FAST — one buffer that grows
var b strings.Builder
b.Grow(estimatedSize) // optional, one allocation if you can estimate
for _, x := range items {
b.WriteString(x)
}
out := b.String()
// For a simple join, the stdlib already has it
out := strings.Join(items, ", ")

The strings package

ops.go
strings.Contains(s, "ell") // true
strings.HasPrefix(s, "he")
strings.HasSuffix(s, "lo")
strings.Index(s, "l") // first index, or -1
strings.Count(s, "l")
strings.ToLower(s)
strings.ToUpper(s)
strings.TrimSpace(" hi ") // "hi"
strings.Trim("xxhixx", "x") // "hi"
strings.TrimPrefix("Bearer abc", "Bearer ")
strings.CutPrefix("Bearer abc", "Bearer ") // "abc", true — Go 1.20+
strings.Split("a,b,c", ",") // ["a" "b" "c"]
strings.SplitN("a,b,c", ",", 2) // ["a" "b,c"]
strings.Fields(" a b c ") // ["a" "b" "c"] — splits on any whitespace
strings.Join(parts, ",")
strings.Replace(s, "a", "b", 1) // n replacements
strings.ReplaceAll(s, "a", "b")
strings.Repeat("ab", 3) // "ababab"
strings.EqualFold("Go", "GO") // true — case-insensitive compare
Prefer `strings.EqualFold` over lowering both sides. Prefer `Cut`/`CutPrefix` over Index arithmetic: they are clearer, and there is no off-by-one to get wrong.

Cut: the one that replaced a lot of code

cut.go
before, after, found := strings.Cut("key=value", "=")
// "key", "value", true
// The same shape parses an Authorization header
token, ok := strings.CutPrefix(header, "Bearer ")

Raw strings

raw.go
regular := "line one\nline two\tindented" // escapes are processed
raw := `line one
line two C:\path\here "quotes" fine` // nothing is processed
// This is why every SQL query in this track is a raw string:
const q = `
SELECT id, name FROM users
WHERE id = $1 AND active = true`
Backticks: no escaping, newlines allowed. Use them for SQL, JSON literals, regexes, and struct tags.

Bytes when performance matters

Every strings function has a bytes twin that works on []byte. Converting between string and []byte copies, so in a hot loop reading from a network or a file, staying in []byte avoids that copy. Everywhere else, use string: it is immutable, safe to share between goroutines, and usable as a map key.

Try it yourself

press Run — this executes on the Go Playground
package main
import (
"fmt"
"strings"
"unicode/utf8"
)
func main() {
s := "héllo"
fmt.Println("len (bytes) :", len(s))
fmt.Println("runes (chars):", utf8.RuneCountInString(s))
fmt.Printf("s[1] is a byte: %d %q\n", s[1], s[1])
for i, r := range s {
fmt.Printf(" index %d -> %c\n", i, r) // the index jumps
}
var b strings.Builder
for i := 0; i < 5; i++ {
b.WriteString("ab")
}
fmt.Println("builder:", b.String())
before, after, found := strings.Cut("Bearer abc123", " ")
fmt.Println("cut:", before, "|", after, "|", found)
}
Bytes versus characters, a `strings.Builder`, and `Cut`.
The short version
  • len counts bytes. range and []rune give you characters.
  • Strings are immutable, so += in a loop is O(n²). Use strings.Builder or Join.
  • Cut, CutPrefix, Fields, and EqualFold replace most hand-written parsing.
  • Backtick raw strings for SQL, regexes, and anything with backslashes.
Remember: A string is bytes, not characters. The moment you index or slice one, ask yourself whether you meant a byte or a character.
Build this before moving on
Write a function that reverses a string correctly for "héllo" and for an emoji. Do it with []byte first and watch it break. That failure is the lesson.
Coding challenge · checked by tests
Write Reverse(s string) string that reverses a string by characters, not bytes, so accents and emoji survive. Examples: "abc" → "cba", "héllo" → "olléh", "" → "".

Tab indents. Press Esc, then Tab, to leave the editor.

Then try these in your own editor
  1. 1Print len(s) and utf8.RuneCountInString(s) for a string with an accent and one with an emoji.
  2. 2Reverse a string with []byte, then with []rune, and test both on "héllo".
  3. 3Concatenate 100,000 strings with +=, time it, then do it with strings.Builder and compare.
  4. 4Parse "Bearer abc123" with strings.CutPrefix. Then do the same with Index arithmetic and decide which you trust more.
Check yourselfTake the Core language test