getTimezoneOffset() returns the opposite sign you expect
published
TL;DR
Date.prototype.getTimezoneOffset() returns UTC minus local time, in minutes. A zone behind UTC therefore returns a positive number: Mexico City (UTC-6) gives 360, not -360. Every other place you meet an offset — ISO 8601 strings, Temporal, GMT-06:00 labels — uses the opposite convention. Get the sign backwards in the usual getTime() + offset * 60000 hack and the result is wrong by twice the offset, which is 12 hours in UTC-6.
The problem
The classic one-liner for “shift this Date into local wall-clock fields” appears in a thousand codebases:
const d = new Date("2026-09-02T12:00:00Z"); // noon UTC
// Host machine in America/Mexico_City (UTC-6), local time is 06:00.
d.getTimezoneOffset(); // 360 ← positive
new Date(d.getTime() + d.getTimezoneOffset() * 60000).toISOString();
// '2026-09-02T18:00:00.000Z' ← 18:00, and local was 06:00
Reading those UTC fields as if they were local gives 18:00 for a moment whose real local time is 06:00. That is not a six-hour bug, it is a twelve-hour one — and in a zone at UTC+2 it lands two hours off in the other direction, so a single test run in Europe will not reveal it.
The failure is quiet in the worst way: on a machine at UTC the offset is 0, so the plus and the minus produce identical output. CI is very often at UTC.
Why it happens
The spec defines the value as UTC time minus local time, not the other way round. MDN states it directly:
The
getTimezoneOffset()method ofDateinstances returns the difference, in minutes, between this date as evaluated in the UTC time zone, and the same date as evaluated in the local time zone.
So the sign flips relative to how offsets are written everywhere else:
| Local zone | getTimezoneOffset() | ISO 8601 suffix | Temporal .offset |
|---|---|---|---|
| UTC-8 (Los Angeles, winter) | 480 | -08:00 | -08:00 |
| UTC-6 (Mexico City) | 360 | -06:00 | -06:00 |
| UTC | 0 | Z / +00:00 | +00:00 |
| UTC+3 (Istanbul) | -180 | +03:00 | +03:00 |
Temporal documents the convention the rest of the world uses — local time = UTC time + offset — which is exactly the negation of what getTimezoneOffset() hands you.
What to do
Do not shift Date objects at all. A Date is an instant; it has no time zone. If you want local wall-clock fields, ask a formatter for them:
const d = new Date("2026-09-02T12:00:00Z");
new Intl.DateTimeFormat("en-CA", {
timeZone: "America/Mexico_City",
dateStyle: "short",
timeStyle: "short",
}).format(d);
// '2026-09-02, 6:00 a.m.'
That works for any zone, not just the host’s — which getTimezoneOffset() cannot do at all, since it only ever reports the machine it runs on.
If you genuinely need the offset as a number, get it in the east-positive convention everything else uses, for a zone you name:
function offsetMinutes(date, timeZone) {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
timeZoneName: "longOffset",
}).formatToParts(date);
const label = parts.find((p) => p.type === "timeZoneName").value; // 'GMT-06:00'
const m = /GMT([+-])(\d{2}):(\d{2})/.exec(label);
if (!m) return 0; // plain 'GMT' means UTC
const sign = m[1] === "-" ? -1 : 1;
return sign * (Number(m[2]) * 60 + Number(m[3]));
}
offsetMinutes(new Date("2026-09-02T12:00:00Z"), "America/Mexico_City"); // -360
offsetMinutes(new Date("2026-09-02T12:00:00Z"), "Asia/Kolkata"); // 330
longOffset is the timeZoneName value that renders GMT-06:00; shortOffset gives the compact GMT-8 and is harder to parse. And if you only need the host zone in the normal sign, -d.getTimezoneOffset() is the whole fix — just write the negation where you can see it, not three call frames away.
Temporal.ZonedDateTime carries a zone with the instant and exposes .offset ('-06:00') and .offsetNanoseconds with the conventional sign, which removes the trap entirely. MDN still marks it limited availability — it does not work in some of the most widely used browsers — so it is a good target, not yet a safe default.
Caveats
- The offset is a property of the instant, not the zone. In a DST zone it changes twice a year, so call it on the specific date rather than caching one value at boot. Most of Mexico is fixed at UTC-6 since 2022, but two border zones still switch — see Mexico dropped DST in 2022 and your date math may not know.
- Offsets are not whole hours everywhere. India is
330, Nepal345, Chatham Islands765. Dividing by 60 and assuming an integer breaks on all three. - Never paste the raw number into an ISO string.
+360written as+06:00names a zone on the wrong side of the planet. - This does not apply to
Date.parseortoISOString(), which are UTC-based and unaffected — the trap is specific to arithmetic ongetTime(). - A machine at UTC hides the bug completely. If your CI is UTC, pin a zone in the test (
TZ=America/Mexico_City) or the regression will never fire there.