Skip to content

fmt, strings, and strconv

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.
VerbMeaning
%vDefault representation
%+vStruct with field names
%#vGo-syntax representation
%TType of the value
%dDecimal integer
%sString (unquoted)
%qDouble-quoted string with Go escaping
%fFloating-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 call

The 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)).

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 digits
fmt.Printf("[%6.2f]\n", 3.14159) // [ 3.14] — width 6, 2 decimal places
fmt.Printf("%x\n", 255) // ff — lowercase hex

A negative width left-aligns instead of right-aligning (%-8s); the same width syntax works for %d, %s, %q, and every other verb.

%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 %w
notWrapped := fmt.Sprintf("save failed: %w", baseErr) // go vet error
main.go:17:42: fmt.Sprintf does not support error-wrapping directive %w

go 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.

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") // true
strings.HasPrefix(s, "hello") // true
strings.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"

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-8
fmt.Println(len(s), utf8.RuneCountInString(s)) // 5 4 — byte count vs rune count differ
for 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 rune

Indexing 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.

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.Builder
b.Grow(64) // pre-size the buffer when you know roughly how much you'll write
for 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.

Three families of helpers arrived in recent releases and replace a lot of index arithmetic:

// Go 1.18: split on the FIRST separator
key, val, ok := strings.Cut("user=alice", "=") // "user", "alice", true
// Go 1.27: split on the LAST separator — replaces strings.LastIndex dances
dir, file, ok := strings.CutLast("a/b/c.txt", "/") // "a/b", "c.txt", true
// Go 1.24: lazy iterators — no intermediate []string allocation
text := "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 converts between strings and numeric types. The most common functions:

n, err := strconv.Atoi("42") // string → int
s := strconv.Itoa(42) // int → string
f, err := strconv.ParseFloat("3.14", 64) // string → float64
b, err := strconv.ParseBool("true") // string → bool

Atoi 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.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/op
BenchmarkSprint-11 35726424 34.59 ns/op 8 B/op 1 allocs/op

Same 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 full
len(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: 1
ParseInt base16: 42 <nil>
Quote: "line\tbreak\n"
TrimPrefix: aardvark
TrimLeft cutset trap: dvark
EqualFold: true
Fields: [many spaces here]
%08b: 00000101
%6.2f: [ 3.14]
%x: ff
Builder: hello Cap: true
copy panic recovered: strings: illegal use of non-zero Builder copied by value

The 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 'é'.

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"]
%v checks for Stringer/error first — a String() method that formats itself with %v recurses
What you getBenefitCost
fmt.SprintfFlexible string formattingAllocates a new string every call
fmt.Fprintf(w, …)Write to any io.WriterYou must check the returned error
strings.BuilderEfficient string concatenationMust call Reset() before reuse
%v / %+v / %#vInspect any valueReflection-based — slower than an explicit conversion
  • fmt.Println is fine for production logging — use a structured logger (log/slog, zap) in production instead; fmt has 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.Builder is fastest for many pieces in a loop.
  • %d and %v are very different — %d is explicit for integers, %v is generic but produces the same output for an int; they differ for structs.
  • rune and byte are the same thing — a byte is a uint8 (1 byte); a rune is an int32 (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 TrimPrefix for that.
  • %w works anywhere you’d use %v for an error — %w is meaningful only inside fmt.Errorf; every other fmt function rejects it at go vet time.

💡 Real-world example

kubectl formats table output with fmt.Fprintf and text/tabwriter, which aligns columns automatically.

Go HTTP servers build an error message with fmt.Sprintf and pass it to http.Error, which accepts a plain string.

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, or fmt.Sprint) instead of its individual fields — a recursion go vet flags but is easy to miss in review?
  • Does %w appear in any fmt call other than Errorf? It is a silent no-op error there that go vet also catches, but only if it runs.
  • Does code call strings.TrimLeft/TrimRight expecting prefix/suffix-string removal? Those take a cutset of bytes, not a substring — TrimPrefix/TrimSuffix are 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 strconv skipped in favour of fmt.Sprintf/fmt.Sprint for a simple number-to-string conversion in a hot path, paying for reflection that buys nothing?
  • Is a strings.Builder (or bytes.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 call

Fix 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)
}
Which format verb prints a struct with its field names?
What happens when this runs?
type P struct{ X int }
func (p P) String() string { return fmt.Sprintf("%v", p) }
fmt.Println(P{1})
What does this print, and why?
fmt.Println(strings.TrimLeft("aardvark", "ar"))
Why is strings.Builder preferred over += for building strings in a loop?
strconv.Atoi("42") returns which types?
What is wrong with fmt.Sprintf("result: %w", err)?