Timezones Are Not a Formatting Problem
By Trent Development 4 min read
The bug looks like this. A record says a piece was published on 14 August. The page renders “13 August”. Nobody typed 13 anywhere.
What happened is a type error that no type system caught, because both types are called “date”.
Two things, one word
An instant is a point on the universal timeline. “The deploy finished at 14:32:07Z.” It has a timezone by necessity, and rendering it requires choosing one.
A calendar day is a label. “Published 14 August 2026.” It has no timezone, because it is not a point — it is a day, and the day is the same day in every zone. A conference runs 14–16 September; that is true in Tokyo and in Vancouver.
Almost every date library, database column and JSON serialiser represents both with the same construct. new Date("2026-08-14") produces an instant — specifically, UTC midnight. Render that instant anywhere west of Greenwich and you get the 13th.
The date was never wrong. The conversion was.
Where the conversion sneaks in
Rarely in code someone wrote on purpose. It arrives through defaults:
- YAML frontmatter.
publishedAt: 2026-08-14unquoted is parsed into a Date by the YAML parser before your schema ever sees it. Quote it and it stays a string. - Schema coercion.
z.coerce.date()is exactly the conversion, written down and made to look like validation. - JSON round-trips.
JSON.stringify(new Date(...))produces an ISO timestamp; parse it back and you have an instant again, now with an implied zone nobody chose. - ORM column types.
DATEandTIMESTAMPare different for this reason, and the driver’s mapping to your language’s date type frequently is not. - Innocent-looking helpers.
date.toLocaleDateString()with notimeZoneoption renders in the runner’s local zone. Your laptop and your CI runner will disagree.
The last one is the nastiest because it is environment-dependent. It works on the developer’s machine, works in review, and shifts by a day in the deployment region.
The fix is a type discipline, not a utility function
Keep calendar days as strings, in YYYY-MM-DD, and never construct a Date from them for any purpose except rendering — and then only through UTC.
/** "2026-08-14" -> the UTC instant of that calendar day. */
export function utcDay(iso: string): Date {
const [y, m, d] = iso.split('-').map(Number)
return new Date(Date.UTC(y, m - 1, d))
}
Build from the integer parts. Date.UTC has no zone to get wrong, and there is no string parsing step for a runtime to interpret differently.
Three things fall out of this, all of them free:
Sorting works. Zero-padded ISO days sort lexicographically in chronological order. "2026-08-14" < "2026-09-01" is true and requires no parsing at all. The comparator is a.localeCompare(b) and it cannot be timezone-wrong because it never leaves string space.
Serialising works. The value that came out of the file is the value that goes into your markup. datePublished: "2026-08-14" is a legal schema.org value and it is byte-identical to what the author wrote. There is no round-trip to lose it in.
Equality works. Two records published on the same day compare equal. Two instants at UTC midnight compare equal too, right up until one of them was constructed on a machine in a different zone.
Assert the boundary
The reason to be strict about this rather than careful about it is that the failure is silent and reversible-looking. A date that is off by one still renders as a date. It looks fine. It is fine on the machine of whoever checks.
So put the constraint where a machine enforces it:
publishedAt: z
.string({
invalid_type_error:
'must be a QUOTED "YYYY-MM-DD" string — an unquoted YAML date is ' +
'parsed into a Date and shifted by the build machine\'s timezone',
})
.regex(/^\d{4}-\d{2}-\d{2}$/)
Two useful things happen. The unquoted-YAML case fails immediately with a sentence explaining itself, rather than validating as a Date and shifting later. And anyone who later “improves” the schema to z.coerce.date() reintroduces the bug in a diff, in front of a reviewer, rather than by accident.
Then check the other end: assert that the date in the rendered structured data equals the string in the record, exactly. If a formatting change ever reintroduces a parse, that assertion is where you find out.
When you actually need an instant
Some things genuinely are instants and should be stored as such: when a check ran, when a build finished, when a record was last verified. Those have a real point on the timeline and rendering them in a chosen zone is correct.
The discipline is not “never use timestamps”. It is knowing which of the two you have, storing it as that, and never letting a default convert one into the other on your behalf.
Read next
The whole archive-
AI
An Agent With Write Access Is an Outage Waiting for a Schedule
Autonomous agents are good at proposing changes and bad at being trusted with them. The whole design problem is putting a reviewable boundary between the two, and most setups skip it because the boundary is inconvenient.
-
Development
Zero JavaScript Is a Product Decision, Not a Performance Trick
Shipping no client-side script is usually sold as a speed optimisation. The speed is the smallest thing you get. What actually changes is the set of failures your site is capable of having.
-
SEO
The Canonical Tag Is a Claim About Identity
Most canonical bugs are not tagging mistakes. They are a site accidentally telling search engines that all of its pages are the same page — and the defaults in every framework make that the easy thing to do.
Keep reading
More on Development
Every vertical on this site carries its own writing, its upcoming events and its answers on one page — and the whole archive is available as a feed with a real publication date on every item.