What does encoding/json emit for a time.Time field, and what timestamp input will it reject?
answer
- The struct's fields are all unexported
- It brings its own marshaling methods
- One quoted interchange format, no alternatives
- The offset survives, the zone name does not
- Anything else needs your own named type
basics
~20 sA time.Time marshals to a quoted RFC 3339 string with sub-second precision, because time.Time implements MarshalJSON. Decoding accepts only a quoted RFC 3339 string, so an epoch number or a space-separated datetime fails with an unmarshal error.
solid answer
~40 s`time.Time` implements `json.Marshaler` and `json.Unmarshaler` (and the text equivalents), so `encoding/json` never touches its unexported fields. Marshalling produces a quoted RFC 3339 timestamp with sub-second precision and trailing zeros trimmed — `"2024-03-05T14:09:07Z"` for a whole second in UTC, `"…+02:00"` for an offset zone. Note that only the numeric offset survives: the zone's name is not part of RFC 3339 and is lost on the wire. Unmarshalling is strict — a JSON number, or a string like `"2024-03-05 14:09:07"`, produces an error rather than a best-effort parse. To accept anything else you declare a named type wrapping `time.Time` and give it an `UnmarshalJSON` (or `UnmarshalText`) method, which is exactly how a receiver normalises a partner that sends epoch milliseconds.
code
go · 6 linestype event struct {
At time.Time `json:"at"`
}
b, _ := json.Marshal(event{At: time.Date(2024, time.March, 5, 14, 9, 7, 0, time.UTC)})
fmt.Println(string(b)) // {"at":"2024-03-05T14:09:07Z"}go deeper
Know that a time.Time field turns into a quoted string like 2024-03-05T14:09:07Z, and that sending a number instead of that string makes decoding fail.
Explain that time.Time implements the marshaling interfaces itself, describe the sub-second and offset handling, and write a named type with UnmarshalJSON on a pointer receiver to accept a different format.
Show what the round trip loses and when it matters, handle the zero-value and absent-timestamp cases deliberately, and insist on tests that assert the exact emitted string plus a negative decode case.
Decide what the service's payloads commit to: whether timestamps are always RFC 3339, whether a zone identifier travels alongside, and whether per-sender decoding types are permitted or confined to one adapter layer.
## What the default is `time.Time` is a struct whose fields are all unexported, so the reflective path in `encoding/json` would produce `{}`. It does not, because `time.Time` implements the marshaling interfaces itself: - `MarshalJSON` emits the instant as a **quoted RFC 3339 string with sub-second precision** — effectively `time.RFC3339Nano` inside quotes. Because that layout uses the `.999999999` fractional form, trailing zeros are dropped and a whole-second timestamp has no fractional part at all. - `UnmarshalJSON` accepts a quoted RFC 3339 string and nothing else. - `MarshalText`/`UnmarshalText` do the same without the quotes, which is what makes a `time.Time` usable as a JSON **map key** and in other text-based encodings. So the round trip for `time.Date(2024, time.March, 5, 14, 9, 7, 0, time.UTC)` is the string `"2024-03-05T14:09:07Z"`, and feeding that string back gives an equal instant. ## What is lost RFC 3339 encodes an **offset**, not a zone. A time in a zone whose current offset is +02:00 marshals as `+02:00`; the zone name is gone, and decoding produces a fixed-offset location rather than the original zone. If the receiving side needs to know *which* zone — because it must do calendar arithmetic across a daylight-saving boundary later — the zone identifier has to travel as a separate field. For a pure instant, the offset is sufficient and the loss does not matter. A second edge: `MarshalJSON` reports an error if the instant cannot be represented in RFC 3339, for example a year outside the four-digit range. In practice that only shows up with corrupt or synthetic data. ## The zero value The zero `time.Time` is not `null`. It marshals as `"0001-01-01T00:00:00Z"`, and the classic `omitempty` tag option does **not** suppress it, because `omitempty` has no concept of an empty struct. If you need an absent timestamp on the wire, make the field a `*time.Time` and leave it nil, or use the `omitzero` tag option available in recent Go, which does consult the value's zero-ness. ## Accepting another format When a sender will not emit RFC 3339 — epoch milliseconds is the usual case — you do not reach for a global setting, because there is none. You declare a named type and give it the decoding behaviour: ```go type epochMillis struct{ time.Time } func (e *epochMillis) UnmarshalJSON(b []byte) error { … } ``` The receiver-side rules matter. `UnmarshalJSON` must be on the **pointer** receiver, since it has to mutate; the decoder will find it as long as the field is addressable, which a struct field being decoded into always is. `b` is the raw JSON token including quotes if it is a string, so you either `json.Unmarshal` it into an intermediate value or slice the quotes off yourself. Symmetrically, if you want the type to *emit* the same format, implement `MarshalJSON` too — a type that decodes epoch millis but encodes RFC 3339 is a real and confusing asymmetry unless it is deliberate. A per-sender named type also gives you somewhere to put the sender's quirks: a trailing `Z` the sender omits, a comma as the decimal separator, a two-digit year. That is far better than a chain of `if err != nil { try the next layout }`, which silently picks the first layout an ambiguous value happens to satisfy. ## Testing it Marshalling assertions should compare the **exact string**, not a re-parsed time, or you cannot detect a format regression. Decoding assertions should include a negative case: feed the payload shape you are *not* accepting and assert the error, so the day someone adds a permissive fallback the test fails. And pin the fixture to a reference instant with a day above the 12th, a non-zero offset and a non-zero fraction — that single value catches format errors, day/month confusion and precision truncation at once.
- A partner sends timestamps as epoch milliseconds. How do you decode them into a struct?Declare a named type that embeds or wraps `time.Time` and give it an `UnmarshalJSON` method on the pointer receiver: decode the token into an `int64` and set the field with `time.UnixMilli(ms).UTC()`. Use that type for the field instead of `time.Time`. If the same type must also encode in that format, implement `MarshalJSON` as well so the round trip is symmetric.
- Why does omitempty fail to drop a zero time.Time from the output?`omitempty` skips false, 0, nil pointers and empty strings, slices and maps — it has no rule for an empty struct, and `time.Time` is a struct. The zero value therefore marshals as `"0001-01-01T00:00:00Z"`. Use a `*time.Time` field left nil for a genuinely absent timestamp, or the `omitzero` tag option in recent Go, which does test the value's zero-ness.
- What information about the original time is not preserved by the JSON round trip?The zone's identity. RFC 3339 carries a numeric offset, so a time in a named zone comes back in a fixed-offset location with no name. That is fine for representing an instant, but if the consumer must later do calendar arithmetic across a daylight-saving change, the zone identifier has to travel as its own field alongside the timestamp.
saying these in an interview costs you the question
- Expects a time.Time to marshal as an epoch number
- Thinks json emits the struct's internal fields
- Assumes Unmarshal will best-effort parse any date string
- Believes omitempty drops a zero time.Time
- Thinks the JSON string preserves the zone name