skip to content

What does encoding/json emit for a time.Time field, and what timestamp input will it reject?

level: middleimportance: should knowfreq 52%

answer

  1. The struct's fields are all unexported
  2. It brings its own marshaling methods
  3. One quoted interchange format, no alternatives
  4. The offset survives, the zone name does not
  5. Anything else needs your own named type

basics

~20 s

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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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