skip to content

Conversion Interfaces

One type, several conversion contracts: MarshalText and MarshalBinary are the format-neutral pair, MarshalJSON overrides them, and String is not a serialisation method at all.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

explore

questions

9

Why does adding a String() method to a type leave json.Marshal's output unchanged?

level: juniorimportance: must knowfreq 60%

answer

  1. two packages, two separate agreements
  2. fmt asks one question; the JSON encoder asks another
  3. String is for humans, not for the wire
  4. the encoder never looks up String at all
  5. it looks for a Marshal-shaped method instead

basics

~10 s

fmt.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 s

Go 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 lines
go
type 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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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
open as a page

Why does a time.Duration field marshal to a bare number in encoding/json, and how do you get "1m30s"?

level: middleimportance: must knowfreq 62%

basics

~20 s

time.Duration is a named int64 counting nanoseconds and implements no marshaler interface, so encoding/json encodes the underlying integer: 90*time.Second becomes 90000000000. To emit "1m30s", define your own duration type with MarshalText and an UnmarshalText that calls time.ParseDuration.

open as a page

What does implementing encoding.TextMarshaler and TextUnmarshaler change about a type's encoding/json output?

level: juniorimportance: should knowfreq 52%

basics

~20 s

encoding/json writes the type as one JSON string built from the bytes MarshalText returns, instead of encoding its fields or its underlying number. Decoding hands the unquoted bytes back to UnmarshalText. json adds the quotes and escaping itself.

open as a page

In encoding/json, what may a map's key type be, and what does MarshalText have to do with it?

level: middleimportance: should knowfreq 38%

basics

~20 s

JSON object keys are strings, so encoding/json accepts a map key type only if it is a string kind, an integer kind, or implements encoding.TextMarshaler. Anything else fails with an unsupported-type error. Decoding needs the mirror: a string, an integer, or TextUnmarshaler.

open as a page

Why does fmt.Println(v) skip a String() method declared with a pointer receiver?

level: middleimportance: should knowfreq 44%

basics

~20 s

Only the pointer type's method set contains a pointer-receiver method, so the value copy stored in fmt's interface argument does not satisfy fmt.Stringer. fmt cannot take the address of that copy, so it falls back to default formatting.

open as a page

Why might encoding/json silently skip your MarshalText or UnmarshalText methods?

level: seniorimportance: should knowfreq 41%

basics

~20 s

Almost always a receiver mistake. MarshalText on a pointer receiver is skipped whenever json holds an unaddressable value, so the type encodes structurally instead. UnmarshalText on a value receiver runs but writes to a copy, leaving the field at its zero value.

open as a page

Why does a String() method that formats its own receiver with fmt.Sprintf crash the program?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Formatting the receiver with %v or %s makes fmt call String again, so the method recurses forever. The goroutine stack grows to the runtime limit and the process dies with a fatal stack overflow, which recover cannot catch.

open as a page

When a type implements both json.Marshaler and encoding.TextMarshaler, which does encoding/json use?

level: middleimportance: nice to knowfreq 32%

basics

~10 s

json.Marshaler wins: encoding/json calls MarshalJSON and writes its bytes, which must form a complete valid JSON value. MarshalText is only the fallback, and its output is always quoted and escaped into a JSON string.

open as a page

When is adding encoding.TextMarshaler to a shared library's value type a commitment you should refuse?

level: principalimportance: nice to knowfreq 27%

basics

~20 s

Refuse when the type has no single canonical, lossless, human-meaningful text form. Publishing MarshalText pins those exact bytes forever across every consumer's config files, JSON map keys, logs and stored documents, and no module version can take them back.

open as a page