Strings, bytes, and runes
Why len("héllo") is 6, and the string operations you will use every day.
- Explain why
len("héllo")is 6 and not 5. - Write a function that works per character with
[]runeorrangeinstead of bytes. - Fix a string-building loop that uses
+=by switching tostrings.Builderorstrings.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.
s := "héllo"len(s) // 6 — BYTES. é takes two bytes in UTF-8.utf8.RuneCountInString(s) // 5 — actual characterss[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 indexingfmt.Println(string(rs[1])) // "é"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
// SLOW — every += allocates a whole new string. O(n²).out := ""for _, x := range items { out += x}// FAST — one buffer that growsvar b strings.Builderb.Grow(estimatedSize) // optional, one allocation if you can estimatefor _, x := range items { b.WriteString(x)}out := b.String()// For a simple join, the stdlib already has itout := strings.Join(items, ", ")The strings package
strings.Contains(s, "ell") // truestrings.HasPrefix(s, "he")strings.HasSuffix(s, "lo")strings.Index(s, "l") // first index, or -1strings.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 whitespacestrings.Join(parts, ",")strings.Replace(s, "a", "b", 1) // n replacementsstrings.ReplaceAll(s, "a", "b")strings.Repeat("ab", 3) // "ababab"strings.EqualFold("Go", "GO") // true — case-insensitive compareCut: the one that replaced a lot of code
before, after, found := strings.Cut("key=value", "=")// "key", "value", true// The same shape parses an Authorization headertoken, ok := strings.CutPrefix(header, "Bearer ")Raw strings
regular := "line one\nline two\tindented" // escapes are processedraw := `line oneline 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`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
package mainimport ( "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)}lencounts bytes.rangeand[]runegive you characters.- Strings are immutable, so
+=in a loop is O(n²). Usestrings.BuilderorJoin. Cut,CutPrefix,Fields, andEqualFoldreplace most hand-written parsing.- Backtick raw strings for SQL, regexes, and anything with backslashes.
[]byte first and watch it break. That failure is the lesson.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.
- 1Print
len(s)andutf8.RuneCountInString(s)for a string with an accent and one with an emoji. - 2Reverse a string with
[]byte, then with[]rune, and test both on"héllo". - 3Concatenate 100,000 strings with
+=, time it, then do it withstrings.Builderand compare. - 4Parse
"Bearer abc123"withstrings.CutPrefix. Then do the same withIndexarithmetic and decide which you trust more.