After embedding time.Time in a struct, why does json.Marshal emit only a timestamp and drop the other fields?
answer
- embedding brings methods, not just fields
- the outer type now satisfies an interface
- the encoder checks that before walking fields
- one promoted method speaks for the whole struct
basics
~20 sEmbedding promotes time.Time's MarshalJSON into the outer struct's method set, so the outer struct itself satisfies json.Marshaler. The encoder calls the promoted method, which knows only the time, and never walks the outer fields. Give the field a name.
solid answer
~50 sEmbedding a type promotes its methods, and `time.Time` has a `MarshalJSON` that renders an RFC 3339 string. So the outer struct now implements `json.Marshaler` — with a method that knows nothing about it. `encoding/json` checks for that interface before its reflective walk, calls the promoted method, and writes its output as the encoding of the whole struct: one JSON string, with every other field silently gone. It is not a `time.Time` quirk; any embedded type carrying `MarshalJSON` does the same, which is why this bites when someone embeds a shared base type in a library other teams consume. The fix is usually to stop embedding: name the field, `At time.Time`. If the embedding is deliberate, write the outer type's own `MarshalJSON` that shadows the promoted one. A round-trip test does not catch it — you need a golden test asserting the exact bytes.
code
go · 7 linestype Event struct {
time.Time
ID string
}
// json.Marshal(Event{}) writes "0001-01-01T00:00:00Z"
// - a JSON string, not an object, and ID is absent.go deeper
Know that embedding a type gives the outer struct that type's methods as well as its fields, and that time.Time carries a MarshalJSON. The safe habit is to give the field a name rather than embed it.
Explain the two steps: promotion puts MarshalJSON in the outer struct's method set, and encoding/json checks json.Marshaler before its field walk, so the promoted method's output becomes the whole encoding.
Diagnose it from the wire — a string where an object was expected, a field that never arrives — and know why a round-trip test misses it. Be able to say which fixes actually remove the promoted method and which only change the shape.
Treat it as an API-design question. A type you export for embedding that carries MarshalJSON hijacks every consumer's wire format, and removing that method later is a breaking change; decide up front whether your published types are embeddable at all.
## What you see ```go type Event struct { time.Time ID string } ``` `json.Marshal(Event{})` produces a bare JSON string — `"0001-01-01T00:00:00Z"` — not an object, and `ID` appears nowhere. Decoding is broken in the same direction: `time.Time` also has `UnmarshalJSON`, promoted the same way, so the decoder hands the whole document to it and `ID` is never populated. Fields did not go missing from the object; there is no object. ## The mechanism, in two steps **Step one: promotion.** An embedded field contributes both its fields and its methods to the outer struct. `Event` therefore has a method `MarshalJSON` — the one declared on `time.Time`, reached through the embedded field. It is a real member of `Event`'s method set, not a convenience at the call site. **Step two: the interface check.** `encoding/json` asks whether the value's type implements `json.Marshaler` before it does anything else. `Event` does now. So the encoder calls `Event.MarshalJSON`, which is `time.Time.MarshalJSON` on the embedded value, and writes exactly what that returns. The reflective walk that would have produced `{"Time":...,"ID":...}` never runs, so no tag, no field, no `omitempty` has any effect. Nothing here is specific to `time.Time`; it is only the type people embed most. Any embedded type with a `MarshalJSON` — a shared `Base` in an internal library, a wrapper around a decimal, an ID type that renders as a string — hijacks its container the same way. The hijack is also transitive: a struct embedding `Event` inherits the promoted method again. ## Why it survives review and tests The compiler is happy: `Event` is a perfectly good type. `go vet` says nothing. A round-trip test can pass, because encoding and decoding are hijacked symmetrically — the timestamp survives the trip, and if the test only asserts on the timestamp it goes green with `ID` quietly lost. The failure shows up as a consumer complaining that a field "never arrives", or as an empty column downstream. What catches it is a golden test: assert the exact bytes for a fully populated value. What catches it in review is a rule of thumb — **embedding a type into a struct you serialise is a decision, not a shortcut** — and a habit of asking what methods the embedded type brings with it. ## The fixes, in order of preference **Name the field.** `At time.Time \`json:"at"\`` restores the ordinary struct encoding: `time.Time`'s marshaler is then used for that *field*, which is what you wanted, and the other fields come back. This is almost always the right answer, because the embedding was rarely buying anything except a shorter selector. **Give the embedded field a JSON tag name.** A tagged anonymous field is treated as a named member for encoding purposes. It fixes the wire shape but leaves the promoted method in the type's method set, so the outer struct still satisfies `json.Marshaler` and still hijacks itself. Not a fix. **Write the outer type's own MarshalJSON.** If the embedding is load-bearing for the Go API, declare `func (e Event) MarshalJSON()` on the outer type; a method declared on the outer type shadows the promoted one at the same depth, and the encoder calls yours. You then decide the shape explicitly — and you owe a matching `UnmarshalJSON`, since the decoding side is hijacked too. A trap inside that fix: the usual `type plain Event` trick does **not** help here. A type definition drops methods declared on `Event`, but the embedded field travels with the struct, so `plain` still promotes `time.Time`'s `MarshalJSON` and marshaling it recurses right back into a timestamp string. To use the wrapper pattern you must shadow the embedded field in the wrapper struct — declare a member with the same JSON name at the outer level so the shallower field wins — or, again, just name the field. ## The library-boundary angle This is worth flagging because the damage is asymmetric. When the struct is internal, someone notices in a day. When it is in a package other teams import and embed into *their* wire types, the promoted method rides along into payloads you never see, and the fix is a breaking change to a type you published. If a library exports a type meant to be embedded, either keep it free of `MarshalJSON` and `UnmarshalJSON`, or document loudly that embedding it takes over the container's JSON — and prefer exporting it as a named field in your own wire structs so no one learns the pattern from your examples.
- Is decoding affected in the same way?Yes, and it is the half people forget. `time.Time` also has `UnmarshalJSON`, promoted identically, so the decoder hands the entire document to it and every other field stays at its zero value. Any fix has to restore both directions, which is why naming the field beats writing one custom method.
- Does declaring MarshalJSON on the outer struct fix it?Yes — a method declared on the outer type shadows the one promoted from the embedded field, so the encoder calls yours. You then own the whole shape and should write the matching `UnmarshalJSON` too. Inside it, remember that a `type plain Event` conversion still promotes the embedded type's marshaler.
- How would you catch this before it reaches consumers?A golden test that asserts the exact bytes for a fully populated value; a round-trip test can pass while a field silently disappears, because both directions are hijacked consistently. In review, treat embedding into a serialised struct as a decision and ask what methods the embedded type carries.
saying these in an interview costs you the question
- Calls it a time.Time bug rather than method promotion
- Says adding json tags to the other fields will bring them back
- Thinks embedding only promotes fields, never methods
- Claims a round-trip test would have caught it
- Expects type plain Event to strip the promoted marshaler