The Stringer Pattern
How to implement the fmt.Stringer interface to define custom string representations for your types in Go.
Every Go developer prints values during debugging or logging. The first surprise comes when a thoughtfully designed struct prints as a jumble of field values you have to squint at. The Stringer interface is Go's answer to that — a single method that lets your type control exactly how it presents itself as a string.
What the Stringer Interface Is
The fmt package defines an interface that looks like this:
type Stringer interface {
String() string
}
A type satisfies this interface when it has a method named String that takes no arguments and returns a string. That is the entire contract. If your type has that method, the fmt package — and many other packages that print values — will call it automatically instead of producing a default representation.
Why the Pattern Exists
Before Go 1.0, printing a struct with fmt.Println would dump its memory layout or a machine‑oriented view. It was raw data, not information. The Stringer interface was introduced so types could provide a human‑readable description that appears anywhere formatting verbs like %v or %s are used. This turns every struct into something that can describe itself — no extra wrapper functions, no separate Display() method that everyone forgets to call.
Beyond fmt, the pattern is used by logging libraries, test frameworks, and error‑reporting tools. When your error type implements Stringer, the text that appears in logs is under your control.
How the fmt Package Detects Stringer
The fmt package does not know about your type at compile time. At runtime, when you call fmt.Println(value), the formatting engine uses a type assertion to check whether the value implements fmt.Stringer. If it does, the String() method is called and the returned string is printed. If it does not, the package falls back to a default format based on reflection.
This runtime check has one consequence that surprises many newcomers: the receiver type of your String() method determines whether the check succeeds. If your method is on a pointer receiver but you pass a value, the assertion fails and you get the default representation. We will look at this in detail shortly.
The Stringer interface is in the fmt package:
The definition lives at fmt.Stringer. You never need to import anything extra — just having the method with the right signature on your type is enough.
Implementing String() on a Custom Type
The most common case is a struct with a few fields that should print in a clean, readable format. Here is a Person type that implements Stringer:
package main
import "fmt"
type Person struct {
Name string
Age int
}
func (p Person) String() string {
return fmt.Sprintf("%s (%d years)", p.Name, p.Age)
}
func main() {
a := Person{"Arthur Dent", 42}
z := Person{"Zaphod Beeblebrox", 9001}
fmt.Println(a, z)
}
Running this prints:
Arthur Dent (42 years) Zaphod Beeblebrox (9001 years)
Without the String() method, the same fmt.Println call would produce:
{Arthur Dent 42} {Zaphod Beeblebrox 9001}
The fmt.Sprintf inside String() uses specific format verbs (%s and %d) that do not trigger another call to String(). That is deliberate — it prevents infinite recursion, which we cover later.
Dealing with Non‑String Underlying Types
The String() method is not limited to structs. If you define a named type based on []byte, for example, you need to be careful with how you construct the string representation. A naive use of string() on individual bytes treats them as Unicode code points, not numeric values:
type IPAddr [4]byte
// WRONG — prints character codes instead of numbers
func (p IPAddr) String() string {
return string(p[0]) + "." + string(p[1]) + "." + string(p[2]) + "." + string(p[3])
}
When you print IPAddr{127, 0, 0, 1}, this version would interpret byte 127 as a character, not the number 127, producing garbled output. Instead, use fmt.Sprintf with %d to format each byte as its numeric value:
func (p IPAddr) String() string {
return fmt.Sprintf("%d.%d.%d.%d", p[0], p[1], p[2], p[3])
}
Now fmt.Println(IPAddr{127, 0, 0, 1}) prints 127.0.0.1. The rule is straightforward: when your underlying data is numeric and you want the digits to appear, avoid direct string() conversion and use fmt.Sprintf with %d.
Value Receivers vs Pointer Receivers
The choice between a value receiver and a pointer receiver on String() is not just about whether you need to mutate the receiver — it directly affects whether fmt can find your method.
Consider this type with a pointer receiver:
type Counter struct {
count int
}
func (c *Counter) String() string {
return fmt.Sprintf("count: %d", c.count)
}
When you pass a Counter value to fmt.Println, the runtime check for fmt.Stringer fails. The method String() belongs to *Counter, not Counter. The output will be the default struct representation, not your custom string:
c := Counter{10}
fmt.Println(c) // prints {10}, not "count: 10"
Pass a pointer and the method is found:
fmt.Println(&c) // prints "count: 10"
The same principle applies in reverse. If your type uses a value receiver, both a value and a pointer will satisfy the interface, because Go can automatically dereference a pointer to call a value‑receiver method. The safest approach is to use a value receiver for String() unless you have a specific reason to require a pointer — for example, when the type is large or when the method must observe mutations that happen elsewhere.
A pointer receiver can hide your String() method:
If you define String() on *T and then pass a T value to fmt.Println, you will silently get the default format. There is no compile‑time warning. Always test printing with both values and pointers when you first implement the method.
The Infinite Recursion Trap
The most dangerous mistake with String() is having the method call itself indirectly through fmt functions. This happens when you use %v, %s, or fmt.Sprint on the same type inside the String() method:
type User struct {
Name string
}
func (u User) String() string {
return fmt.Sprintf("User: %v", u) // INFINITE RECURSION
}
%v asks fmt to format u, which checks whether u implements Stringer. It does, so it calls String() again, which hits fmt.Sprintf again, and so on until the stack overflows.
The fix is to convert the value to a type that does not implement Stringer. The simplest way is to cast to the underlying type:
func (u User) String() string {
return fmt.Sprintf("User: %v", struct{ Name string }(u)) // safe
}
Or, more commonly, format fields individually without using %v on the whole struct:
func (u User) String() string {
return fmt.Sprintf("User{Name: %q}", u.Name) // safe
}
The same trap exists with fmt.Println inside String(). Never call fmt.Println on a value of your own type from within its String() method; use explicit field formatting instead.
Stack overflow from recursive String():
A call to fmt.Println or fmt.Sprintf with %v on the same type inside String() will crash your program with a stack overflow at runtime. The compiler cannot detect this. Always audit every formatting call inside String().
The Stringer Tool for iota Constants
The Go toolchain includes a command called stringer that generates String() methods for types defined with iota — integer constants that represent a fixed set of named values.
Given a type like this:
package painkiller
type Pill int
const (
Placebo Pill = iota
Aspirin
Ibuprofen
Paracetamol
)
Running go generate with a //go:generate stringer -type=Pill directive produces a file containing a String() method that maps each constant to its name. Without it, fmt.Println(Paracetamol) prints the integer 3. With the generated method, it prints Paracetamol.
This is not a language feature — it is a code‑generation tool. The generated String() method uses a switch statement and a map lookup behind the scenes, avoiding infinite recursion because it formats using plain string concatenation, not fmt functions on the same type.
Generated code is safe from recursion:
The stringer tool emits a String() method that never calls back into fmt on the original type. If you use it, you are protected from the recursion pitfall automatically.
Best Practices
- Use a value receiver for
String()unless your type absolutely requires a pointer receiver. This ensures the method is available on both values and pointers. - Never format the receiver itself with
%v,%s, orfmt.SprintinsideString(). Format individual fields or cast to an anonymous type. - Make the output stable and side‑effect‑free.
String()may be called by debuggers, loggers, or race detectors — it should not modify state or produce different output for the same input. - Avoid expensive computations inside
String(). If formatting requires a heavy operation, cache the result or perform the work elsewhere. - Handle nil receivers gracefully if you use a pointer receiver. A
String()method on a nil pointer should not panic; return something like"<nil>".
Common Mistakes at a Glance
- Forgetting that
string()on a byte or rune produces a character, not a digit. Usefmt.Sprintfwith%dfor numeric values. - Defining
String()on a pointer receiver and then passing a value tofmt.Println. The interface check fails silently. - Causing infinite recursion by using
%vor%son the same type insideString(). - Leaving
String()undefined for a type that wraps a built‑in string. When you definetype MyString stringand give it aString()method, printing a value ofMyStringwill call that method. If your method callsfmt.Printlnon the value, you hit recursion just like with structs.
Summary
The Stringer pattern replaces opaque default representations with a description you control. The fmt package finds your String() method through a runtime interface check, so the receiver type matters. The main danger is letting fmt call String() recursively — a single misplaced %v can take down your program. The pattern extends naturally to named types of any underlying kind, and for iota-based constants the stringer tool eliminates boilerplate. Once you internalize the receiver‑checking behavior and the recursion rule, you will reach for String() without a second thought every time you need readable output.