Handle dates and time zones without bugs
A reliable approach to date math in Northwind scripts — TZ-aware comparisons and formatting.
Published Aug 8, 2025
Dates are the single richest source of subtle, hard-to-reproduce bugs in Apps Script. A script works perfectly for months, then quietly produces wrong results for one week in spring and one in autumn — the daylight-saving switchovers. Or it works for you and not for a colleague in another country.
The root cause is almost always an implicit assumption about which time zone a date is in. Get explicit about time zones everywhere — in formatting, in comparisons, in arithmetic — and the whole class of bugs disappears.
The trap
A JavaScript Date is just a number: milliseconds since the Unix epoch, in UTC.
It carries no time zone of its own. The time zone only enters the picture when
you display or format that number.
This causes three recurring problems:
new Date()is UTC under the hood. The instant is fine, but any method that splits it into year/month/day/hour does so in some assumed zone.- Display is environment-dependent. The same
Datecan render differently depending on the script’s time zone setting, the spreadsheet’s setting, or a viewer’s browser. - Comparisons silently drift across DST. Naive day arithmetic that adds a fixed number of milliseconds breaks on the two days a year when a local day is not 24 hours long.
Always specify TZ
Never let a date format itself with whatever default is in scope. Use
Utilities.formatDate() and pass the time zone explicitly, every time. This
makes the output deterministic regardless of where the script runs.
// Today's date as a string, explicitly in the London time zone.
const today = Utilities.formatDate(new Date(), 'Europe/London', 'yyyy-MM-dd');
// Start-of-day timestamp, again pinned to a named zone — not a guess.
const start = Utilities.formatDate(
new Date(), 'Europe/London', "yyyy-MM-dd'T'00:00:00"
);
Use IANA zone names like Europe/London or America/New_York, never raw
offsets like GMT+1. An offset is wrong half the year; a named zone knows when
DST applies and adjusts itself.
For comparisons
When you need to compare two dates, the safest approach is to compare ISO 8601 strings. As long as both strings include the same offset (or both are UTC), a plain string comparison gives the correct chronological order.
// toISOString() always returns UTC with a 'Z' suffix — a stable,
// unambiguous representation that sorts and compares correctly.
const aIso = a.toISOString();
const bIso = b.toISOString();
// Lexical string comparison matches chronological order here, because
// ISO 8601 is designed so that earlier instants sort earlier.
return aIso < bIso;
This works because ISO 8601’s fixed-width fields (year, then month, then day, and so on) mean alphabetical order equals time order. It sidesteps every time zone subtlety as long as both sides are in the same reference frame.
DST gotchas
Daylight-saving transitions are where most date bugs actually bite. Keep these in mind.
setHours(0, 0, 0, 0)does not always give local midnight. It sets the fields in the runtime’s assumed zone, which may not be the zone you care about — and on a DST-transition day, “midnight” may not even exist.- Adding
86400000ms is not “+1 day”. On the day a clock springs forward or falls back, the local day is 23 or 25 hours long. Adding a fixed millisecond count lands you an hour off. - For day arithmetic, mutate the date fields instead. `d.setDate(d.getDate()
- 1)` adds one calendar day correctly across DST boundaries, because it works in calendar terms rather than raw milliseconds.
// Correct: advances by one calendar day, DST-safe.
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
// Wrong: drifts by an hour on DST-switch days.
// const tomorrow = new Date(today.getTime() + 86400000);
Time zones in Sheets
A spreadsheet has its own time zone setting, separate from the script’s. You
will find it under File → Settings. Every Date value stored in a cell
renders according to that setting, not the script’s.
This is a frequent source of “the script wrote the right value but the cell
shows the wrong time” confusion. When a script writes dates into a sheet, make
sure the spreadsheet’s zone matches what you intend, and use that same zone in
any Utilities.formatDate() calls that produce strings for that sheet.
Common mistakes
- Formatting without a zone.
date.toString()or a bareformatDate()uses whatever default is in scope, so the same code gives different output in different projects. Always pass an explicit zone. - Using offsets instead of named zones.
GMT+1is correct for only part of the year.Europe/Londonis correct all year. - Adding milliseconds for day math. Fixed-millisecond arithmetic breaks on
DST days. Use
setDate()for calendar-day changes. - Comparing
Dateobjects across zones without normalising. If the two dates were built in different zones, convert both to UTC ISO strings first. - Forgetting the spreadsheet’s own zone. A script can be perfectly correct
and still display the wrong time because the sheet’s setting differs. Check
File → Settingswhen cell output looks off. - Assuming the user’s zone equals the script’s zone. A script shared across countries will surprise you. Pick one canonical zone and be explicit about it everywhere.