The mystery of the missing field names
You are debugging a command-line tool. You print a user struct to the terminal and get {42 Alice admin}. You stare at the output. Which number is the ID? Which string is the role? You switch to %+v and suddenly the terminal shows {ID:42 Name:Alice Role:admin}. The mystery solves itself. This is the core tension in Go's fmt package. The package gives you a handful of formatting verbs, each with a specific job. Pick the wrong one and the program crashes. Pick the right one and your logs become instantly readable.
Go does not hide formatting decisions behind magic. The language forces you to declare exactly how a value should appear as text. That explicitness keeps output predictable across different environments, different Go versions, and different teams. You trade a few extra characters in the format string for zero ambiguity in the terminal.
How verbs actually work
A verb is a placeholder inside a format string. It starts with a percent sign and tells the fmt package exactly how to convert a Go value into a sequence of bytes. Think of a factory assembly line with different stamping machines. One machine stamps barcodes on boxes. Another stamps nutritional labels. Another stamps fragile stickers. You hand the machine a box, and it applies the correct stamp based on the instruction. If you hand a fragile sticker machine a liquid container, the line stops.
Go's fmt package works the same way. %s expects a string or a byte slice. %d expects an integer. %v is the general-purpose machine that accepts almost anything and falls back to a sensible default. The package checks the type of the argument at runtime. If the type matches the verb's expectation, it formats the value. If it does not, the program panics.
The fmt package also supports flags and width modifiers that sit between the percent sign and the verb letter. A + flag adds extra information. A # flag requests a language-specific syntax. A number before the verb sets the minimum field width. A . followed by a number sets precision. These modifiers stack on top of the base verb. They give you fine-grained control without requiring custom printer functions.
Minimal example
package main
import "fmt"
// User represents a basic account record.
type User struct {
ID int
Name string
Role string
}
// main demonstrates how different verbs render the same value.
func main() {
u := User{ID: 42, Name: "Alice", Role: "admin"}
// %s only accepts strings or []byte. It prints the raw text without quotes.
fmt.Printf("Raw text: %s\n", u.Name)
// %d only accepts integers. It prints the number in base 10.
fmt.Printf("Integer: %d\n", u.ID)
// %v accepts any type. It uses the default format for each field.
fmt.Printf("Default: %v\n", u)
// %+v adds struct field names to the default output.
fmt.Printf("Debug: %+v\n", u)
// %#v prints a Go-syntax literal with type information and quotes.
fmt.Printf("Syntax: %#v\n", u)
// Width and precision modifiers control alignment and decimal places.
fmt.Printf("Padded: %10s\n", u.Name)
fmt.Printf("Float: %.2f\n", 3.14159)
}
What happens under the hood
When the runtime encounters %v, it does not guess. It follows a strict resolution chain. First, it checks if the value implements the fmt.Stringer interface. That interface requires a single method: String() string. If the type provides it, fmt calls the method and uses the result. If not, it falls back to the type's default representation. For structs, that means printing each field in order, separated by spaces, wrapped in curly braces.
The + flag modifies the verb's behavior. Adding + to %v tells the formatter to include extra information. For structs, that extra information is the field names. For pointers, it prints the memory address. The # flag takes a different approach. It asks for a Go-syntax representation. The formatter reconstructs the value as if you had typed it directly into a source file. Strings get quotes. Structs get their package-qualified type name. Integers stay as integers.
This design keeps the fmt package small. Instead of writing custom printers for every type, you implement one method and let the standard library handle the rest. The convention is simple: if you want your type to print nicely in logs, give it a String() method. The receiver name should be short, usually one or two letters matching the type. (u User) String() string reads naturally. (this User) String() string breaks community expectations. Stick to the short receiver name and the code stays idiomatic.
The fmt package also respects the fmt.Formatter interface for advanced control. That interface defines a Format(f fmt.State, c rune) method. It gives you direct access to the formatting state machine. You rarely need it. Most projects only require String() string. When you do implement Format, you gain control over width, precision, and flag parsing. The standard library uses it for time values, complex numbers, and custom error types.
Goroutines do not share format strings. Each call to Printf or Sprintf runs independently. The fmt package allocates a small internal buffer for each call. It reuses that buffer for the duration of the function. When the function returns, the buffer is eligible for garbage collection. This design prevents race conditions in logging code. You can safely call fmt.Printf from multiple goroutines without locks.
Realistic example
package main
import (
"fmt"
"log"
"net/http"
)
// RequestLog holds metadata for an incoming HTTP request.
type RequestLog struct {
Method string
Path string
Status int
User string
}
// String formats the log entry for quick console output.
// It follows the convention of returning a human-readable summary.
func (r RequestLog) String() string {
return fmt.Sprintf("%s %s -> %d", r.Method, r.Path, r.Status)
}
// handleRequest simulates a web handler that logs incoming traffic.
func handleRequest(w http.ResponseWriter, r *http.Request) {
entry := RequestLog{
Method: r.Method,
Path: r.URL.Path,
Status: 200,
User: "guest",
}
// The String() method runs automatically with %v.
// This keeps production logs concise and machine-parseable.
log.Printf("Quick log: %v", entry)
// %+v ignores String() and prints the full struct with names.
// Use this during development to trace field values.
log.Printf("Full debug: %+v", entry)
// %#v generates a copy-pasteable Go literal for test fixtures.
// Developers use this to seed unit tests without manual typing.
log.Printf("Fixture: %#v", entry)
w.WriteHeader(200)
}
Pitfalls and runtime panics
The fmt package refuses to guess your intent. Pass a float to %d and the program crashes with panic: fmt: %d has invalid verb. Pass a struct to %s and you get panic: fmt: %s has invalid verb. The compiler does not catch these mistakes because the format string is just a string literal. The type checking happens at runtime.
You can avoid the panic by using %v as a fallback. %v accepts any type and formats it safely. The tradeoff is verbosity. %v on a complex nested struct produces a wall of text. %+v makes it readable. %#v makes it precise.
Another common trap is forgetting that %s does not automatically convert integers or structs to strings. Go does not perform implicit type coercion. If you need a string representation of an integer, use strconv.Itoa or fmt.Sprintf("%d", n). If you need a string representation of a struct, implement String() string or use fmt.Sprintf("%v", s).
The compiler will also complain if you pass the wrong number of arguments. Forget one and you get panic: fmt: %!v(MISSING). Pass too many and you get panic: fmt: %!(EXTRA type=value). These runtime panics are loud by design. They force you to fix the format string before the bug reaches production.
Context cancellation does not stop fmt operations. The fmt package does not accept a context.Context parameter. It runs synchronously and returns immediately. If you need cancellable logging, wrap the call in a select statement or use a logging library that respects context deadlines. The convention remains clear: context.Context always goes as the first parameter in functions that support cancellation. fmt functions are pure formatters. They do not manage lifecycles.
Do not pass a *string to %s. Strings are already cheap to pass by value. Dereferencing a pointer just to print it adds unnecessary indirection. Pass the string directly. The compiler will handle the copy efficiently.
When to use which verb
Use %s when you have a string or byte slice and want the raw text without quotes or escaping. Use %d when you need a base-10 integer and want to guarantee the input is numeric. Use %v when you want a quick, readable representation of any value and are okay with the default formatting. Use %+v when you are debugging a struct and need to see which field holds which value. Use %#v when you need to generate a Go-syntax literal for test fixtures, configuration files, or code generation. Use fmt.Sprintf when you need to build a string value instead of printing it directly to standard output. Use plain sequential code when you don't need formatting: the simplest thing that works is usually the right thing.
Pick the verb that matches the data shape. Let the formatter do the heavy lifting. Trust the standard library.