Skip to content

Timezones Are Not a Formatting Problem

A calendar day and an instant in time are different types, and most date bugs are the moment one is silently converted into the other. The fix is not better formatting — it is refusing the conversion.

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-14 unquoted 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. DATE and TIMESTAMP are different for this reason, and the driver’s mapping to your language’s date type frequently is not.
  • Innocent-looking helpers. date.toLocaleDateString() with no timeZone option 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.

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.