How to Compare ZonedDateTime Across Different Timezones
To compare two Temporal.ZonedDateTime values across zones, decide first whether you mean "same exact moment" (use Temporal.ZonedDateTime.compare() or .equals(), which work on the UTC instant) or "same local clock time" (convert with .toPlainTime() and use Temporal.PlainTime.compare()). This page is part of Working with ZonedDateTime Objects.
Why this scenario is tricky
Comparing two zoned times is ambiguous because there are two different things "compare" can mean, and they give opposite answers. One question is "which happened first on the physical timeline" — an instant comparison that ignores zones entirely, so a noon-in-New-York and a 5pm-in-London that denote the same moment compare as equal. The other question is "which shows an earlier reading on the wall clock" — a civil comparison that ignores the instant, so 9am-in-Tokyo is "earlier" than 10am-in-Los-Angeles even though the Tokyo time is actually later in absolute terms. Confusing the two produces sort orders that look plausible and are wrong.
Legacy Date only supports the instant axis, because it is fundamentally a UTC moment, and it offers no clean way to ask the wall-clock question without string surgery. Worse, developers sometimes compare the formatted strings of two dates, which sorts lexicographically and breaks the moment ordering entirely. Temporal makes both axes explicit and correct: compare/equals on the instant, and a deliberate strip-to-civil step for the wall clock. The trick is simply knowing which axis your feature needs.
The failure mode is conflating two different questions that look identical in code. New York at 12:00 and London at 17:00 on the same date can be the exact same instant, yet New York at 09:00 and Los Angeles at 09:00 are the same wall-clock time but three hours apart in reality. If you reach for the wrong comparison — or worse, compare the ISO strings directly — you get false negatives whenever the offset suffixes differ, even for identical instants.
DST makes it sharper still. The local string 2024-11-03T01:30:00 maps to two distinct instants in New York because the fall-back hour repeats. Compare such strings textually and they look equal; compared as instants they are an hour apart. The fix is to always resolve a bare local time to a concrete ZonedDateTime before comparing, and to pick a comparison axis on purpose.
The two axes are shown below.
Minimal working solution
Temporal.ZonedDateTime.compare(a, b) returns -1, 0, or 1 by comparing the underlying instants, and a.equals(b) is true when two values are the same instant and the same zone. For sorting a feed of events that happened around the world into the order they actually occurred, instant comparison is exactly right — the offsets are accounted for, so two differently-zoned values that name the same moment tie. This is the comparison you want the overwhelming majority of the time, because "when did it happen" is an absolute-timeline question.
For the wall-clock axis, reduce each value to its civil part first — toPlainDateTime() or toPlainTime() — and compare those. That answers "which clock shows an earlier time," which is what you want for questions like "who starts their day earliest" across zones. Being explicit about the reduction makes the intent obvious to the next reader, who can see at the call site that you deliberately dropped the zone rather than forgetting it.
import { Temporal } from '@js-temporal/polyfill';
const ny = Temporal.ZonedDateTime.from('2024-11-01T12:00:00-04:00[America/New_York]');
const london = Temporal.ZonedDateTime.from('2024-11-01T17:00:00+01:00[Europe/London]');
// Instant axis: compares the underlying UTC moment, ignoring offsets.
console.log(Temporal.ZonedDateTime.compare(ny, london)); // 0 — same instant
console.log(ny.equals(london)); // true
// Wall-clock axis: strip the zone, compare only HH:MM:SS.
console.log(Temporal.PlainTime.compare(ny.toPlainTime(), london.toPlainTime())); // -1
Full production version
A production comparator usually needs to sort a heterogeneous list — events each carrying their own zone — and the safe default is to sort by instant using epochNanoseconds (or Temporal.ZonedDateTime.compare as the sort callback). That gives a stable, correct chronological order regardless of how many zones are represented, and it is the ordering a user means by "most recent first." Layer a secondary key (id, or wall-clock time) only to break exact-instant ties deterministically, so the sort is reproducible.
When the feature genuinely is about wall-clock alignment — "find everyone whose local time is currently between 9am and 5pm," a follow-the-sun support rota — compare the civil projections in each entity's own zone. The key discipline is never to mix the axes within one comparison: decide up front whether you are ordering by moment or by clock reading, reduce every value to that axis, and compare consistently. A comparator that comingles instants and wall-clock fields produces an ordering that is not even internally consistent, which manifests as items that appear to jump around when re-sorted.
import { Temporal } from '@js-temporal/polyfill';
export type ComparisonMode = 'instant' | 'wall-clock';
export type Ordering = -1 | 0 | 1;
function coerce(input: Temporal.ZonedDateTime | string): Temporal.ZonedDateTime {
if (input instanceof Temporal.ZonedDateTime) return input;
try {
// Requires a bracketed IANA zone; a bare local string throws here on purpose.
return Temporal.ZonedDateTime.from(input);
} catch {
throw new Error(`Invalid ZonedDateTime input: "${input}"`);
}
}
export function compareZoned(
a: Temporal.ZonedDateTime | string,
b: Temporal.ZonedDateTime | string,
mode: ComparisonMode = 'instant'
): Ordering {
const za = coerce(a);
const zb = coerce(b);
if (mode === 'instant') {
// compare() returns -1 | 0 | 1 from the epoch instants — DST-safe.
return Temporal.ZonedDateTime.compare(za, zb) as Ordering;
}
// Wall-clock: discard zone and instant, keep only local time of day.
return Temporal.PlainTime.compare(za.toPlainTime(), zb.toPlainTime()) as Ordering;
}
When comparison feeds into locale-aware ordering for display lists, hand the result to the patterns in locale-sensitive date comparison and sorting rather than sorting formatted strings.
Verification snippet
The defining assertion is that two values naming the same instant in different zones compare equal on the instant axis: a New York noon and the London 5pm that share the moment must give compare === 0 and equals === true (when zone is also equal, or compare === 0 regardless). Then assert the wall-clock axis gives a different answer for the same pair — the civil readings differ — which proves the two axes are genuinely distinct and you are selecting the intended one.
Add a daylight-saving case where the offset between two zones changes across a date, and assert the instant ordering reflects the actual offsets on that date rather than a hard-coded difference. Verify that sorting a list of mixed-zone events by instant yields true chronological order, and that the same list sorted by wall clock yields the by-the-clock order. Running these under several TZ values confirms neither comparison secretly depends on the host zone.
import { Temporal } from '@js-temporal/polyfill';
// Same instant in two zones must be equal on the instant axis...
console.assert(
compareZoned(
'2024-11-01T12:00:00-04:00[America/New_York]',
'2024-11-01T17:00:00+01:00[Europe/London]'
) === 0,
'identical instants should compare equal'
);
// ...and the two fall-back occurrences must NOT be equal.
const local = Temporal.PlainDateTime.from('2024-11-03T01:30:00');
const earlier = local.toZonedDateTime('America/New_York', { disambiguation: 'earlier' });
const later = local.toZonedDateTime('America/New_York', { disambiguation: 'later' });
console.assert(!earlier.equals(later), 'fall-back occurrences are one hour apart');
Common pitfalls
The primary pitfall is comparing formatted strings instead of values, which sorts lexicographically and scrambles the true order — '9:00 PM' sorts before '9:00 AM' as text. Always compare the typed values. The second is choosing the wrong axis: using instant comparison when the feature wanted wall-clock alignment (or vice versa), which yields a plausible but incorrect order. Decide which question you are answering and reduce every value to that axis before comparing.
A third mistake is assuming equals means "same instant" when it actually requires the same zone too; two values at the same moment in different zones are not equals, though they do compare as 0. Use compare for pure moment equality and equals when zone identity also matters. Finally, avoid hard-coding offset differences between zones to compare them — offsets change with daylight saving, so a fixed delta is wrong for part of the year; let the instant comparison handle it through the IANA rules.
-
Comparing ISO strings textually.
// Wrong — differing offset suffixes for the same instant fail the check. if (ny.toString() === london.toString()) { /* never true here */ } // Right — compare the instant. if (ny.equals(london)) { /* true */ } -
Treating
.equals()as wall-clock equality. It is instant equality. For local-time matching, convert toPlainTimefirst. -
Passing a bare local time to
from().Temporal.ZonedDateTime.from('2024-03-10T01:30:00'); // throws: no zone Temporal.PlainDateTime.from('2024-03-10T01:30:00') // right: resolve explicitly .toZonedDateTime('America/New_York', { disambiguation: 'reject' }); -
Inventing
Temporal.Duration.between(). It does not exist; usea.until(b)orb.since(a).
Frequently Asked Questions
Does Temporal.ZonedDateTime.equals() account for different timezones?
Yes. It compares the underlying UTC epoch nanoseconds, not the local representation. Two instances in different IANA zones are equal when they point at the exact same moment, regardless of their offsets.
How do I compare only the local clock time across zones?
Convert both values with .toPlainTime(), then use Temporal.PlainTime.compare() or .equals(). This strips the zone and instant and compares only the HH:MM:SS.sss components, which is what you want for business-hours rules.
What happens during DST fall-back when comparing times?
Resolve the ambiguous local time with Temporal.PlainDateTime.from(input).toZonedDateTime(zone, { disambiguation: 'earlier' | 'later' }) before comparing. The two occurrences are one hour apart, so they are not .equals(); passing the bare string to Temporal.ZonedDateTime.from() without an offset throws.
How do I compare two ZonedDateTime values in different time zones?
Use Temporal.ZonedDateTime.compare(a, b), which returns -1, 0, or 1 by comparing the underlying instants and correctly accounts for each value's offset. Two times that name the same moment in different zones compare as 0. Use a.equals(b) when you also require the same zone. For sorting a feed of world-wide events into the order they occurred, instant comparison is the right choice.
How do I compare only the wall-clock time, ignoring the zone?
Reduce each value to its civil part first with toPlainDateTime() or toPlainTime(), then compare those. That answers 'which clock shows an earlier reading' rather than 'which happened first', so 9am in Tokyo counts as earlier than 10am in Los Angeles even though it is later in absolute time. Being explicit about the reduction documents that you deliberately dropped the zone for a wall-clock question.
Why does comparing formatted date strings give the wrong order?
Because string comparison is lexicographic, not chronological: '9:00 PM' sorts before '9:00 AM', and 'November' before 'March'. Formatting also applies a locale and zone that can reorder values unpredictably. Always compare the typed Temporal values with compare() or equals() so the ordering reflects the actual instants, then format only for display after sorting.
Related
- Working with ZonedDateTime Objects — the parent overview.
- Locale-sensitive date comparison and sorting — ordering for display lists.
- Modern Date Logic with the Temporal API — the broader Temporal model.