Serializing and Storing Temporal Values

How to serialize each Temporal type to the string and column that round-trips it losslessly. Part of Modern Temporal API.

Problem framing

Serialization is where the type system you carefully built inside your application meets the far weaker type system of your storage layer, and information leaks at the boundary. Inside a running program a Temporal.ZonedDateTime knows its instant, its wall-clock time, its UTC offset, and the named time zone that ties the two together. A database column, a JSON field, or a URL query parameter knows none of that structure — it holds a string, a number, or at best a native timestamp. The job of serialization is to project the rich in-memory value onto that flatter surface without discarding any bit you will later need to reverse the projection.

The failure mode is almost never a crash. It is silent, delayed data loss: the value round-trips today, looks correct in every test that uses today's time zone rules, and then quietly produces the wrong wall-clock reading months later when a jurisdiction changes its daylight-saving schedule or when a row written in one zone is read back in another. Because the corruption happens at read time rather than write time, it is notoriously hard to trace back to the storage decision that caused it. Treating serialization as a design decision — one column per bit of information the type carries — is what prevents that class of bug.

A useful mental discipline is to ask, for each value you persist, what question a future reader will ask of it. If the reader will only ever ask "what instant did this happen at," a UTC timestamp is enough. If the reader will ask "what did the clock on the wall say, in the actor's own zone," you must keep the zone. If the reader will ask "what calendar day was this, independent of any clock," you want a zoneless date. Matching the stored form to the future question is the whole game.

Persisting a Temporal value is easy to get subtly wrong because each type carries different information and a database column rarely matches it exactly. Store a ZonedDateTime as a bare UTC timestamp and you lose the zone, so you can no longer reconstruct the user's wall clock after a rule change. Store a PlainDate in a timestamptz column and the driver bolts on a midnight and a zone, reintroducing the very ambiguity PlainDate exists to avoid. The rule is to serialize each type to the string form that round-trips it losslessly, and to pick the column that stores exactly those bits.

Match the type to the columnA UTC timestamp loses the zone of a ZonedDateTimeMatch the type to the columnstore ZonedDateTime as UTC mszone discardedstore the full ISO with [zone]reconstructs wall clockEach Temporal type has a canonical string; persist that, not a lossy projection.

API reference

Each canonical string is defined by a grammar in the Temporal specification and by the wider ISO 8601 and RFC 9557 standards, which means the strings are portable across languages and runtimes, not just readable by the polyfill that produced them. Temporal.Instant.toString() emits an RFC 3339 timestamp ending in Z; Temporal.PlainDate.toString() emits YYYY-MM-DD; Temporal.PlainDateTime emits a date and time with no offset; and Temporal.ZonedDateTime.toString() emits the offset plus the IANA identifier in square brackets — the bracketed-zone extension that RFC 9557 standardized specifically so that a wall-clock time and its zone can travel together in one field.

The inverse from() methods are strict by default. They reject a string that does not match the type they parse, which is exactly what you want on the way in from storage: a PlainDate column that somehow contains a full timestamp should fail loudly rather than silently truncate. When you deliberately want to accept a broader input — say, parse a ZonedDateTime string down to just its date — you call the narrower type's from() and let it read the prefix, or you convert explicitly with a method like toPlainDate(). Being explicit about which direction you are narrowing keeps the lossy step visible in the code review rather than buried in a driver.

ZonedDateTime additionally carries an offset disambiguation policy on its from() options. When the stored offset and the stored zone disagree — which can happen if the zone's rules were updated between write and read — you decide via { offset: 'use' | 'ignore' | 'prefer' | 'reject' } whether the frozen offset or the live rules win. Storing the full bracketed string is what gives you that choice at read time; storing only the offset throws it away.

Every Temporal type has a toString() that emits a canonical, machine-parseable form, and a matching from() that round-trips it.

Serialization formsCanonical strings per typeSerialization formsInstant.toString()'2024-03-15T14:30:00Z' — store as timestamptz/UTCZonedDateTime.toString()'...+09:00[Asia/Tokyo]' — needs a text columnPlainDate.toString()'2024-03-15' — store as DATEDuration.toString()'PT1H30M' ISO-8601 duration

Approach A: legacy Date

The reason legacy Date forces a two-column pattern is that it is, underneath, nothing more than a count of milliseconds since the Unix epoch. It has no field for a zone and no representation of a zoneless civil date, so any application that needed those had to bolt them on. The canonical shape was a UTC timestamp column plus a separate varchar holding the IANA zone name, with application code responsible for re-pairing them on every read. Nothing in the type system enforced that the two columns stayed consistent, so it was common to find rows where the zone column had drifted, been defaulted to the server's zone, or been left null entirely.

Worse, developers frequently reached for date.toString() or date.toLocaleString() when they meant toISOString(), and those methods emit locale- and zone-dependent text that cannot be parsed back reliably. A value serialized as "Fri Mar 15 2024 09:00:00 GMT-0400 (Eastern Daylight Time)" is human-readable and machine-hostile: parsing it depends on the reader's locale, and the appended zone name is a display string, not the IANA identifier America/New_York that you would need to reconstruct future offsets. The only defensible legacy serialization was always toISOString(), and even that answered only the "what instant" question.

Migrating such data forward is usually a matter of reading the timestamp as a Temporal.Instant, pairing it with the stored zone string to build a ZonedDateTime, and then writing the single canonical string back. Doing that migration once, at rest, is far cheaper than continuing to reassemble the pair on every read for the life of the system.

With Date, the only safe serialization is toISOString() (UTC). It cannot represent a zone or a zoneless civil date, so applications historically stored a second column for the IANA zone alongside the timestamp.

const row = { at: new Date().toISOString(), zone: 'America/New_York' }; // two columns

Serializing with legacy DatetoISOString is UTC-only; zone and civil date need side channelsLegacyModerntoISOString() → UTCseparate zone columnno civil-date typemanual reassemblyZonedDateTime.toString()zone travels in the stringPlainDate for civilself-describing

Approach B: Temporal

What makes the Temporal round-trip trustworthy is that toString() and from() are specified as inverses for every type: parsing the output of toString() is guaranteed to reconstruct an equal value, with the single caveat around offset-versus-zone conflicts noted above. That guarantee is what lets you treat the string as the source of truth and the in-memory object as a derived, disposable view. You can log the string, put it in a message queue, embed it in a URL, or store it in a text column, and any consumer with a Temporal implementation reconstructs the exact value.

The self-describing nature of the ZonedDateTime string is the key upgrade over the legacy pair. Because the zone rides inside the string, there is no second column to keep in sync and no window in which the two can diverge. A single text column holds 2024-03-15T09:00:00-04:00[America/New_York], and both the frozen offset and the live-rules zone are present, so the reader can apply whichever disambiguation policy the domain requires. For an audit log you might set offset: 'use' to freeze exactly what the clock read; for a future appointment you might set offset: 'ignore' so the wall-clock time follows any rule change. The storage format supports both because it kept both bits.

For values that are genuinely zoneless — a birthday, a contract date, a holiday — PlainDate serializes to a plain YYYY-MM-DD with no offset and no clock, and stores naturally in a SQL DATE column. That alignment between the Temporal type and the column type is the cleanest possible mapping: the column can represent exactly the information the value carries, no more and no less.

Serialize with toString(), deserialize with from(). The zone rides inside the ZonedDateTime string, so a single text column reconstructs everything.

import { Temporal } from '@js-temporal/polyfill';
const s = zdt.toString();                       // '2024-03-15T09:00:00-04:00[America/New_York]'
const back = Temporal.ZonedDateTime.from(s);     // fully reconstructed, DST-aware

Lossless round-triptoString to persist, from to reconstructLossless round-tripZonedDateTimetoString()→ textfrom() →same value

Production implementation

Centralizing the mapping in one small codec module pays off quickly because serialization decisions otherwise scatter across every repository, controller, and background job that touches a date. When the rule lives in one place, a change — adding a new Temporal type, switching a column, tightening a disambiguation policy — is a one-file edit rather than a codebase-wide grep. The codec also becomes the natural home for the invariants you want to assert: that an Instant never lands in a DATE column, that a PlainDate never acquires a spurious midnight, that every revived value is validated before it re-enters the domain.

A reviver is the JSON counterpart of the codec. JSON.parse has no idea that a particular string field is meant to be a Temporal.ZonedDateTime; left alone it hands you a string and your code either forgets to rehydrate it or does so inconsistently. Passing a reviver function as the second argument to JSON.parse lets you intercept known keys and call the matching Temporal.*.from(), so the boundary of your system produces fully typed values. Pairing the reviver with a toJSON on the way out — which several Temporal types already implement — makes the serialization symmetric and the intent obvious at the call site.

In a TypeScript codebase it is worth encoding the column-to-type mapping in the types themselves, so that a function returning a stored appointment is typed as returning a ZonedDateTime and a stored birthday as returning a PlainDate. The compiler then refuses to let a caller treat one as the other, catching at build time the exact confusion that produces the subtle read-time bugs described above.

A tiny codec centralizes the mapping so every layer serializes consistently, and a JSON reviver rehydrates values on the way in.

import { Temporal } from '@js-temporal/polyfill';
export const codec = {
  toJSON: (v: Temporal.ZonedDateTime | Temporal.PlainDate) => v.toString(),
  reviveInstant: (s: string) => Temporal.Instant.from(s),
  reviveZoned:   (s: string) => Temporal.ZonedDateTime.from(s),
  reviveDate:    (s: string) => Temporal.PlainDate.from(s),
};

A serialization codecOne place maps types to strings and backA serialization codecvaluetoString()store/transmitfrom() revive

Edge cases

The most consequential edge case is the offset-only string: a value serialized as 2024-11-03T01:30:00-04:00 with no bracketed zone. On the day it was written the offset was correct, but it does not name the zone, so it cannot answer what the offset will be for any other instant. If daylight-saving rules change — as they periodically do when governments legislate new schedules — a reader has no way to recompute the wall-clock time, because the crucial fact, the IANA identifier, was discarded. Storing the bracketed zone alongside the offset is the fix, and it costs only a handful of characters.

A second trap is the PlainDate that lands in a timestamptz column. The database driver, faced with a date-only value in a timestamp column, supplies a time-of-day (usually midnight) and interprets it in some zone (usually UTC or the session zone). Read that row back from a different session zone and the date can shift by a day, because midnight UTC is the previous evening in the Americas. The value that was supposed to be immune to time-zone effects has acquired one purely through column choice. A DATE column has no time-of-day slot and therefore cannot introduce the shift.

A third is JSON's own coercion habits. JSON.stringify(new Date()) silently produces a UTC string, and JSON.stringify of a Temporal value without a toJSON may produce {} or throw depending on the type. Relying on default JSON behavior is how zones and civil-date semantics quietly evaporate in transit between services. Explicit toJSON/reviver pairs make the transformation deliberate and reviewable rather than emergent and surprising.

Serialization edge casesColumn mismatches and offset driftSerialization edge casesPlainDate in timestamptzdriver adds midnight+zoneuse DATE columnOffset-only string'-04:00' without [zone]future DST unknownJSON round-tripDate auto-UTCuse a reviver

Gotchas & common pitfalls

A frequent mistake is assuming that because two serialized values represent the same instant, they are interchangeable. 2024-03-15T13:00:00Z and 2024-03-15T09:00:00-04:00[America/New_York] denote the same moment, but only the second remembers the wall clock and the zone. Collapsing the second into the first — which happens automatically the moment you route a ZonedDateTime through a UTC timestamp column — is a lossy operation that no downstream code can reverse. The equality of instants tempts developers into treating the richer form as redundant; it is not.

Another pitfall is round-tripping through native Date as an intermediate step. A pipeline that does Temporal → Date → JSON → Date → Temporal looks reasonable but launders away everything Date cannot hold: the zone, sub-millisecond precision, and the distinction between a zoned and a zoneless value. Keep Temporal values as their canonical strings for the entire transit and convert to Date only at the very last interop point that genuinely requires it, such as a legacy API argument.

Finally, watch for precision loss. Temporal.Instant supports nanosecond precision, while native Date and most database timestamp columns stop at milliseconds. If your domain needs the extra digits — high-frequency event ordering, certain scientific records — you must choose a column and a serialization that preserve them, because a silent truncation to milliseconds can reorder events that were distinct. When millisecond precision is genuinely sufficient, document that assumption so nobody later assumes more resolution than the storage actually kept.

Serialization pitfallsLossy columns and offset-only storageWrongRightZonedDateTime → UTC mszone loststore full ISO with [zone]reconstructablestore offset, not [zone]stale after DST rule changekeep the IANA idfuture-proof

Testing checklist

The single most valuable test for a serialization layer is a property-based round-trip: generate a value, serialize it, deserialize it, and assert the result equals the original. Run it across every Temporal type you persist and across a spread of zones, including ones with unusual offsets and ones that have had rule changes, and the test will surface any column mismatch or lossy projection long before production data does. Because the assertion is equals, it catches both the obvious failures and the subtle ones where the value looks right but a hidden field drifted.

Beyond the generic round-trip, add targeted assertions for the edge cases the domain cares about. Prove that a PlainDate written and read back does not acquire a time-of-day, by storing it and re-reading through a session pinned to a non-UTC zone. Prove that a ZonedDateTime survives a simulated DST rule change with the disambiguation policy you chose, by constructing a value near a transition and checking both the frozen-offset and live-rules readings. Prove that your JSON reviver rehydrates every dated field, by serializing a nested object graph and asserting no raw strings remain where typed values belong.

It is also worth testing the failure paths on purpose: feed the deserializer a malformed string, a wrong-type string, and an offset that conflicts with its zone, and assert that it rejects or disambiguates exactly as your policy dictates. A serialization layer that silently accepts bad input is as dangerous as one that loses good input, because it lets corruption in through the front door. Explicit negative tests keep the boundary honest.

Serialization test matrixEvery type round-trips through storageAssertions that prove the edge caseInstant → text → InstantequalZonedDateTime round-tripzone preservedPlainDate in DATE colno time addedJSON.parse w/ revivertyped value

Frequently Asked Questions

How should I store a Temporal.ZonedDateTime in a database?

Serialize it with toString(), which yields a self-describing string like '2024-03-15T09:00:00-04:00[America/New_York]', and store it in a text column. That preserves the IANA zone, so you can reconstruct the exact wall-clock time even after future DST-rule changes — something a bare UTC timestamp cannot do.

Can I store a Temporal.PlainDate in a timestamp column?

Avoid it. A timestamp or timestamptz column forces a time-of-day and often a zone onto the value, reintroducing the ambiguity PlainDate is designed to remove. Use a DATE column and store plainDate.toString() ('2024-03-15').

How do I serialize Temporal values to JSON?

Call toString() when writing (many types also implement toJSON) and use a reviver that calls the matching Temporal.*.from() when reading. A plain JSON.parse leaves them as strings, so a reviver is what restores the typed values.

What is the difference between storing an offset and storing an IANA time zone?

An offset like -04:00 is only a snapshot of the zone's rules at one instant; it cannot tell you what the offset will be at any other time. An IANA identifier like America/New_York names the full rule set, so a reader can compute the correct offset for any instant, including after a future daylight-saving rule change. Store the IANA id (inside the bracketed ZonedDateTime string) whenever the value might be read back against different rules; an offset alone is only safe for a fixed historical instant.

Should I keep Temporal values as strings or convert them to native Date for storage?

Keep them as their canonical Temporal strings for the entire transit and only convert to native Date at a final interop point that genuinely requires one. Routing a Temporal value through Date launders away everything Date cannot hold — the named zone, the zoned-versus-zoneless distinction, and sub-millisecond precision — so using Date as an intermediate storage or transport format silently discards information you meant to keep.

How do I round-trip Temporal values through JSON safely?

Give each type an explicit toJSON (several Temporal types already provide one) so JSON.stringify emits the canonical string, and pass a reviver to JSON.parse that calls the matching Temporal.*.from() for each known dated field. A plain JSON.parse leaves those fields as raw strings, so without a reviver the values re-enter your system untyped and are easy to mishandle. The toJSON/reviver pair makes the transformation symmetric and reviewable.