{} The Go Reference

Essentials · Stdlib · Beginner

strings & bytes

The text toolkit — searching and transforming with the strings package, O(n) assembly via strings.Builder, the parallel bytes package and bytes.Buffer, strconv for number⇄string conversion, and the unicode/utf8 view of multibyte text.

Essentials Beginner ⏱ 7 min read Complete

✂️ Analogy

If a string is a sealed, read-only ribbon of text, the strings package is the toolbox that measures and cuts copies of it — find, split, replace, trim — without ever altering the original. When you need to assemble text instead, strings.Builder is the workbench; when you’re holding mutable bytes from a file or socket, bytes is the same toolbox for a different material.

The strings package

strings is a flat set of pure functions over immutable strings: search, test, split, transform. None of them mutate — each returns a new value or a result — because a Go string’s bytes are read-only. The functions group into a few families:

strings.go — editable & runnable
package main

import (
"fmt"
"strings"
)

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

// trim & case
fmt.Printf("%q\n", strings.TrimSpace(s))             // "Go is fun"
fmt.Println(strings.ToUpper("go"), strings.Title("go lang"))

// test
fmt.Println(strings.Contains(s, "fun"))              // true
fmt.Println(strings.HasPrefix("gopher", "go"))       // true
fmt.Println(strings.Count("banana", "a"))            // 3
fmt.Println(strings.Index("gopher", "ph"))           // 2 (-1 if absent)

// split & join
fmt.Println(strings.Split("a,b,c", ","))             // [a b c]
fmt.Println(strings.Fields("  spread   out  "))      // [spread out]
fmt.Println(strings.Join([]string{"x", "y"}, "-"))   // x-y

// transform
fmt.Println(strings.ReplaceAll("aaa", "a", "b"))     // bbb
fmt.Println(strings.Repeat("ab", 3))                 // ababab
}

🧪 strings.Cut is the modern split-on-first

For “split a key=value pair on the first =,” reach for strings.Cut (Go 1.18+): k, v, ok := strings.Cut("a=b=c", "=") gives k="a", v="b=c", ok=true. It’s clearer and faster than SplitN(..., 2) plus index checks, and the ok boolean tells you whether the separator was even present.

Build, don’t concatenate

A string is immutable, so s += more can’t extend the existing string — it must allocate a brand-new string and copy everything so far. Do that in a loop and you get O(n²) behavior: the millionth append copies a million-character string. strings.Builder instead writes into one buffer that grows geometrically, producing the final string once — O(n):

builder.go — editable & runnable
package main

import (
"fmt"
"strings"
)

func main() {
var b strings.Builder
b.Grow(64) // optional: preallocate if you know the rough size

for i := 0; i < 5; i++ {
	fmt.Fprintf(&b, "item-%d ", i) // Builder is an io.Writer
}
// WriteString / WriteByte / WriteRune avoid fmt's overhead on hot paths
b.WriteString("done")

out := b.String()
fmt.Printf("%q (len %d)\n", out, b.Len())
}

⚠️ Don't copy a strings.Builder

A Builder holds an internal slice; copying it (passing by value, reassigning) and then writing to the copy panics with “illegal use of non-zero Builder copied by value.” Always pass a *strings.Builder, and never reuse a Builder after calling String() if you also keep using the old one. The same applies to bytes.Buffer’s zero-value-but-don’t-copy rule.

bytes: the same toolbox for []byte

The bytes package mirrors strings function-for-function — bytes.Contains, bytes.Split, bytes.TrimSpace, … — but operates on []byte. Use it whenever you already hold a byte slice (a file read, a network buffer, the body of an HTTP request) so you don’t pay to convert to string and back. Its bytes.Buffer is the mutable, readable+writable cousin of strings.Builder — it implements both io.Reader and io.Writer, which makes it the universal scratch space for tests and pipelines:

bytes.go — editable & runnable
package main

import (
"bytes"
"fmt"
)

func main() {
// bytes.Buffer is an io.Writer AND an io.Reader.
var buf bytes.Buffer
buf.WriteString("log: ")
fmt.Fprintf(&buf, "%d events\n", 3)
fmt.Print(buf.String()) // log: 3 events

// bytes mirrors strings, but on []byte (no conversion needed).
data := []byte("name=Ada;role=admin;name=Bob")
fmt.Println(bytes.Count(data, []byte("name")))     // 2
fmt.Printf("%q\n", bytes.Split(data, []byte(";"))) // ["name=Ada" "role=admin" "name=Bob"]

// []byte is mutable — you can edit in place (strings cannot).
b := []byte("hello")
b[0] = 'H'
fmt.Println(string(b)) // Hello
}

strconv: strings ⇄ numbers and booleans

Converting "42" to the integer 42 is not the same as the type conversion int('4'). Text-to-number parsing lives in strconv, and because parsing can fail on bad input, the parse functions return an error you must check:

strconv.go — editable & runnable
package main

import (
"fmt"
"strconv"
)

func main() {
n, err := strconv.Atoi("42") // string -> int
fmt.Println(n, err)          // 42 <nil>

_, err = strconv.Atoi("4two")
fmt.Println("bad input ->", err) // strconv.Atoi: parsing "4two": invalid syntax

f, _ := strconv.ParseFloat("3.14", 64)
ok, _ := strconv.ParseBool("true")
fmt.Println(f, ok) // 3.14 true

// the other direction
fmt.Println(strconv.Itoa(255))            // "255"
fmt.Println(strconv.FormatInt(255, 16))   // "ff" (base 16)
fmt.Println(strconv.Quote("he said \"hi\"")) // "\"he said \\\"hi\\\"\""
}

Bytes, runes, and the UTF-8 view

A Go string is a read-only slice of bytes, and Go source is UTF-8, so len(s) and s[i] work in bytes, not characters. Any non-ASCII character occupies more than one byte, which is the single most common surprise in Go text handling:

graph LR
STR["string (immutable UTF-8 bytes)"] <-->|"[]byte(s) / string(b)"| BYT["[]byte (mutable)"]
STR <-->|"[]rune(s) / string(r)"| RUN["[]rune (code points)"]
STR -->|"strconv.Atoi / Itoa"| NUM["int, float, bool"]
STR --- P1["strings.* funcs"]
BYT --- P2["bytes.* funcs (mirror)"]
utf8.go — editable & runnable
package main

import (
"fmt"
"unicode/utf8"
)

func main() {
s := "héllo, 世界"

fmt.Println("bytes:", len(s))                    // 13 — byte length
fmt.Println("runes:", utf8.RuneCountInString(s)) // 9 — character count

// range over a string yields (byte index, rune) — decoding UTF-8 for you
for i, r := range s {
	if r == '世' {
		fmt.Printf("'%c' starts at byte %d\n", r, i) // byte 8, not 7
	}
}

// []rune indexes by character; []byte by byte
r := []rune(s)
fmt.Printf("3rd character: %c\n", r[2]) // l
}

See strings & runes for the full rune story.

When to use what

You have / wantUse
Search/transform immutable textstrings.*
Assemble a string in a loopstrings.Builder
Already hold []byte (I/O, network)bytes.*
A read+write in-memory bufferbytes.Buffer (it’s a Reader and Writer)
Parse text into a number/boolstrconv.Atoi / ParseFloat / ParseBool
Format a number as textstrconv.Itoa / FormatInt / FormatFloat
Count/iterate charactersunicode/utf8, []rune, or range
Split on the first separatorstrings.Cut

🐹 Stay in one representation on hot paths

[]byte(s) and string(b) each allocate and copy — strings are immutable, so Go can’t safely share the memory. One conversion is nothing; thousands in a loop show up in profiles. Pick the representation that matches your data source — stay in []byte for I/O and parsing, string for map keys and APIs — and convert at the boundary, once. And remember: string indexing gives bytes, not characters.

See also

  • fmt & iostrings.Builder and bytes.Buffer are both io.Writers.
  • strings & runes — bytes vs runes, UTF-8 decoding, and the indexing trap in depth.
  • encoding/json — JSON marshaling builds strings the efficient way for you.
  • regexp — when fixed-string search isn’t enough and you need patterns.

Next: matching and extracting with patterns — regexp.

Check your understanding

Score: 0 / 5

1. Why use strings.Builder instead of += to concatenate many strings?

Each += allocates a brand-new string and copies everything so far. In a loop that's quadratic. strings.Builder writes into one growing byte buffer and produces the final string once.

2. When would you use the bytes package instead of strings?

The bytes package mirrors strings (Contains, Split, etc.) but operates on []byte. Use it when you already hold a byte slice — from a file, a network read, or a bytes.Buffer — to avoid converting.

3. What does strconv.Atoi("42") return?

strconv converts between strings and other types. Atoi returns (int, error) — always check the error, because parsing user input can fail. Itoa goes the other way.

4. len("héllo") on a UTF-8 string with one accented letter returns…

len on a string is the byte length. 'é' encodes as two UTF-8 bytes, so the string is 6 bytes though it's 5 runes. Use utf8.RuneCountInString for the character count.

5. Why does strings.Replace return a new string instead of modifying in place?

A Go string is read-only; you can never change its bytes. Every strings.* transform (Replace, ToUpper, Trim, …) produces a fresh string. If you need in-place mutation, convert to []byte and use the bytes package.

Comments

Sign in with GitHub to join the discussion.