✂️ 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:
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):
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:
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:
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)"]
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 / want | Use |
|---|---|
| Search/transform immutable text | strings.* |
| Assemble a string in a loop | strings.Builder |
Already hold []byte (I/O, network) | bytes.* |
| A read+write in-memory buffer | bytes.Buffer (it’s a Reader and Writer) |
| Parse text into a number/bool | strconv.Atoi / ParseFloat / ParseBool |
| Format a number as text | strconv.Itoa / FormatInt / FormatFloat |
| Count/iterate characters | unicode/utf8, []rune, or range |
| Split on the first separator | strings.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 & io —
strings.Builderandbytes.Bufferare bothio.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.
Related topics
Formatting and streaming — the fmt verbs you'll actually use, width/precision flags, the Stringer/Formatter hooks, and the tiny io.Reader/io.Writer interfaces (plus io.Copy, MultiWriter, TeeReader) that everything plugs into.
essentialsencoding/jsonTurning Go values into JSON and back — Marshal/Unmarshal, struct tags and omitempty, decoding into structs vs maps, streaming Encoder/Decoder, custom Marshaler/Unmarshaler, and json.RawMessage for deferred decoding.
Check your understanding
Score: 0 / 51. 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.