fmt, strings, and strconv
fmt — formatted I/O
Section titled “fmt — formatted I/O”The fmt package is the entry point for almost all output in Go. Three functions cover the vast majority of use cases:
fmt.Println(args...)— prints each argument separated by spaces, followed by a newline.fmt.Printf(format, args...)— prints using a format string with verbs.fmt.Sprintf(format, args...)— returns the formatted string instead of printing it.
Format verbs
Section titled “Format verbs”| Verb | Meaning |
|---|---|
%v | Default representation |
%+v | Struct with field names |
%#v | Go-syntax representation |
%T | Type of the value |
%d | Decimal integer |
%s | String (unquoted) |
%q | Double-quoted string with Go escaping |
%f | Floating-point decimal |
type Person struct { Name string; Age int }p := Person{"Alice", 30}fmt.Printf("%v\n", p) // {Alice 30}fmt.Printf("%+v\n", p) // {Name:Alice Age:30}fmt.Printf("%#v\n", p) // main.Person{Name:"Alice", Age:30}fmt.Printf("%T\n", p) // main.Person%v calls String() — and can recurse into it
Section titled “%v calls String() — and can recurse into it”When a value implements fmt.Stringer (String() string) or error, %v (and %s, Println, and every verb that falls back to the default representation) calls that method instead of printing the struct’s fields. This is convenient — until the String() method itself formats the receiver with %v:
type Point struct{ X, Y int }
func (p Point) String() string { return fmt.Sprintf("%v", p) // BUG: %v on a Stringer calls String() again}go vet catches this specific shape at compile time — it is common enough in AI-generated code to have its own analyzer:
main.go:8:22: fmt.Sprintf format %v with arg p causes recursive (command-line-arguments.Point).String method callThe fix is to format the underlying fields, or a converted type that does not carry the method: fmt.Sprintf("(%d, %d)", p.X, p.Y), or fmt.Sprintf("%v", struct{ X, Y int }(p)).
Width and padding verbs
Section titled “Width and padding verbs”Every verb accepts a width and, for floats, a precision, written before the verb letter:
fmt.Printf("%08b\n", 5) // 00000101 — zero-padded to 8 digitsfmt.Printf("[%6.2f]\n", 3.14159) // [ 3.14] — width 6, 2 decimal placesfmt.Printf("%x\n", 255) // ff — lowercase hexA negative width left-aligns instead of right-aligning (%-8s); the same width syntax works for %d, %s, %q, and every other verb.
%w is only valid inside fmt.Errorf
Section titled “%w is only valid inside fmt.Errorf”%w is not a general formatting verb — it is a signal to fmt.Errorf specifically, telling it to wrap the given error so errors.Is/errors.As/errors.Unwrap can see through it later. Every other fmt function treats %w as an error and refuses to compile with go vet:
baseErr := errors.New("disk full")wrapped := fmt.Errorf("save failed: %w", baseErr) // OK — Errorf understands %wnotWrapped := fmt.Sprintf("save failed: %w", baseErr) // go vet errormain.go:17:42: fmt.Sprintf does not support error-wrapping directive %wgo vet (part of go build’s default checks since Go 1.10 for a subset of analyzers, and always run by go test) rejects this at compile-check time, so the mistake rarely reaches production — but it is worth recognising when reading a diff, because AI-generated code sometimes copies a %w-bearing format string into a Sprintf or Println call by habit.
strings — text manipulation
Section titled “strings — text manipulation”The strings package provides functions for searching, splitting, joining, and transforming strings. Strings in Go are immutable UTF-8 byte sequences; these functions return new strings rather than modifying in place.
s := "hello, world"strings.Contains(s, "world") // truestrings.HasPrefix(s, "hello") // truestrings.Split(s, ", ") // ["hello" "world"]strings.Join([]string{"a","b"}, "-") // "a-b"strings.ReplaceAll(s, "l", "L") // "heLLo, worLd"strings.ToUpper(s) // "HELLO, WORLD"strings.TrimSpace(" hi ") // "hi"Strings are bytes; range walks runes
Section titled “Strings are bytes; range walks runes”A Go string is an immutable sequence of bytes, usually holding UTF-8 text. len(s) counts bytes, and s[i] indexes a single byte — not a character. for range over a string, by contrast, decodes UTF-8 and gives you the rune (a Unicode code point, int32) at each position, together with the byte offset it started at:
s := "café" // é is 2 bytes in UTF-8fmt.Println(len(s), utf8.RuneCountInString(s)) // 5 4 — byte count vs rune count differfor i, r := range s { fmt.Printf("byte %d: rune %q\n", i, r)}fmt.Println(s[3]) // 195 — a raw UTF-8 continuation byte, not a printable runeIndexing s[i] with an arithmetic offset (rather than iterating with range or converting to []rune) is only safe for pure-ASCII strings; any non-ASCII input silently slices into the middle of a multi-byte character.
TrimPrefix vs TrimLeft: the cutset trap
Section titled “TrimPrefix vs TrimLeft: the cutset trap”strings.TrimPrefix(s, prefix) removes prefix only if s starts with that exact string, once. strings.TrimLeft(s, cutset) looks completely different: it treats cutset as a set of individual bytes, and strips every leading byte that appears anywhere in that set — not a prefix string:
strings.TrimPrefix("aardvark", "ar") // "aardvark" — does not start with "ar"strings.TrimLeft("aardvark", "ar") // "dvark" — strips every leading 'a' or 'r'AI-generated code that reaches for TrimLeft expecting prefix-string behaviour produces a plausible-looking but wrong result on any input where the cutset characters repeat or appear in the wrong order — use TrimPrefix (or CutPrefix) whenever the intent is “remove this exact prefix.”
strings.Builder — efficient concatenation
Section titled “strings.Builder — efficient concatenation”When building a string from many pieces in a loop, use strings.Builder instead of +=. The += operator allocates a new string on every iteration; Builder writes into a growing buffer and only allocates the final string once.
var b strings.Builderb.Grow(64) // pre-size the buffer when you know roughly how much you'll writefor i := 0; i < 5; i++ { fmt.Fprintf(&b, "item%d ", i)}result := strings.TrimSpace(b.String()) // "item0 item1 item2 item3 item4"Builder is not safe to copy once it holds data — like bytes.Buffer, but strings.Builder actively enforces it. Copying a non-zero Builder by value and then writing to the copy panics at runtime with strings: illegal use of non-zero Builder copied by value, because the copy carries a pointer back to the original’s buffer that the runtime detects has moved.
Cut, CutLast, and iterator helpers
Section titled “Cut, CutLast, and iterator helpers”Three families of helpers arrived in recent releases and replace a lot of index arithmetic:
// Go 1.18: split on the FIRST separatorkey, val, ok := strings.Cut("user=alice", "=") // "user", "alice", true
// Go 1.27: split on the LAST separator — replaces strings.LastIndex dancesdir, file, ok := strings.CutLast("a/b/c.txt", "/") // "a/b", "c.txt", true
// Go 1.24: lazy iterators — no intermediate []string allocationtext := "a=1\nb=2\n"for line := range strings.Lines(text) { fmt.Print(line) } // keeps the "\n"for part := range strings.SplitSeq("a,b,c", ",") { fmt.Println(part) }for word := range strings.FieldsSeq(" a b ") { fmt.Println(word) }Lines, SplitSeq, and FieldsSeq return iter.Seq[string]; use them when you only need to walk the pieces once, and keep Split/Fields when you need a slice you index into. bytes has the same set (bytes.CutLast and friends).
Two other small additions worth knowing: the new uuid package (Go 1.27) generates and parses UUIDs without a third-party module, and log/slog.NewMultiHandler (Go 1.26) fans one logger out to several handlers.
strconv — string-to-number conversions
Section titled “strconv — string-to-number conversions”strconv converts between strings and numeric types. The most common functions:
n, err := strconv.Atoi("42") // string → ints := strconv.Itoa(42) // int → stringf, err := strconv.ParseFloat("3.14", 64) // string → float64b, err := strconv.ParseBool("true") // string → boolAtoi and ParseFloat return (value, error) because the input might not be a valid number. Always check the error before using the value.
ParseInt(s, base, bitSize) handles other bases (strconv.ParseInt("2A", 16, 64) → 42), and strconv.Quote produces a Go-syntax-quoted string with escapes made visible — useful for logging a value that might contain control characters.
strconv vs fmt.Sprint for numbers
Section titled “strconv vs fmt.Sprint for numbers”strconv.Itoa(n) and fmt.Sprint(n) produce the same string for an int, but they are not equally cheap. fmt.Sprint boxes its argument into an any, then uses reflection to discover it is an integer before formatting it; strconv.Itoa converts directly with no boxing or reflection. Benchmarked on this machine:
BenchmarkItoa-11 92186020 12.60 ns/op 8 B/op 1 allocs/opBenchmarkSprint-11 35726424 34.59 ns/op 8 B/op 1 allocs/opSame allocation count, but fmt.Sprint costs roughly 2.7x more CPU time per call — reflection is not free even when it does not allocate more. Prefer strconv in a hot path where you know the concrete type; reach for fmt.Sprintf when you need to interpolate several values into a template.
package main
import ( "fmt" "strconv" "strings")
type Person struct { Name string Age int}
func main() { // fmt verbs p := Person{Name: "Alice", Age: 30} fmt.Printf("%v\n", p) fmt.Printf("%+v\n", p) fmt.Printf("%#v\n", p) fmt.Printf("%T\n", p) fmt.Printf("%d %s %q\n", 42, "hello", "world")
// Sprintf builds a string s := fmt.Sprintf("Name: %s, Age: %d", p.Name, p.Age) fmt.Println(s)
// strings package text := " Go is great " fmt.Println(strings.TrimSpace(text)) fmt.Println(strings.Contains(text, "great")) fmt.Println(strings.ToUpper(strings.TrimSpace(text)))
words := strings.Split("one,two,three", ",") fmt.Println(words) fmt.Println(strings.Join(words, " | ")) fmt.Println(strings.ReplaceAll("aabbcc", "b", "x"))
// strings.Builder — efficient concatenation var b strings.Builder for i := 0; i < 3; i++ { fmt.Fprintf(&b, "item%d ", i) } fmt.Println(strings.TrimSpace(b.String()))
// strconv n, err := strconv.Atoi("123") if err == nil { fmt.Println(n + 1) } fmt.Println(strconv.Itoa(456)) f, err := strconv.ParseFloat("3.14", 64) if err == nil { fmt.Printf("%.2f\n", f) }} package main
import ( "errors" "fmt" "strconv" "strings" "testing" "unicode/utf8")
func main() { // 1. %w is only meaningful in fmt.Errorf — elsewhere it prints a literal error marker baseErr := errors.New("disk full") wrapped := fmt.Errorf("save failed: %w", baseErr) fmt.Println("Errorf %w:", wrapped, "| Unwrap:", errors.Unwrap(wrapped))
// 2. strings are bytes; range walks runes; len() and s[i] are byte-oriented s := "café" // é is 2 bytes in UTF-8 fmt.Println("len(s):", len(s), "utf8.RuneCountInString(s):", utf8.RuneCountInString(s)) for i, r := range s { fmt.Printf(" byte offset %d: rune %q (%d)\n", i, r, r) } fmt.Println("s[3]:", s[3], "(a raw byte, not a rune)")
// 3. strconv vs fmt.Sprint — allocation count via testing.AllocsPerRun n := 123456 allocsStrconv := testing.AllocsPerRun(1000, func() { _ = strconv.Itoa(n) }) allocsSprint := testing.AllocsPerRun(1000, func() { _ = fmt.Sprint(n) }) fmt.Printf("allocs/op strconv.Itoa: %.0f fmt.Sprint: %.0f\n", allocsStrconv, allocsSprint)
// strconv.ParseInt and Quote v, err := strconv.ParseInt("2A", 16, 64) fmt.Println("ParseInt base16:", v, err) fmt.Println("Quote:", strconv.Quote("line\tbreak\n"))
// 4. TrimPrefix (exact string) vs TrimLeft (cutset of individual bytes) fmt.Println("TrimPrefix:", strings.TrimPrefix("aardvark", "ar")) fmt.Println("TrimLeft cutset trap:", strings.TrimLeft("aardvark", "ar"))
// EqualFold and Fields fmt.Println("EqualFold:", strings.EqualFold("Go", "GO")) fmt.Println("Fields:", strings.Fields(" many spaces here "))
// 5. width and padding verbs fmt.Printf("%%08b: %08b\n", 5) fmt.Printf("%%6.2f: [%6.2f]\n", 3.14159) fmt.Printf("%%x: %x\n", 255)
// 6. strings.Builder — Grow then write, then show the copy panic var b strings.Builder b.Grow(64) b.WriteString("hello") fmt.Println("Builder:", b.String(), "Cap:", b.Cap() >= 64)
func() { defer func() { if r := recover(); r != nil { fmt.Println("copy panic recovered:", r) } }() b2 := b // copying a non-zero Builder by value b2.WriteString(" world") }()} Real output from this program on Go 1.26.5:
Errorf %w: save failed: disk full | Unwrap: disk fulllen(s): 5 utf8.RuneCountInString(s): 4 byte offset 0: rune 'c' (99) byte offset 1: rune 'a' (97) byte offset 2: rune 'f' (102) byte offset 3: rune 'é' (233)s[3]: 195 (a raw byte, not a rune)allocs/op strconv.Itoa: 1 fmt.Sprint: 1ParseInt base16: 42 <nil>Quote: "line\tbreak\n"TrimPrefix: aardvarkTrimLeft cutset trap: dvarkEqualFold: trueFields: [many spaces here]%08b: 00000101%6.2f: [ 3.14]%x: ffBuilder: hello Cap: truecopy panic recovered: strings: illegal use of non-zero Builder copied by valueThe range loop over "café" visits 4 runes at byte offsets 0, 1, 2, 3 — the 4-byte-wide é occupies offsets 3 and 4, which is why len(s) is 5 while utf8.RuneCountInString(s) is 4, and s[3] yields a lone continuation byte (195), not 'é'.
The %v dispatch path
Section titled “The %v dispatch path”flowchart LR
Value["value passed to %v"] --> Check{"implements Stringer or error?"}
Check -- "yes" --> Call["call String() / Error()"]
Check -- "no" --> Reflect["reflect: walk struct fields"]
Call -- "calls %v on itself" --> Loop["infinite recursion — go vet catches this"] Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| fmt.Sprintf | Flexible string formatting | Allocates a new string every call |
| fmt.Fprintf(w, …) | Write to any io.Writer | You must check the returned error |
| strings.Builder | Efficient string concatenation | Must call Reset() before reuse |
| %v / %+v / %#v | Inspect any value | Reflection-based — slower than an explicit conversion |
Common Misconceptions
Section titled “Common Misconceptions”- fmt.Println is fine for production logging — use a structured logger (
log/slog, zap) in production instead;fmthas no log levels, structured fields, or JSON output. - String concatenation with + is faster than fmt.Sprintf — it depends on how many concatenations there are;
strings.Builderis fastest for many pieces in a loop. - %d and %v are very different —
%dis explicit for integers,%vis generic but produces the same output for an int; they differ for structs. - rune and byte are the same thing — a
byteis auint8(1 byte); aruneis anint32(1-4 bytes for a Unicode code point). - TrimLeft(s, “prefix”) removes the prefix “prefix” — it removes leading bytes found anywhere in the cutset, not a prefix string; use
TrimPrefixfor that. - %w works anywhere you’d use %v for an error —
%wis meaningful only insidefmt.Errorf; every otherfmtfunction rejects it atgo vettime.
💡 Real-world example
kubectl formats table output with
fmt.Fprintfandtext/tabwriter, which aligns columns automatically.Go HTTP servers build an error message with
fmt.Sprintfand pass it tohttp.Error, which accepts a plain string.
Reading AI-generated code
Section titled “Reading AI-generated code”fmt/strings/strconv code compiles almost regardless of whether it is correct, which makes it easy to skim past a subtle bug. Check for these:
- Does a
String()method format the receiver with%v(or%s, orfmt.Sprint) instead of its individual fields — a recursiongo vetflags but is easy to miss in review? - Does
%wappear in anyfmtcall other thanErrorf? It is a silent no-op error there thatgo vetalso catches, but only if it runs. - Does code call
strings.TrimLeft/TrimRightexpecting prefix/suffix-string removal? Those take a cutset of bytes, not a substring —TrimPrefix/TrimSuffixare what “remove this exact string” means. - Does code index a string by byte offset (
s[i],s[i:j]) when the input might contain non-ASCII text? That can slice into the middle of a multi-byte rune. - Is
strconvskipped in favour offmt.Sprintf/fmt.Sprintfor a simple number-to-string conversion in a hot path, paying for reflection that buys nothing? - Is a
strings.Builder(orbytes.Buffer) copied by value after it has been written to, instead of passed by pointer?
🐛 Spot the bug
type Point struct{ X, Y int }
func (p Point) String() string { return fmt.Sprintf("%v", p)}
func main() { p := Point{1, 2} fmt.Println(p)}Show the answer
%v on a value that implements fmt.Stringer calls String() — but String() here formats p with %v again, calling itself forever. go vet catches this exact shape at build time, before the program ever runs:
main.go:8:22: fmt.Sprintf format %v with arg p causes recursive (command-line-arguments.Point).String method callFix it by formatting the underlying fields instead of the receiver itself:
func (p Point) String() string { return fmt.Sprintf("(%d, %d)", p.X, p.Y)}