Why does adding a String() method to a type leave json.Marshal's output unchanged?
answer
- two packages, two separate agreements
- fmt asks one question; the JSON encoder asks another
- String is for humans, not for the wire
- the encoder never looks up String at all
- it looks for a Marshal-shaped method instead
basics
~10 sfmt.Stringer is consulted only by the fmt package. encoding/json never looks for String; it looks for json.Marshaler and then encoding.TextMarshaler, and a type with neither is encoded from its structure, field by field.
solid answer
~40 sGo has no single "render me as text" hook. `fmt.Stringer` (`String() string`) is an agreement with the `fmt` package only: `fmt.Printf`, `fmt.Println`, `fmt.Sprintf` and anything layered on them, such as the `log` package, call `String()` for the verbs `%v`, `%s`, `%q`, `%x` and `%X`. `encoding/json` has its own, separate agreements: it checks whether the type implements `json.Marshaler` (`MarshalJSON() ([]byte, error)`), and if not, whether it implements `encoding.TextMarshaler` (`MarshalText() ([]byte, error)`). `String` is not in that list at any point, so the encoder never sees it and falls back to walking the value with reflection — exported struct fields, map entries, slice elements. That is why a type can print beautifully in logs and still serialise as a raw number or a bare struct in an API response.
code
go · 11 linestype Status string
func (s Status) String() string { return "status:" + string(s) }
type Job struct {
ID int
Status Status
}
// fmt.Println(Job{ID: 1, Status: "queued"}) prints: {1 status:queued}
// json.Marshal(Job{ID: 1, Status: "queued"}) gives: {"ID":1,"Status":"queued"}go deeper
Remember that String() belongs to fmt alone. Be ready to say what fmt.Println and json.Marshal each print for the same value, and to name the method the JSON encoder actually looks for.
Explain the lookup each package performs: fmt checks Formatter, then error, then Stringer for a specific set of verbs; encoding/json checks json.Marshaler and then encoding.TextMarshaler, and never String.
Show the review judgment: catch a colleague who adds String() expecting the API payload to change, and know that presentation and wire format are separate contracts with separate tests.
Frame it as contract boundaries: a human-readable rendering must stay free to change, while a serialised form is a promise to consumers. Argue for keeping them in different methods even when the text happens to match today.
## Two packages, two unrelated interfaces A common expectation, especially coming from languages with a single `toString`/`__str__`/`ToString` hook, is that giving a type one human-readable rendering makes every subsystem use it. Go deliberately does not work that way. Each package that has to turn a value into bytes defines *its own* interface and looks for *only* that interface. - `fmt` looks for `fmt.Stringer`: ```go type Stringer interface{ String() string } ``` - `encoding/json` looks for `json.Marshaler`: ```go type Marshaler interface{ MarshalJSON() ([]byte, error) } ``` and, only if that is absent, for `encoding.TextMarshaler`: ```go type TextMarshaler interface{ MarshalText() ([]byte, error) } ``` There is no inheritance, no registry, and no fallback between the two families. `encoding/json` contains no reference to `String()` at all, so a `String` method is invisible to it. ## What `fmt` actually does When `fmt` formats an operand it asks a short series of questions. If the operand implements `fmt.Formatter` it hands over completely, for every verb. Otherwise, for the verbs `%v`, `%s`, `%q`, `%x` and `%X` only, it checks for `error` (calling `Error()`), and then for `fmt.Stringer` (calling `String()`). For any other verb — `%d`, `%f`, `%t`, `%p` — the method is never consulted, which is why an integer-based type with a `String` method still prints as a number under `%d`. This lookup also applies to values *nested* inside what you print. Printing a struct with `%v` formats each field in turn, and a field whose type has a `String` method is rendered through it. ## What `encoding/json` actually does When the encoder first meets a type it builds an encoder function for it. The decision is: does this type implement `json.Marshaler`? Then use `MarshalJSON` and write its bytes as the value. Does it implement `encoding.TextMarshaler`? Then call `MarshalText` and write the returned bytes as a **JSON string** — quoted and escaped by the encoder. Otherwise, encode structurally: a struct becomes an object of its exported fields (honouring `json` struct tags), a slice becomes an array, a defined type with underlying type `string` becomes a JSON string of the underlying value, and so on. So for ```go type Status string func (s Status) String() string { return "status:" + string(s) } ``` `fmt.Println(Status("queued"))` prints `status:queued`, while `json.Marshal(Status("queued"))` produces `"queued"`. Both are correct; they are answering different questions asked by different packages. ## Why the split is a feature, not an oversight `String()` is for humans: log lines, CLI output, error messages. It is free to be lossy, to add prefixes, to truncate, to change between releases. A wire format is for machines: it must be parseable, stable, and round-trippable by whoever reads it. If `encoding/json` silently used `String()`, every debugging-friendly rendering anyone added would become part of an API contract, and changing a log format would break clients. Keeping the interfaces separate means the human rendering and the machine rendering evolve independently, and it makes the intent explicit at the point of declaration: the method name tells the reader which consumer it serves. ## Practical consequences - Adding, changing or removing `String()` is safe for your JSON payloads and your database columns; it is only ever a presentation change. - Conversely, if the JSON must change, the `String` method is not the lever. The encoder is looking for a different method entirely, and the type must be given one. - Neither direction is transitive: `fmt` does not call `MarshalText`, and `encoding/json` does not call `String`. A type that wants a custom form on both sides declares both, and they may legitimately differ. - A reviewer seeing "I added `String()` so the API returns the pretty name" should push back: that change will not do what the author believes, and the test that would catch it is a marshalling test, not a printing one. The general rule to carry: in Go, ask *which package* is doing the rendering, then look up *that package's* interface. The answer is never "the language's universal string method", because there isn't one.
- Does the log package pick up a type's String method?Yes. `log.Printf`, `log.Println` and friends format their arguments through `fmt`, so the same `fmt.Stringer` lookup happens. Anything that ultimately formats with `fmt` — including `text/template`'s printing of values — gets the `String()` rendering; anything that serialises with its own encoder does not.
- If a struct field's type has a String method, does printing the whole struct with %v use it?Yes. `fmt` applies the same lookup to nested values while walking the operand, so a field whose type implements `fmt.Stringer` is rendered by its `String()` method inside the surrounding `{...}`. The enclosing struct itself needs no method for this to happen.
- Which fmt verbs actually invoke String()?`%v`, `%s`, `%q`, `%x` and `%X`. Numeric and boolean verbs such as `%d`, `%f` and `%t` never consult it, so an integer-based type with a `String` method prints its number under `%d`. `fmt.Formatter`, if implemented, takes over ahead of `String` for every verb.
String() is the label you write on a box for the person carrying it; the JSON encoding is the barcode the warehouse scanner reads. Relabelling the box does not change what the scanner sees.
saying these in an interview costs you the question
- Says String() is what encoding/json uses to serialise a value
- Assumes fmt and encoding/json share one text interface
- Claims the JSON encoder falls back to String when no marshaler exists
- Thinks implementing one rendering interface covers every package
- Treats String() output as the type's wire contract