1. Why fmt.Stringer exists and how fmt uses it
When you’re just starting to write programs, it seems like output is simply “well, just show a number and a string.” But as soon as you have structures (struct) with several fields (and we already do), default output quickly turns into a mess: which part is the field, which part is the value, why is it printed exactly like this, and why is it hard to read. And it gets even worse when you read logs where each struct is printed “however it comes out,” and then you sit there like an archaeologist over ancient ruins: “so… there was a Done field here… probably…”.
Go has a good principle: if a type matters in your program, it should be able to represent itself as a string in a reasonable way. Not necessarily always, but at least for debugging, logging, and user-facing output. And this is where the standard library saves us.
The fmt.Stringer interface and the check inside fmt
The nicest part: you don’t need to “hack” fmt, write your own printers, or reinvent the wheel with square wheels. The fmt package already has a convention: if a value can turn itself into a string, fmt will happily use that.
That convention is expressed by the fmt.Stringer interface. In this lecture we treat an interface simply as a “one-method contract” (without diving into the subtleties of interface values and nil traps): if a type has a method with the right signature, then the type fits the contract.
Here is what the essence of Stringer looks like:
type Stringer interface {
String() string
}
The idea is very similar to how fmt works with errors: an error is a value that has an Error() string method, and fmt prints it through that method. With Stringer it’s the exact same logic, just for “regular” types.
To “see” the mechanics, it’s convenient to imagine a mini diagram:
flowchart TD
A["fmt.Println(x)"] --> B{"does x implement String() string ?"}
B -- yes --> C["call x.String()"]
C --> D[print the resulting string]
B -- no --> E[use standard fmt formatting]
E --> D
Notice: this isn’t magic. It’s just a check for “does the value have a String() method?”
2. Example: making Task look nice
Let’s keep evolving our training app: a small “task manager.” We’re not jumping to a real CLI (that’s for separate days), but we can already make the data model pleasant: there’s a task, it has a title and a completion flag.
Let’s start with a simple struct and see how it prints “by default”:
package main
import "fmt"
type Task struct {
ID int
Title string
Done bool
}
func main() {
t := Task{ID: 1, Title: "Read about Stringer", Done: false}
fmt.Println(t) // {1 Read about Stringer false}
}
That output… well… it’s “technically true.” But it’s not always quick to understand at a glance what is what. Let’s add String().
Important: the String() method must return a string, not print by itself. Printing is fmt’s job; your job is to provide a nice representation.
package main
import "fmt"
type Task struct {
ID int
Title string
Done bool
}
func (t Task) String() string {
if t.Done {
return fmt.Sprintf("#%d %q (done)", t.ID, t.Title)
}
return fmt.Sprintf("#%d %q (todo)", t.ID, t.Title)
}
func main() {
t := Task{ID: 1, Title: "Read about Stringer", Done: false}
fmt.Println(t) // #1 "Read about Stringer" (todo)
}
Pay attention to %q: it prints a string in quotes and escapes special characters. For debugging this is often more useful than plain %s, because you immediately see where spaces, tabs, and other “invisible artists” are.
And now the “magic”: fmt.Println() calls not the “struct output,” but our t.String().
3. Receiver for String(): T or *T
Now comes the moment where knowledge from earlier lectures starts working “in real life,” not just in an exercise like “move the point 10 steps to the right.” We already know:
- func (t Task) ... — value receiver, you get a copy.
- func (t *Task) ... — pointer receiver, you get an address.
But String() has a special trait: it almost always must not mutate the object. It’s a method that “looks at itself in the mirror and describes what it sees.” If the mirror changes your face in the process, the mirror is suspicious.
That’s why the typical choice is to implement String() on a value receiver.
There’s also a more pragmatic reason: if String() is declared on Task (the value), then both Task and *Task will print nicely. But if you declare String() on *Task, then nice output is guaranteed only for the pointer, while the value may print “however it comes out” — that’s a direct consequence of the method set.
Let’s compare with a small example:
package main
import "fmt"
type Note struct {
Text string
}
func (n *Note) String() string {
return "NOTE: " + n.Text
}
func main() {
n := Note{Text: "don't forget about receiver"}
fmt.Println(n) // {don't forget about receiver}
fmt.Println(&n) // NOTE: don't forget about receiver
}
Why does this happen? Because Note (the value) is not required to have String() in its method set if that method is declared only for *Note. But *Note “sees” more methods.
To make it easier to navigate, here is a compact cheat-sheet table:
| Where String() is declared | T prints nicely | *T prints nicely | Typical use |
|---|---|---|---|
|
yes | yes | almost always the best choice |
|
sometimes no | yes | rare case (usually not needed) |
One more nuance: if you use a pointer receiver for String(), you must remember that you can end up with a nil pointer. Today we’re not going into interface subtleties, but it’s worth keeping basic caution in mind: if the method is declared on *T, then t inside can be nil, and any field access (t.Title) will panic.
4. String() for statuses: switch and default
Tasks often have “states”: new, in progress, done, canceled. In Go, a popular approach is to make a named type based on int and constants. And so we don’t show users numbers like 0, 1, 2, we implement String().
The classic pattern for such a String() is switch + default, where default returns something diagnosable. This is good practice: an unknown value should print in a way that makes it obvious it’s unknown, rather than quietly lying.
Let’s add a status to our task:
package main
import "fmt"
type Status int
const (
StatusTodo Status = iota
StatusDone
)
func (s Status) String() string {
switch s {
case StatusTodo:
return "todo"
case StatusDone:
return "done"
default:
return fmt.Sprintf("Status(%d)", int(s))
}
}
Now let’s update Task and its String():
package main
import "fmt"
type Status int
const (
StatusTodo Status = iota
StatusDone
)
func (s Status) String() string {
switch s {
case StatusTodo:
return "todo"
case StatusDone:
return "done"
default:
return fmt.Sprintf("Status(%d)", int(s))
}
}
type Task struct {
ID int
Title string
Status Status
}
func (t Task) String() string {
return fmt.Sprintf("#%d %q [%s]", t.ID, t.Title, t.Status)
}
func main() {
t1 := Task{ID: 1, Title: "Understand Stringer", Status: StatusTodo}
t2 := Task{ID: 2, Title: "Be happy", Status: StatusDone}
fmt.Println(t1) // #1 "Understand Stringer" [todo]
fmt.Println(t2) // #2 "Be happy" [done]
}
Notice: inside Task.String() we format t.Status using %s. But %s expects a string. Why does this work? Because Status implements Stringer, and fmt knows how to extract the string representation via String().
5. Stringer and formatting: %v and “raw” fields
When you add String(), you get very nice human-readable output. But there’s a flip side: sometimes while debugging you want to see all fields of a struct, while String() shows only a “pretty storefront.”
This isn’t a bug; it’s expected: if fmt sees String(), it assumes you know best how to print your type.
Let’s take a look:
package main
import "fmt"
type Task struct {
ID int
Title string
Done bool
}
func (t Task) String() string {
if t.Done {
return "done: " + t.Title
}
return "todo: " + t.Title
}
func main() {
t := Task{ID: 10, Title: "debug output", Done: false}
fmt.Println(t) // todo: debug output
fmt.Printf("%v\n", t) // todo: debug output
fmt.Printf("%T\n", t) // main.Task
}
Sometimes that’s great. But if you temporarily need to see the “raw” object, there’s a simple trick: create a temporary alias type without methods and cast the value to it. It looks a bit “hacky,” but it’s actually honest and transparent: you’re telling the compiler “treat this as another type that does not have String().”
package main
import "fmt"
type Task struct {
ID int
Title string
Done bool
}
func (t Task) String() string {
return fmt.Sprintf("Task#%d %q", t.ID, t.Title)
}
type rawTask Task
func main() {
t := Task{ID: 10, Title: "see fields", Done: false}
fmt.Println(t) // Task#10 "see fields"
fmt.Printf("%+v\n", rawTask(t)) // {ID:10 Title:see fields Done:false}
}
Yes, it’s a small trick. But it’s useful when you don’t want to delete String() just for one debugging session.
6. A good String(): purity and protection from recursion
With String(), there’s a temptation to make it “the smartest and most universal thing,” and then accidentally build infinite recursion or turn simple output into a mega-combine. In real life, String() should be boring. That’s a compliment.
First rule: no side effects. String() must not change fields, write to a file, increment a counter, send a network request, or “while we’re at it” fix the database. Because fmt may call String() unexpectedly often: you printed a struct in logs, showed it in an error, debug-printed it somewhere else — and suddenly a “normal println” starts changing your program.
Second rule: don’t panic. If String() panics, debugging output turns into a landmine. It’s better to return something like "<invalid>" or carefully handle weird values.
Third rule: don’t format the object itself via %v, or you’ll get recursion. Here’s an example of “what not to do”:
package main
import "fmt"
type Task struct {
Title string
}
func (t Task) String() string {
return fmt.Sprintf("%v", t) // bad: fmt will call String() again
}
func main() {
fmt.Println(Task{Title: "boom"})
}
This ends with an infinite attempt to print t, which calls String() again, which calls fmt.Sprintf() again, and so on — until the stack runs out.
The right style is to format fields explicitly:
package main
import "fmt"
type Task struct {
Title string
}
func (t Task) String() string {
return fmt.Sprintf("Task{title=%q}", t.Title)
}
func main() {
fmt.Println(Task{Title: "ok"}) // Task{title="ok"}
}
7. Typical mistakes
Mistake #1: declare String() on *T, then print a T value and be surprised.
It looks very innocent: “but I did write String().” But the method set does not forgive inattention. If String() lives on *T, then fmt.Println() for x of type T may print the struct “by default” rather than your nice text. The simplest fix is to put String() on a value receiver, because the method should not mutate the object and it safely works for both forms.
Mistake #2: String() mutates the object’s state.
Sometimes you want to “fix up” data in String(): trim spaces, fill an empty field with a default, tweak a status. The problem is that printing must not change the meaning of the program. String() should be a pure function: look — return a string — leave. If you need data normalization, create a separate method, for example Normalize().
Mistake #3: recursively printing itself via fmt.Sprintf("%v", x).
This is a classic, especially among beginners: “I want it to be universal.” But fmt and Stringer do exactly what you asked, and the recursion is honest, infinite, and merciless. The fix is simple: format fields directly, without trying to “print the whole object again.”
Mistake #4: output that’s too “heavy” — many lines, all fields, huge nested structs.
Sometimes people turn String() into a mini memory dump: “let it include everything, so it’s definitely clear.” In practice this kills readability. A good String() usually shows 2–4 key fields: an identifier, a name, a status. Everything else is situational and belongs in separate diagnostic prints.
Mistake #5: forgetting default in an enum’s String().
Today you have StatusTodo and StatusDone, and tomorrow somewhere you accidentally end up with Status(123) (a parsing error, a logic bug, bad data). If String() can’t represent unknown values, you’ll get either emptiness or a misleading text. A proper default returns something like Status(123), so the problem is visible immediately. This diagnosable default approach is a stable best practice for String() on enum-like types.
GO TO FULL VERSION