skip to content

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%

answer

  1. object names are always strings
  2. three accepted key shapes, no more
  3. integers come back quoted
  4. the escape hatch is one interface
  5. nothing checks that the text form is unique

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.

solid answer

~50 s

A JSON object's keys are strings, so `encoding/json` needs a way to turn every map key into text. It accepts three cases: a string kind, used as is; an integer kind, formatted as a quoted decimal such as `"42"`; or any type implementing `encoding.TextMarshaler`, whose `MarshalText` output becomes the key. Everything else — a struct, a float, an array, an interface — makes `json.Marshal` return an unsupported-type error for the whole document. Decoding requires the reverse: a string kind, an integer kind, or a pointer-receiver `UnmarshalText`. Note that `json.Marshaler` is **not** consulted for keys; a type with only `MarshalJSON` still fails as a key type, which surprises people. Two other things matter in practice: json sorts the encoded keys, so map output is deterministic; and if two distinct keys marshal to the same text you get a duplicate JSON key and silently lose one entry on the way back.

code

go · 4 lines
go
b, _ := json.Marshal(map[time.Time]int{
	time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC): 4,
})
// b is {"2026-03-01T00:00:00Z":4}

go deeper

for a junior

Be ready to name the three accepted key shapes and to say that integer keys come out quoted, because JSON object names are always strings.

for a middle

Explain that MarshalText is the only extension point for keys, that MarshalJSON is not consulted, and that the decoding side needs a pointer-receiver UnmarshalText.

for a senior

Bring up the collision failure: a non-injective text form silently merges entries with no error, and show the round-trip test over deliberately close keys that catches it.

for a principal

Recognise that shipping a text form on an exported value type is what lets every downstream team serialise maps keyed by it, and that changing the form later breaks their stored documents' keys.

## The constraint comes from JSON, not from Go In the JSON grammar an object member's name is always a string. Go maps are far more permissive — any comparable type can be a key. `encoding/json` has to bridge that gap, and the bridge is the text-marshaler interface. ## Encoding When `json.Marshal` meets a map it inspects the **key type** and accepts exactly three shapes: 1. **A string kind.** `string` or any named type whose underlying type is `string`. The value is used directly as the object key, with normal JSON escaping. 2. **An integer kind.** Any signed or unsigned integer type. json formats it in decimal and quotes it, so `map[int]bool{42: true}` becomes `{"42":true}`. This is why an integer-keyed map does not survive a naive round trip through a schema that expects numbers — JSON simply has no numeric keys. 3. **A type implementing `encoding.TextMarshaler`.** `MarshalText` is called and the returned bytes become the key. This is the only extension point, and it is what lets `map[time.Time]int` or a map keyed by your own ID type marshal at all. Anything else — a struct without `MarshalText`, a float, an array, a `bool`, an interface — produces an error from `json.Marshal` (an unsupported type error), and because marshaling is all-or-nothing that error kills the whole document, not just the map. Two details that come up in review: - **`json.Marshaler` is not consulted for keys.** A type with a `MarshalJSON` method and no `MarshalText` is still an invalid key type. The reason is structural: `MarshalJSON` returns arbitrary JSON — an object, an array, a number — and none of those can be an object name, whereas `MarshalText` returns text by definition. - **The keys are sorted.** `encoding/json` sorts map keys by their encoded string form, so output is deterministic across runs even though Go's own map iteration order is randomised. That is what makes byte-for-byte golden-file tests of JSON output possible. ## Decoding On the way back json needs to turn each object name into a key value. It accepts a string kind, an integer kind (parsed from the quoted decimal, failing if it overflows the type), or a key type whose pointer implements `encoding.TextUnmarshaler`. The usual receiver rule applies: declare `UnmarshalText` on `*T`, and json will allocate a fresh key value, take its address, and call the method. ## The collision trap This is the failure worth naming, because nothing reports it. `MarshalText` is a function from your key type to text, and nothing requires it to be **injective**. If two distinct keys produce the same text, the encoder emits the same object name twice. The result is technically legal JSON that almost every parser resolves by last-one-wins, so decoding gives you a map with fewer entries than you started with and no error anywhere. A concrete version with this leaf's data: a key type wrapping `time.Time` whose `MarshalText` formats only the calendar date collapses every timestamp in a day onto one key. Another: an ID type that lowercases its text form merges `A1` and `a1`. Another: a key whose text form drops a time zone merges two instants an hour apart. Each of these looks fine in a unit test with two well-chosen keys. The defence is the same table-driven round-trip test used for values, extended to the map: build a map with keys that are close together under whatever your text form throws away, marshal, unmarshal, and assert the length and contents are unchanged. If the text form cannot be injective — because it genuinely loses information the type carries — then the type should not be used as a map key at all, and the honest fix is to key the map on the reduced value explicitly so the collapse is visible in the code. ## A worked case: time as a key `time.Time` implements `MarshalText` (RFC 3339 with nanoseconds, trailing zeros trimmed) and `UnmarshalText`, so `map[time.Time]int` marshals and unmarshals out of the box. But it is a good example of why keying on a rich value type is delicate: two `time.Time` values that compare unequal in Go — different monotonic readings, or the same instant in different locations — can produce different key text, or the same key text, depending on which fields differ. `time.Time` is also a struct containing a pointer to a `*Location`, so `==` on it is not the equality most people mean. If a map must be keyed by an instant, keying it on `t.UTC().Format(...)` or on a Unix nanosecond count makes the intended identity explicit instead of leaving it to the marshaler. ## Why the extension point is worth knowing If you export a value type that other teams will key maps on, `MarshalText` is what makes those maps serialisable at all. Without it, a downstream service that tries to send you `map[YourID]Stats` gets a marshal error and has to convert every key to a string by hand at the boundary. That makes the presence of a text form part of the ergonomics of your package, not just a formatting nicety.

  • Does implementing MarshalJSON make a type usable as a JSON map key?
    No. Only `MarshalText` is consulted for keys. `MarshalJSON` may return any JSON value — an object, an array, a number — and none of those can serve as an object name, so json refuses the key type and returns an unsupported-type error. A key type needs a text form specifically, which is exactly what `encoding.TextMarshaler` promises.
  • What happens if two different keys produce the same MarshalText output?
    The encoder writes the same object name twice. That is legal-ish JSON that decoders resolve last-one-wins, so unmarshaling gives you a smaller map with no error reported anywhere. Nothing in the standard library checks injectivity, so it is on you to test it — build a map with keys that differ only in what the text form discards and assert the round trip preserves the length.
  • Is map output from encoding/json deterministic despite Go's randomised map iteration?
    Yes. `encoding/json` sorts map keys by their encoded string form before writing, so the same map always produces the same bytes. That is what makes golden-file tests of JSON output stable. It does not extend to anything else: iterating the map yourself in your own marshaler gives you the randomised order back.

saying these in an interview costs you the question

  • Thinks any comparable Go type can be a JSON map key
  • Expects integer keys to appear as JSON numbers
  • Believes MarshalJSON also works for map keys
  • Assumes distinct keys always produce distinct text
  • Thinks JSON map output follows Go's map iteration order