skip to content

Text and Binary Marshalers

encoding.TextMarshaler and BinaryMarshaler are the format-neutral pair, and encoding/json falls back to the text form for map keys and for types with no MarshalJSON of their own.

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

questions

5

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%

answer

  1. the type is a named integer
  2. it implements no marshaler interface
  3. the units are nanoseconds and invisible
  4. ParseDuration is the way back in
  5. 104 days is where float64 stops being exact

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.

solid answer

~50 s

`time.Duration` is defined as `type Duration int64` holding a nanosecond count, and it implements neither `json.Marshaler` nor `encoding.TextMarshaler`. `encoding/json` therefore falls back to encoding the underlying integer, so a 90-second timeout appears as `90000000000`, and decoding requires the caller to write that same number. The fix is a named type of your own — `type Duration time.Duration` — with `MarshalText` returning `time.Duration(d).String()` and a pointer-receiver `UnmarshalText` that runs `time.ParseDuration`. Because it is a text marshaler, json quotes it for you and the same type also works as a map key and as a `flag.TextVar`. Watch the round trip on the way out too: nanosecond counts above roughly 2^53 cannot be represented exactly by a JSON parser that decodes numbers as float64, so a raw nanosecond field is silently lossy for long durations even before anyone has to read it.

code

go · 6 lines
go
type Config struct {
	Timeout time.Duration `json:"timeout"`
}

b, _ := json.Marshal(Config{Timeout: 90 * time.Second})
// b is {"timeout":90000000000}

go deeper

for a junior

Be ready to say what a bare 90000000000 in a JSON config means and where the nanosecond unit comes from. Know that time.ParseDuration is the function that reads 1m30s back.

for a middle

Explain that the encoder finds no marshaler on the named int64 and falls back to the integer, then write the wrapper type with the correct receivers from memory.

for a senior

Argue the wire-format choice: text for anything a human edits, an explicitly unit-named integer otherwise, and show that you would prove the round trip with a table-driven test rather than one happy-path case.

for a principal

Own the fact that the chosen resolution and spelling of the duration form is permanent once other teams' config files and stored documents contain it, and decide it deliberately rather than inheriting whatever String happened to print.

## Why the number appears `time.Duration` is declared in the `time` package as a named integer type: ```go type Duration int64 ``` The value is a count of nanoseconds. It has a rich set of methods — `Seconds`, `Minutes`, `Truncate`, `Round`, `String` — but it deliberately implements none of the marshaler interfaces. `encoding/json` looks for a marshaler interface on the type; finding none, it encodes the type structurally, and structurally a named int64 is a JSON number. So this: ```go type Config struct { Timeout time.Duration `json:"timeout"` } json.Marshal(Config{Timeout: 90 * time.Second}) ``` produces `{"timeout":90000000000}`. Decoding is the same in reverse: the JSON must carry the nanosecond integer, and `{"timeout":"1m30s"}` fails with a type error because a JSON string cannot be assigned to an integer type. This catches people because `fmt.Println(d)` prints `1m30s`, and `flag.Duration` and `time.ParseDuration` both speak the friendly form. Those are different mechanisms. `encoding/json` looks only for the marshaler interfaces, and `time.Duration` has none of them, so nothing about the pretty printing reaches the encoder. ## Whether this is a bug It is a deliberate, frozen decision rather than an oversight. Adding `MarshalText` to `time.Duration` now would change the JSON output of every program in existence that stores durations, and every stored document those programs have written, so it cannot be done under Go's compatibility promise. Treat it as a fixed property of the standard library and plan around it. ## The fix: your own type The standard remedy in a shared value-type package is a thin named type carrying the text form: ```go type Duration time.Duration func (d Duration) MarshalText() ([]byte, error) { return []byte(time.Duration(d).String()), nil } func (d *Duration) UnmarshalText(text []byte) error { v, err := time.ParseDuration(string(text)) if err != nil { return err } *d = Duration(v) return nil } ``` Points worth stating out loud in an interview: - `MarshalText` takes a **value** receiver and `UnmarshalText` a **pointer** receiver. Get that backwards and the encoder quietly skips the method. - `MarshalText` returns `1m30s`, not `"1m30s"`. json supplies the quotes. - A defined type (`type Duration time.Duration`), not an alias (`= time.Duration`). An alias is the same type and cannot carry new methods; a defined type can. It also does not inherit `time.Duration`'s methods, so callers convert back with `time.Duration(d)` — which is a small tax on every use and the main argument for a struct wrapper instead. - Because the method set is on your type, the same value now also works as a JSON map key, an XML value, and a `flag.TextVar` target, with no extra code. ## The lossy round trip This leaf's real trap is that both encodings can lose information, in different ways. **Raw nanoseconds lose precision at the far end.** JSON numbers have no integer type in the specification, and most parsers outside Go — including every JavaScript one, and Go's own when you decode into `any` — represent them as IEEE-754 doubles. A double holds integers exactly only up to 2^53, which is about 9.0e15 nanoseconds, roughly 104 days. A retention period of one year expressed in nanoseconds does not survive a trip through such a parser unchanged. Decoding into `map[string]any` in Go has exactly this problem, because json produces `float64` for every number unless you use a `json.Decoder` with `UseNumber`. **The text form loses precision only if you choose a lossy format.** `time.Duration.String()` prints the full nanosecond resolution — `1.5ms`, `2h45m0.000000001s` — and `time.ParseDuration` reads that back, so the pair round-trips. If instead you format with `d.Seconds()` and a `%.2f`, or with `d.Round(time.Second)`, you have hard-coded truncation into the wire format forever. Deciding the resolution of the text form is a decision, not a formatting detail. The way you find both classes of defect is a table-driven round-trip test: a slice of interesting durations — zero, one nanosecond, a fractional millisecond, a negative duration, a multi-year duration, the extreme values of the underlying int64 — marshaled and unmarshaled and compared for exact equality. That test is cheap, it runs in milliseconds, and it is the thing that catches the ones the obvious cases hide. ## Which to choose For anything a human reads or edits — configuration files, API request bodies, log lines, command-line flags — the text form wins outright, because `30s` is self-describing and `30000000000` is a puzzle whose units nobody can see. For an internal, high-volume, machine-to-machine channel, a fixed integer of an explicitly named unit (`timeout_ms`) is defensible; name the unit in the field, because a bare number with the unit in a comment is how the wrong unit ships.

  • Would a type alias, type Duration = time.Duration, work instead of a defined type?
    No. An alias is the same type, so it cannot carry new methods and `encoding/json` still sees plain `time.Duration`. You need a defined type, `type Duration time.Duration`, which is a distinct type with its own method set. The cost is that it does not inherit `time.Duration`'s methods, so callers convert with `time.Duration(d)` before calling `Seconds` or `Truncate`.
  • What breaks if you keep nanoseconds on the wire and a browser consumes the API?
    JSON numbers are read as IEEE-754 doubles by most non-Go parsers, which hold integers exactly only up to 2^53 — about 104 days in nanoseconds. Anything longer comes back rounded, so a 1-year retention period silently changes value. Send text (`8760h`), or send an integer in a coarser unit and put that unit in the field name.
  • How do you test that the text form actually round-trips?
    A table-driven test over interesting values: zero, one nanosecond, a fractional millisecond, a negative duration, a multi-year duration and the int64 extremes. Marshal each, unmarshal into a fresh variable, and require exact equality. It is the only cheap way to discover that a `%.2f` seconds format or a `Round(time.Second)` in the marshaler quietly truncates.

A raw nanosecond count in a config file is a price tag with no currency symbol. It is exact and completely useless to the person reading it.

saying these in an interview costs you the question

  • Claims json omits the field because Duration is an int64
  • Expects the pretty 1m30s form to appear automatically
  • Adds a String method to the wrapper and expects JSON to change
  • Formats seconds as a rounded float and calls it lossless
  • Assumes nanosecond integers survive every JSON parser
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 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

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