Working with ZonedDateTime Objects

Temporal.ZonedDateTime is the one Temporal type that knows everything about a moment: the exact instant, the wall-clock time a human reads, and the IANA zone that ties them together. Part of Modern Date Logic with the Temporal API. If you are new to Temporal, skim Getting Started with Temporal API before diving in here.

What breaks without it

Without a zone-aware type, the moment you try to reason about "what time is it there" you are forced into one of two bad options: store a bare wall-clock string and lose the instant it corresponds to, or store a UTC instant and lose the zone it was meant to be read in. Legacy Date pushes you toward the second, because it is a UTC instant with locale-dependent display methods and no slot for an IANA zone. So applications bolt a zone name onto a separate column and hope the two never drift, or they hard-code offsets that are correct for only half the year. Both approaches leak wrong times at daylight-saving boundaries.

ZonedDateTime exists precisely to hold all three facts a scheduled time needs at once — the instant, the wall-clock reading, and the named zone that ties them together — so that no operation has to reconstruct a missing piece by guessing. That completeness is what lets it convert between zones exactly, do arithmetic that respects transitions, and serialize to a single self-describing string. The bugs that plague zone handling in legacy code are, almost without exception, symptoms of a value that was missing one of those three facts.

The legacy Date object stores a single UTC number and borrows the host machine's timezone for every read. That works until you schedule a meeting for "9 AM in Berlin" on a server running in UTC, add a day across a spring-forward transition, or render a timestamp during server-side rendering where the host zone differs from the user's. The displayed time drifts by an hour, recurring events land in the wrong slot, and hydration mismatches flash the wrong value. ZonedDateTime removes the ambiguity by carrying the zone with the value and applying real DST rules to every calculation.

Wall-clock vs absolute confusionTwo kinds of 'a day'Wall-clock vs absolute confusionadd 24h for 'tomorrow'drifts ±1h at DSTadd({days:1})holds wall-clock timeA calendar day is 23–25 real hours across DST; only ZonedDateTime knows which.

The hardest idea on this page: wall-clock days vs. absolute hours

The single concept that unlocks correct zoned arithmetic is that "one day later" and "twenty-four hours later" are different operations, and they diverge exactly on daylight-saving days. Adding { days: 1 } to a ZonedDateTime keeps the same wall-clock time on the next calendar day — 9am today to 9am tomorrow — even when the intervening night was 23 or 25 hours long. Adding { hours: 24 } moves exactly 24 hours of physical time, which on a spring-forward day lands at 10am and on a fall-back day at 8am. Neither is "more correct"; they answer different questions, and a scheduler must know which one it means.

Most human scheduling is wall-clock: a daily standup is at 9am regardless of whether the clocks shifted overnight, and a subscription renews on "the 1st" not "every 720 hours." So { days: 1 }, { months: 1 }, and their calendar kin are usually what you want. Absolute-hour arithmetic is for elapsed-time reasoning — timeouts, SLA windows, "two hours from now" — where the physical duration is the point. ZonedDateTime keeps both available and distinct, which is exactly the distinction legacy millisecond math erases by treating every day as 86,400,000 milliseconds.

A ZonedDateTime is an instant + an IANA timeZone + a calendar. The subtle part is how arithmetic behaves across a DST boundary. .add({ days: 1 }) preserves the wall-clock time — 9 AM stays 9 AM even though the real elapsed time was 23 or 25 hours. .add({ hours: 24 }) adds absolute time — exactly 24 hours of physical duration, which can land on a different wall-clock hour. The diagram below shows both paths across the US spring-forward night.

ZonedDateTime anatomy and DST-aware additionA ZonedDateTime combines an exact instant, an IANA timeZone, and a calendar. add({days:1}) keeps the local clock at 09:00 across the spring-forward gap, while add({hours:24}) advances the absolute instant and lands at 10:00 local.Temporal.ZonedDateTime = three partsexact instant (ns)IANA timeZonecalendar (iso8601)Sun 09:00EST -05:0002:00 to 03:00 gap (skipped).add({ days: 1 })Mon 09:00 EDTsame wall clock.add({ hours: 24 })Mon 10:00 EDT+24h absoluteCalendar units keep the clock. Time units keep the duration.

API reference

A ZonedDateTime is constructed from a wall-clock string plus a bracketed IANA zone ('2024-03-15T09:00:00-04:00[America/New_York]'), or by attaching a zone to a PlainDateTime via toZonedDateTime, or by reading Temporal.Now.zonedDateTimeISO(zone). Once built, it exposes the full set of civil fields (year, month, day, hour, dayOfWeek, …) read in its own zone, plus epochNanoseconds and toInstant() for the absolute moment, and offset/timeZoneId for the zone facts. The point is that every projection you might need is one accessor away, with no external zone database call in your own code.

The operations that make it powerful are withTimeZone (same instant, new zone lens), startOfDay (first valid instant of the civil day, DST-aware), round (snap to a unit, respecting transitions), and the add/subtract/until/since arithmetic that distinguishes calendar units from clock units as described above. Serialization is toString() producing the self-describing bracketed form, and Temporal.ZonedDateTime.from() reconstructs it losslessly, with an offset disambiguation option for the rare case where a stored offset conflicts with the zone's current rules.

Member Signature Returns Timezone caveat
Temporal.ZonedDateTime.from() from(item, opts?) ZonedDateTime String must carry [IANA/Zone]; bare local times throw
Temporal.Now.zonedDateTimeISO() zonedDateTimeISO(tz?) ZonedDateTime Defaults to host zone — pass explicit tz on servers
.add() / .subtract() add(Duration | object, opts?) ZonedDateTime Calendar units keep wall-clock; time units add absolute
.until() / .since() until(other, opts?) Duration Operates on UTC instants — DST-safe
.equals() equals(other) boolean Compares exact instant, not wall-clock
.withTimeZone() withTimeZone(tz) ZonedDateTime Same instant, re-projected to a new zone
.toInstant() / .toPlainDateTime() Instant / PlainDateTime Drops zone or instant, respectively
.offset / .offsetNanoseconds property string / number Reflects DST at that moment, not a fixed value

ZonedDateTime APIsZone-aware operationsZonedDateTime APIsZonedDateTime.from(str)needs an [IANA] zonezdt.add({days:1})calendar add, holds wall timezdt.withTimeZone('Tokyo')same instant, new zonezdt.offset / .hoursInDay23/24/25 on transition days

Approach A: the legacy Date solution and why it falls short

// "Add one day to a 9 AM New York appointment" with legacy Date.
const appt = new Date('2024-03-09T09:00:00-05:00'); // 9 AM EST
const nextDay = new Date(appt.getTime() + 24 * 60 * 60 * 1000); // add 24h of ms
// In a process running in America/New_York, this reads as 10:00 AM, not 9:00 AM,
// because 2024-03-10 lost an hour to spring-forward. Date cannot keep the clock.
console.log(nextDay.toString());

Date only knows how to add absolute milliseconds. It has no concept of "the same wall-clock time tomorrow," and the result it displays depends on the host machine's zone. There is no safe way to express calendar-aware arithmetic with Date alone.

Legacy DateOnly UTC or host zone; DST is manualLegacyModern+ 24*3600*1000 msabsolute, driftsno arbitrary zoneDST math by handzdt.add({days:1})wall-clock heldany IANA zoneDST from tz db

Approach B: the Temporal solution

import { Temporal } from '@js-temporal/polyfill';

const appt = Temporal.ZonedDateTime.from(
  '2024-03-09T09:00:00-05:00[America/New_York]'
);

// Calendar unit: keep the wall clock at 09:00 the next day.
const sameClock = appt.add({ days: 1 });
console.log(sameClock.toString());
// '2024-03-10T09:00:00-04:00[America/New_York]' — still 9 AM, now EDT

// Time unit: add exactly 24 hours of real elapsed time.
const absolute = appt.add({ hours: 24 });
console.log(absolute.toString());
// '2024-03-10T10:00:00-04:00[America/New_York]' — 10 AM, because the day was 23h long

The choice is explicit and intentional, not an accident of host configuration. For the full mental model of mutation-free calendar math, see date arithmetic without mutations and its detailed walkthrough of adding months without overflow.

Disambiguating ambiguous and nonexistent local times

When you build a ZonedDateTime from a bare local time, DST transitions create two failure modes: a fall-back hour that exists twice and a spring-forward hour that does not exist at all. The disambiguation option decides what happens.

import { Temporal } from '@js-temporal/polyfill';

// Fall-back: 2024-11-03 01:30 occurs twice in New York.
const local = Temporal.PlainDateTime.from('2024-11-03T01:30:00');

// 'earlier' picks the first (pre-transition) occurrence, EDT -04:00.
const first = local.toZonedDateTime('America/New_York', { disambiguation: 'earlier' });
// 'later' picks the second (post-transition) occurrence, EST -05:00.
const second = local.toZonedDateTime('America/New_York', { disambiguation: 'later' });

console.log(first.equals(second)); // false — one hour apart in real time
// 'compatible' (the default) mirrors legacy Date behavior; 'reject' throws on ambiguity.

Use 'reject' in scheduling pipelines where a silently-shifted time is a data-integrity bug, and choose 'earlier'/'later' deliberately when a policy dictates which occurrence wins.

Zone-anchored arithmeticDo calendar math on ZonedDateTime; offsets resolve automaticallyZone-anchored arithmeticZonedDateTimeadd({days})wall-clockheld

Production implementation

In production the recurring theme is: keep the zone attached from the moment a time enters the system until it leaves, and choose calendar-unit or clock-unit arithmetic deliberately at each step. A booking service stores each appointment as a full ZonedDateTime string so the customer's wall-clock intent survives a later rule change; a reminder scheduler advances by { days: 1 } so 9am stays 9am across a transition; an SLA monitor advances by { hours: 4 } because it genuinely means four hours of elapsed time. The type does not make the decision for you, but it makes the decision expressible, which is the prerequisite for getting it right.

The other production discipline is reducing to the right type for each job. Compare and sort events by their instant (epochNanoseconds) when you care about ordering on the physical timeline; reduce to a PlainDate in a chosen zone when you care about civil-calendar bucketing like "which day did this happen for the user." Storing the zoned value as the canonical record and deriving these projections on demand keeps one source of truth while serving every downstream question. That pattern — canonical ZonedDateTime, derived projections — is the backbone of a codebase that handles time without drift.

A reusable factory that validates input, supplies an explicit zone, and rejects ambiguity by default keeps drift out of your codebase.

import { Temporal } from '@js-temporal/polyfill';

export interface ZonedInput {
  /** ISO local date-time WITHOUT offset, e.g. '2024-11-03T01:30:00'. */
  local: string;
  /** IANA identifier, e.g. 'Europe/Berlin'. Never a numeric offset. */
  timeZone: string;
  disambiguation?: 'compatible' | 'earlier' | 'later' | 'reject';
}

export function makeZoned({
  local,
  timeZone,
  disambiguation = 'reject', // fail loud on DST ambiguity in scheduling code
}: ZonedInput): Temporal.ZonedDateTime {
  let plain: Temporal.PlainDateTime;
  try {
    plain = Temporal.PlainDateTime.from(local); // throws on malformed input
  } catch {
    throw new Error(`Invalid local date-time: "${local}"`);
  }
  try {
    // toZonedDateTime applies the real tzdata rules for this zone + instant.
    return plain.toZonedDateTime(timeZone, { disambiguation });
  } catch (err) {
    // 'reject' surfaces gap/overlap times here instead of silently shifting them.
    throw new Error(`Cannot resolve ${local} in ${timeZone}: ${(err as Error).message}`);
  }
}

On servers, serverless functions, and edge runtimes, never call Temporal.Now.zonedDateTimeISO() without an argument — it reads the host zone, which is almost always UTC in production and rarely the user's. Pass the zone from the user's profile or a request header. In containers, keep tzdata current so recent DST legislation resolves correctly; a stale image will compute offsets from outdated rules. The arithmetic behind those offsets is covered in timezone offset math explained.

Scheduling utilityValidate the zone, do calendar math, choose disambiguationScheduling utilityzoned startvalidate zoneadd + disambigoccurrences

Edge cases

The headline edge cases are the two kinds of daylight-saving transition. A spring-forward gap deletes a wall-clock hour: 2:30am simply does not exist on that date, so anchoring that wall time requires a disambiguation policy to resolve it forward. A fall-back overlap repeats an hour: 1:30am occurs twice, so the same wall time maps to two instants and you must choose which. ZonedDateTime construction takes a disambiguation option ('compatible', 'earlier', 'later', 'reject') so these are explicit decisions rather than silent guesses — the default handles most cases, but scheduling right at a transition should choose on purpose.

Beyond DST, there are zones with offsets that are not whole hours (India at +5:30, Nepal at +5:45), zones that have changed their rules over history, and the very rare political redefinition of a zone. Because ZonedDateTime resolves offsets through the IANA database keyed on the instant, it handles the fractional and historical cases without special code. The one thing to store carefully is the IANA identifier itself rather than a frozen offset, so that a value written today still resolves correctly if the zone's future rules change — the difference between a durable record and one that silently rots.

Spring-forward gap (nonexistent time)

import { Temporal } from '@js-temporal/polyfill';

// 2:30 AM does not exist on 2024-03-10 in New York.
const gap = Temporal.PlainDateTime.from('2024-03-10T02:30:00');
const compatible = gap.toZonedDateTime('America/New_York'); // default 'compatible'
console.log(compatible.toString());
// '2024-03-10T03:30:00-04:00[America/New_York]' — pushed forward into the next valid hour

Fall-back overlap (time exists twice)

Already shown above: the same local string maps to two distinct instants one hour apart. Decide which one your domain means.

Month-end rollover

import { Temporal } from '@js-temporal/polyfill';

const jan31 = Temporal.ZonedDateTime.from('2024-01-31T12:00:00-05:00[America/New_York]');
// Calendar arithmetic constrains to the last valid day rather than overflowing.
console.log(jan31.add({ months: 1 }).toPlainDate().toString()); // '2024-02-29' (leap year)

Re-projecting across zones

import { Temporal } from '@js-temporal/polyfill';

const tokyo = Temporal.ZonedDateTime.from('2024-06-01T09:00:00+09:00[Asia/Tokyo]');
// Same instant, viewed from another zone — the wall clock changes, the moment does not.
console.log(tokyo.withTimeZone('America/New_York').toString());
// '2024-05-31T20:00:00-04:00[America/New_York]'

ZonedDateTime edge casesTransition-day hazardsZonedDateTime edge casesSpring gap02:30 missingdisambiguationFall overlap01:30 twiceearlier/laterhoursInDay23 / 24 / 25not always 24

Gotchas and common pitfalls

ZonedDateTime pitfallsAbsolute math for wall-clock intentWrongRightadd hours for daily eventdrifts at DSTadd({days:1})wall-clock stablestore Instant + re-add daysno zonekeep the ZonedDateTimezone retained

Testing checklist

Scenario Input Expected
Add a day across spring-forward 2024-03-09T09:00[NY].add({days:1}) 2024-03-10T09:00 -04:00
Add 24 hours across spring-forward 2024-03-09T09:00[NY].add({hours:24}) 2024-03-10T10:00 -04:00
Nonexistent local time 2024-03-10T02:30 → NY (compatible) 03:30 -04:00
Fall-back, earlier vs later differ 2024-11-03T01:30 earlier vs later not .equals()
Month-end constrain 2024-01-31[NY].add({months:1}) date 2024-02-29

Run your suite under several host zones to flush out implicit-zone assumptions:

# CI matrix — the result must be identical regardless of the host zone.
for tz in UTC America/New_York Asia/Tokyo Pacific/Chatham; do
  TZ=$tz npx jest zoneddatetime
done

ZonedDateTime test matrixDST behaviour asserts correctlyAssertions that prove the edge caseadd days over spring09:00 held, 23hadd hours 24drifts to 10:00fall-back 01:30two instantshoursInDay25 on fall-back

Frequently Asked Questions

Why does .add({ days: 1 }) give a different result than .add({ hours: 24 })?

Calendar units like days preserve the wall-clock time, so 9 AM stays 9 AM even when the day is 23 or 25 hours long across a DST transition. Time units like hours add absolute elapsed time, so .add({ hours: 24 }) advances the instant by exactly 24 hours and can land on a different local clock time.

When should I use ZonedDateTime instead of Instant?

Use ZonedDateTime when the wall-clock time and timezone matter to your domain: scheduling, business hours, and user-facing timestamps. Use Instant for UTC-only concerns such as logging, idempotency keys, and storage where timezone context is irrelevant.

How do I convert a legacy Date to a ZonedDateTime safely?

Use Temporal.Instant.fromEpochMilliseconds(date.getTime()).toZonedDateTimeISO(zone), supplying the IANA zone you intend to interpret the moment in. Validate the original Date's zone assumptions first so you do not lock in silent drift. A full round-trip walkthrough lives in the related guide on converting between Date and Temporal.Instant.

Is ZonedDateTime ready for production?

Yes, with @js-temporal/polyfill pinned to a fixed version. Native support is progressively shipping in browsers and Node.js, and edge runtimes may need explicit timezone-database bundling.

What is a Temporal.ZonedDateTime and when should I use it?

It is a date-time that carries three facts at once: the exact instant, the wall-clock reading, and the named IANA time zone that links them. Use it whenever a time is meaningful in a particular place — appointments, reminders, store hours, anything a user reads on a local clock — because it converts between zones exactly, does daylight-saving-aware arithmetic, and serializes to one self-describing string. Reach for the simpler PlainDate or Instant only when the value genuinely has no zone (a birthday) or is purely an instant (a log timestamp).

Why does adding one day differ from adding 24 hours on a ZonedDateTime?

They diverge on daylight-saving days. Adding { days: 1 } keeps the same wall-clock time on the next calendar day even when that day is 23 or 25 hours long, which is what human scheduling means by 'tomorrow at 9am'. Adding { hours: 24 } moves exactly 24 hours of physical time, landing an hour off the wall clock on a transition day. Both are valid but answer different questions, so choose calendar units for wall-clock scheduling and clock units for elapsed-time reasoning.

How do I store a ZonedDateTime without losing information?

Serialize it with toString(), which produces a self-describing string like 2024-03-15T09:00:00-04:00[America/New_York], and store that in a text column. It keeps the instant, the offset, and the IANA zone, so you can reconstruct the exact wall-clock time even after a future rule change. Storing only a UTC timestamp loses the zone, and storing only an offset loses the ability to recompute future offsets — keep the bracketed IANA identifier.