The Slug Function That Grew a Loop
A CMS needs slugify(title string): lowercase, alphanumeric runs kept, everything else collapsed into single dashes, no leading or trailing dash. The first version reads like pseudocode — concatenate with +=, then clean up with a second pass of replacements. It works on a blog title and crawls on a 40 KB document, because every += copies the whole string so far, giving quadratic time before the cleanup pass even starts.
strings.Builder is the tool built for this, and the standard library already owns the parts of the job that are not yours:
func slugify(title string) string {
var b strings.Builder
b.Grow(len(title)) // pre-size: output cannot be longer than the input
lastWasDash := true
for _, r := range strings.ToLower(title) {
switch {
case r >= 'a' && r <= 'z' || r >= '0' && r <= '9':
b.WriteRune(r)
lastWasDash = false
case !lastWasDash:
b.WriteByte('-')
lastWasDash = true
}
}
return strings.TrimSuffix(b.String(), "-")
}
Three details carry the performance. Grow allocates once up front instead of letting the buffer double repeatedly. lastWasDash tracks state in a bool rather than calling b.String() inside the loop to inspect the tail — each String() call would allocate a copy. And strings.ToLower(title) produces a new string, so the original is never mutated; strings in Go are immutable byte slices, and every transformation returns a copy.
For reading text, four functions cover nearly every case, and choosing wrong is a correctness bug, not a style one:
// Whitespace-separated words: collapses runs of spaces/tabs/newlines,
// and returns nil (zero fields) for blank input.
words := strings.Fields(" go test\t./...") // ["go", "test", "./..."]
// Fixed delimiter: preserves empty fields, which matters for CSV-ish data.
cols := strings.Split("a,,b", ",") // ["a", "" , "b"]
// Split once: the remainder can contain the delimiter.
key, val, ok := strings.Cut("a=b=c", "=") // "a", "b=c", true
// Boundary checks are O(1) reads into the backing array — no copy.
if strings.HasPrefix(name, "tmp-") { ... }
strings.Cut deserves special attention: it returns the part before, the part after, and whether the delimiter existed at all, which is exactly the shape of key=value and name: value parsing. The pre-Cut idiom — strings.Index followed by two Substring-style slices — hides three bounds checks and an off-by-one that fuzzing loves to find. TrimSpace, TrimLeft, strings.ReplaceAll, strings.Contains, and strings.Join round out the reading toolkit. When the input is a byte slice from a network read, the bytes package mirrors all of these with the same names — bytes.Cut, bytes.Fields — and lets you parse without copying to a string first, which matters on hot paths where iterating bytes and runes already taught you the cost model.
Formatting: Say What You Mean With One Verb
fmt is the output layer, and its verbs exist because hand-built concatenation lies about types. A status line built with + renders a struct as its address; the right verb renders its fields. The set worth memorizing is small:
u := User{Name: "Ada", Email: "ada@example.com", ID: 42}
fmt.Sprintf("%v", u) // {Ada ada@example.com 42} — default display
fmt.Sprintf("%+v", u) // {Name:Ada Email:ada@example.com ID:42}
fmt.Sprintf("%#v", u) // users.User{Name:"Ada", ...} — Go source form, for debugging
fmt.Sprintf("%T", u) // users.User — dynamic type
fmt.Sprintf("%q", u.Email) // "ada@example.com" — quoted, escapes included
fmt.Sprintf("user %d <%s>", u.ID, u.Email) // user 42 <ada@example.com>
%v follows an interface when the value has one: implement String() string (the fmt.Stringer interface) and every %v, %s, and log line through fmt uses it. That is the clean intersection with interfaces and implicit satisfaction — one method changes how a type appears across the whole program:
func (d Duration) String() string {
if d.Hours >= 24 {
return fmt.Sprintf("%dd", d.Hours/24)
}
return fmt.Sprintf("%dh", d.Hours)
}
The performance rule follows from the API shape: fmt.Sprintf allocates a string, while fmt.Fprintf writes straight to an io.Writer. In a handler writing to http.ResponseWriter, or a logger writing to a file, use Fprintf — there is no reason to build an intermediate string only to copy it out:
fmt.Fprintf(w, "status=%d path=%s\n", code, r.URL.Path)
This is also where error messages come from: fmt.Errorf("%w", err) reuses the same formatting machinery to wrap an error while adding context.
Two traps. First, verbs must match the value: %s on an int prints %!s(int=5) instead of failing, so %d for integers, %f for floats, %t for booleans — go vet catches the common mismatches, which is why its output belongs in CI. Second, fmt.Sprintf in a hot loop allocates per call: a million formatted log lines are a million string allocations. When you are counting bytes on a profiled path, the answer is strconv.Append* into a reused buffer — the same family of tools, one layer down, covered next.
Numbers Crossing the String Boundary
A config loader reads MAX_CONNS=64 and a metrics reporter renders 42 into a gauge line. Both convert between strings and numbers, and fmt is the wrong tool for the parsing half: it cannot report "this was not a number" precisely, it cannot honor base prefixes, and it cannot tell you the value did not fit.
strconv is the dedicated package:
n, err := strconv.Atoi("64") // base 10, int-sized
if err != nil {
return fmt.Errorf("parse MAX_CONNS: %w", err)
}
// Full control: base 0 auto-detects 0x / 0o / 0b / 0 prefixes.
v, err := strconv.ParseInt(raw, 0, 64) // returns int64
if errors.Is(err, strconv.ErrRange) {
return fmt.Errorf("%q out of range for int64: %w", raw, err)
}
The arguments encode real decisions. base 0 means "respect the prefix the user wrote," which is why flag parsing uses it — --workers 0x10 means 16. bitSize is 64 or 32 to match the destination type, and it is what makes overflow an error instead of silent truncation: ParseInt("99999999999999999999", 10, 64) returns strconv.ErrRange wrapped in a *strconv.NumError carrying the function name, the original text, and the reason. Match it with errors.As at the boundary and with errors.Is for the sentinel — never with strings.Contains(err.Error(), "range").
The formatting direction has two levels. strconv.FormatInt(n, 10) and strconv.Itoa(n) build a fresh string; strconv.AppendInt(buf, n, 10) appends to a byte slice you already own. The Append family is what a hot formatter uses:
func appendGauge(dst []byte, name string, value int64) []byte {
dst = append(dst, name...)
dst = append(dst, '=')
dst = strconv.AppendInt(dst, value, 10)
return append(dst, '\n')
}
line := appendGauge(buf[:0], "queue_depth", 42) // reuses buf across calls
One allocation for the whole line instead of one per number — measurable in any benchmark that renders metrics.
The conversion that bites everyone once: string(65) does not produce "65". It converts a rune to its UTF-8 encoding, so string(65) is "A" and string(0x1F600) is an emoji. For digits use strconv.Itoa(65). For bytes, string(b) copies the slice — correct and idiomatic; the reverse, []byte(s), also copies, which is why APIs that hand you []byte and APIs that hand you string have different ownership rules. go vet flags the bare int-to-string conversion for exactly this reason, and the fix is always explicit: string(rune(n)) when you meant a character, strconv.Itoa(n) when you meant digits.
Quiz
1. You must split a log line at the FIRST '=' and keep everything after it, including later '=' characters. Which call is correct?
2. A function builds a large output string with `s += part` inside a loop over 10,000 parts. What is the idiomatic fix?
3. A handler writes a status line to http.ResponseWriter using fmt.Sprintf, then w.Write([]byte(s)). What does switching to fmt.Fprintf(w, ...) change?
4. Config text `MAX_CONNS=99999999999999999999` is parsed with strconv.ParseInt(raw, 10, 64). What does the error tell you, and how should it be checked?
Flashcards
Question
When do you reach for strings.Fields instead of strings.Split?
Click to reveal answer
Answer
Fields splits on runs of whitespace and drops empty fields, which is correct for human-typed words and command lines. Split on a fixed delimiter preserves empty fields, which is correct for CSV-like data where a,,b means three columns. Choosing Split for whitespace turns double spaces into phantom empty entries.
Question
What problem does strings.Builder solve that += does not, and what does Grow add?
Click to reveal answer
Answer
Immutable strings make += copy the accumulated result on every append — quadratic time. Builder appends into one growing buffer in place. Grow pre-allocates capacity so the buffer never doubles repeatedly; estimate with the input length when the output cannot be larger than it.
Question
What does implementing String() string change about how fmt treats your type?
Click to reveal answer
Answer
fmt.Stringer is satisfied implicitly, and fmt consults it for %v, %s, %q and anything formatting that value — so logs, error messages, and Sprintf all display your custom representation without callers passing a verb or a helper.
Question
Why does strconv.ParseInt take a bitSize argument?
Click to reveal answer
Answer
bitSize matches the destination type (32 or 64) so overflow is reported as strconv.ErrRange instead of silently wrapping. The same argument controls int vs int64 parsing, which is why the return value is always int64 and needs a conversion to int when the range is known.
Question
What is the difference between string(65) and strconv.Itoa(65)?
Click to reveal answer
Answer
string(65) converts an integer as a rune, yielding "A" — the UTF-8 encoding of code point 65. strconv.Itoa(65) yields "65", the digits. go vet flags bare int-to-string conversions because the rune interpretation is almost never intended; use string(rune(n)) when you do mean a character.
Revision Notes
Key Takeaways
- 1. Strings are immutable, so concatenation in a loop is quadratic — strings.Builder with Grow appends into one buffer
- 2. Fields, Split, and Cut encode three different parsing contracts; the wrong one silently changes empty-field semantics
- 3. fmt is for display: %v/+v/#v/q/T cover debugging, Stringer customizes all of them, and Fprintf formats straight into an io.Writer
- 4. strconv is for conversions with accountability: base selection, bitSize overflow as ErrRange, and NumError details extracted with errors.As
- 5. The Append family (AppendInt, AppendQuote) formats into a buffer you own, which is how hot paths avoid an allocation per value
Interview Tips
- • "Parse this config line" — reach for strings.Cut for the first delimiter, strconv.ParseInt with an explicit bitSize, and wrap the error with %w so the boundary can classify it
- • "Why not just use fmt for parsing?" — fmt cannot report non-numeric input precisely, cannot honor 0x prefixes, and has no overflow concept; strconv exists for all three
- • "How do you avoid allocations when formatting metrics?" — strconv.AppendInt into a reused []byte, and say why Sprintf in a loop shows up as a wall of allocs in a profile
- • "What's the bug in string(myInt)?" — it converts a rune, not digits; name the go vet warning and give Itoa as the fix
Cheat Sheet
strings, fmt, and strconv
Pick the string operation:
| Task | Call |
|---|---|
| words separated by whitespace | strings.Fields(s) |
| fixed delimiter, empty fields matter | strings.Split(s, ",") |
| first delimiter only, keep the rest | before, after, ok := strings.Cut(s, "=") |
| O(1) boundary check | strings.HasPrefix / HasSuffix / Contains |
| build a string | var b strings.Builder; b.Grow(n); b.WriteString(...) |
| merge pieces | strings.Join(parts, sep) |
fmt verbs: %v default, %+v named struct fields, %#v Go syntax for debugging, %T type, %q quoted string, %d/%f/%t typed, %w wrap an error. Write to a stream with fmt.Fprintf(w, ...) to skip the intermediate allocation.
strconv decisions:
| Need | Call |
|---|---|
| string -> int, base 10 | strconv.Atoi(s) |
| string -> int64 with prefixes honored | strconv.ParseInt(s, 0, 64) |
| range/overflow as an error | bitSize argument, check errors.Is(err, strconv.ErrRange) |
| number -> string | strconv.Itoa(n) / FormatInt(n, base) |
| allocation-free formatting into a buffer | strconv.AppendInt(dst, n, 10) |
Code template:
func parseLimit(raw string) (int, error) {
n, err := strconv.ParseInt(raw, 10, 32)
if err != nil {
return 0, fmt.Errorf("parse limit %q: %w", raw, err)
}
return int(n), nil
}
Trap: string(65) is "A" (rune conversion); digits need strconv.Itoa(65).