The Stringer interface

The stringer tool generates String() methods for bitset types to convert integer flags into readable names.

The debugging log problem

You are staring at a production log that prints 0x3 instead of READ | WRITE. You know exactly what those bits mean, but the next engineer on call does not. You could scatter switch statements across your codebase to translate integers into words. You could write a separate helper function for every type. Or you could teach the value to speak for itself.

Go gives you a single method that solves this everywhere. The fmt.Stringer interface lives in the standard library and requires exactly one method: String() string. When any fmt function encounters a value, it checks whether that value satisfies the interface. If it does, the function calls String() and prints the result. If it does not, the function falls back to default formatting. One implementation covers Println, Printf, Sprintf, and every logging wrapper that delegates to them.

How fmt.Stringer works

The interface definition is deliberately minimal:

type Stringer interface {
    String() string
}

Go does not require explicit declarations like implements or extends. Interface satisfaction is implicit. If your type has a method named String with no parameters and a single string return value, it satisfies the interface. The compiler verifies this at compile time, and the runtime uses it for formatting.

Convention aside: receiver names should be short and match the type. Use (s Status) String() string or (l *LogLevel) String() string. Avoid this, self, or receiver. The Go community treats the receiver as a contextual hint, not a keyword.

One method, universal translation.

A minimal implementation

Here is the simplest way to make a custom type speak for itself.

package main

import "fmt"

// LogLevel tracks application verbosity using an underlying integer.
type LogLevel int

const (
    // Info represents standard operational messages.
    Info LogLevel = iota
    // Warn represents potential issues that do not stop execution.
    Warn
    // Error represents failures that require attention.
    Error
)

// String returns the human-readable name for a LogLevel.
// The fmt package calls this method automatically when printing.
func (l LogLevel) String() string {
    // Match the constant value to its display name.
    switch l {
    case Info:
        return "INFO"
    case Warn:
        return "WARN"
    case Error:
        return "ERROR"
    default:
        // Fallback prevents silent bugs when an invalid level is passed.
        return fmt.Sprintf("UNKNOWN(%d)", l)
    }
}

func main() {
    // fmt.Println checks for the Stringer interface at runtime.
    fmt.Println(Info)
    fmt.Println(Warn)
}

The code prints INFO and WARN. No format verbs are needed. The fmt package handles the lookup.

What happens at runtime

When you call fmt.Println(val), the formatting engine does not blindly convert the value to a string. It performs a type assertion to check whether val implements fmt.Stringer. If the assertion succeeds, it calls val.String(). If it fails, it falls back to the default representation for the underlying type.

This check is fast. The fmt package avoids heavy reflection for common interfaces. It uses a series of type assertions and interface checks that compile down to simple pointer comparisons and method table lookups. The overhead is negligible compared to I/O or string allocation.

You can verify this behavior by passing a value that does not implement Stringer. The output will show the raw integer. You can also pass a pointer to a type that implements Stringer on the value receiver. The fmt package automatically dereferences pointers when checking for interface satisfaction, so fmt.Println(&Info) still prints INFO.

Interfaces are contracts, not declarations. Satisfy them implicitly.

Automating with stringer

Manual switch statements break when you add a new constant. You forget to update the formatter. The compiler does not catch missing cases in a switch unless you use exhaustive linters. The stringer tool removes this friction entirely.

stringer lives in golang.org/x/tools/cmd/stringer. It reads your source files, extracts integer-based constant declarations, and generates a String() method that maps every constant to its identifier. You add a single directive to your source file:

//go:generate stringer -type=Status -output=status_string.go

The directive tells the Go toolchain to run stringer whenever you execute go generate. The -type flag specifies which type to process. The -output flag names the generated file. You run go generate ./... in your project root, and the tool produces a file containing the String() method.

Convention aside: //go:generate is the standard way to hook code generation into the build pipeline. Most teams run go generate in CI before go build or go test. Some developers configure their editors to run it on save. The generated file should be committed to version control so that go build works without requiring the stringer binary on every machine.

Automate the boring parts. Let the compiler guard your constants.

Bitsets and combined flags

Integer constants are not always mutually exclusive. Network protocols, permission systems, and feature flags often combine values with the bitwise OR operator. A value of 3 might mean READ | WRITE. A value of 7 might mean READ | WRITE | EXECUTE.

The stringer tool handles this with the -bitset flag. When you enable it, the generated String() method checks every flag independently and joins the matching names with pipes. You do not need to write nested conditionals or manual bitmask checks.

Here is how you declare a bitset type for generation:

//go:generate stringer -type=Permission -bitset -output=permission_string.go

// Permission represents file access control flags.
type Permission int

const (
    // Read allows viewing the resource.
    Read Permission = 1 << iota
    // Write allows modifying the resource.
    Write
    // Execute allows running the resource.
    Execute
)

The generated method will produce READ | WRITE for a value of 3. It produces READ | WRITE | EXECUTE for 7. It produces an empty string for 0 unless you provide a custom zero value with the -trimprefix or -type options. The tool parses your AST, extracts the shift expressions, and builds a lookup table that iterates through the bits at runtime.

Bitmask formatting becomes a configuration detail, not a manual implementation.

Common pitfalls and compiler feedback

The Stringer interface is simple, but a few patterns cause friction.

Forgetting to match the exact signature breaks interface satisfaction. If you return []byte instead of string, or if you add a parameter like String(ctx context.Context), the type no longer satisfies fmt.Stringer. The compiler rejects this with cannot use x as fmt.Stringer value in argument when you pass it to a function that expects the interface.

Running stringer without updating the constants leaves stale mappings. The generated file contains the old names. The compiler does not complain because the method signature is still valid. You will see outdated strings in production logs. Always regenerate after changing constant declarations.

Using stringer on non-integer types fails at generation time. The tool requires an underlying int, int8, int16, int32, int64, or their unsigned variants. If you try to generate for a string or float64 type, the tool exits with an error about unsupported base types.

Forgetting to run go generate before building causes missing method errors. The compiler complains with undefined: Status.String when the generated file does not exist. Add go generate ./... to your pre-commit hooks or CI pipeline to catch this early.

The compiler will not save you from forgotten generation steps. Run go generate early.

Choosing your approach

Different projects have different formatting needs. Pick the tool that matches your complexity.

Use a manual String() implementation when you have complex formatting logic that depends on external state or requires conditional branching beyond simple constant mapping.

Use the stringer tool when you have a closed set of integer constants and want zero-maintenance, compile-time safe string representations.

Use the fmt package verbs like %v or %+v when you need quick debugging output without defining custom types.

Use a custom fmt.Formatter implementation when you need to support multiple formatting verbs like %s, %q, and %v with different behaviors.

Use plain integer printing when performance is measured in nanoseconds and you are writing a tight loop that bypasses the fmt package entirely.

Match the tool to the complexity. Keep it simple.

Where to go next