Skip to main content

Specifying Date, Time and Timezone Formats

Symptom: a delivery scheduled for 3 August shows as 2 August for customers in the Americas. Or a subscription renews an hour early for a fortnight after a daylight-saving change. Or two events with identical timestamps sort differently in two services. All three are the same defect: a temporal value whose meaning was not pinned in the contract. This guide is part of Data Format and Precision Contracts.

Root Cause: Four Different Things Look Like One Type

“A date” in a payload can be any of four distinct concepts, and they are not interchangeable.

Four temporal types, four representations An instant is a moment on the global timeline; a calendar date is a day with no time; a wall-clock time is a reading with no date; a duration is an elapsed amount. Each has a correct representation and a characteristic failure when treated as one of the others. instant a moment on the global timeline 2026-07-29 T10:00:00Z format: date-time created_at, expires_at calendar date a day, with no time and no zone 2026-08-03 format: date delivery_date, birthday wall-clock time a reading, with no date 09:00:00 format: time + zone opening_time duration an elapsed amount 900 (seconds) integer seconds ttl, timeout

Step 1: Require an Explicit Offset on Instants

JSON Schema’s format: date-time means RFC 3339, whose grammar makes the offset mandatory. That is stricter than ISO 8601, which permits 2026-07-29T10:00:00 with no offset at all — a value that means six different instants depending on who reads it.

components:
  schemas:
    Order:
      type: object
      required: [id, created_at]
      properties:
        created_at:
          type: string
          format: date-time
          description: |
            RFC 3339 instant, always in UTC with a `Z` suffix and exactly three
            fractional-second digits. Sortable lexicographically.
          pattern: '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$'
          example: "2026-07-29T10:00:00.000Z"

The pattern alongside format is deliberate. format may not be asserted at all unless the validator is configured for it — the trap covered in using pattern and format keywords safely — and the pattern additionally pins the precision and the Z, which format does not.

Fixing the fractional digits matters more than it looks: mixed precision breaks lexicographic sorting, which is the cheapest way to order timestamps and the one every cursor implementation reaches for.

"2026-07-29T10:00:00Z"    < "2026-07-29T10:00:00.500Z"   // false — "Z" > "."
"2026-07-29T10:00:00.000Z" < "2026-07-29T10:00:00.500Z"  // true

Step 2: Keep Calendar Dates as Dates

A calendar date has no time and no zone. The moment it becomes an instant, it acquires both — and moves.

Delivery:
  type: object
  required: [order_id, delivery_date]
  properties:
    delivery_date:
      type: string
      format: date
      pattern: '^\d{4}-\d{2}-\d{2}$'
      description: |
        The customer's local delivery date. This is a calendar date, NOT an
        instant: do not convert it to a timestamp at any layer.
      example: "2026-08-03"
// The conversion this field exists to prevent:
new Date("2026-08-03").toISOString()          // 2026-08-03T00:00:00.000Z
new Date("2026-08-03").toLocaleDateString("en-US", { timeZone: "America/New_York" })
// "8/2/2026"  ← the day before

Store it as a DATE column, transport it as a date, render it as a date. No layer converts.

Step 3: Carry an IANA Zone Where Local Meaning Matters

An offset records what the clock read at a moment. It does not survive into the future, because offsets change when daylight-saving rules do — and those rules change by legislation, not by arithmetic.

RecurringReport:
  type: object
  required: [schedule_time, timezone]
  properties:
    schedule_time:
      type: string
      format: time
      pattern: '^\d{2}:\d{2}:\d{2}$'
      description: Local wall-clock time the report is generated.
      example: "09:00:00"
    timezone:
      type: string
      pattern: '^[A-Za-z]+/[A-Za-z_+-]+$'
      description: |
        IANA timezone identifier. Required: an offset alone would drift by an
        hour when the zone's daylight-saving rules change.
      example: "Europe/Dublin"
Why a recurring local time needs a zone, not an offset Stored as 09:00+01:00, a recurring job runs at 08:00 local after the clocks change. Stored as 09:00 with Europe/Dublin, it keeps running at 09:00 local across the transition. clocks change before the change after the change "09:00+01:00" runs at 09:00 local still 09:00+01:00 — now 08:00 local "09:00" + Europe/Dublin still 09:00 local — correct Offsets describe the past accurately and predict the future badly.

Step 4: Represent Durations as Integer Seconds

Session:
  type: object
  properties:
    ttl_seconds:
      type: integer
      minimum: 1
      maximum: 2592000
      description: Lifetime in whole seconds. Not "30d", not "PT30M".
      example: 3600
    billing_period:
      type: string
      format: duration
      description: |
        ISO 8601 duration, used ONLY where calendar-aware arithmetic is needed —
        P1M is a month, whose length depends on when it starts.
      example: "P1M"

Integer seconds for anything the code computes with; an ISO duration only where “one month” genuinely has to mean a calendar month rather than a fixed number of seconds. Free-text forms like "2h" or "30 days" require a parser, and every team writes a slightly different one.

Three ways to say "a month" Integer seconds are unambiguous and calendar-blind; an ISO 8601 duration is calendar-aware and needs a library; a free-text duration needs a parser everyone writes differently. 2592000 integer seconds no parser needed always exactly 30 days use for timeouts and TTLs P1M ISO 8601 duration needs a library 28 to 31 days, by start date use for billing periods "1 month" free text a parser per consumer no two agree never use

Step 5: Reject Ambiguity at the Edge

import { z } from "zod";

// offset: true rejects "2026-07-29T10:00:00" — the ambiguous form.
export const instant = z.string().datetime({ offset: true, precision: 3 });

export const calendarDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
  .refine((s) => {
    // Guard against 2026-02-30, which the regex happily accepts.
    const [y, m, d] = s.split("-").map(Number);
    const probe = new Date(Date.UTC(y, m - 1, d));
    return probe.getUTCFullYear() === y && probe.getUTCMonth() === m - 1 && probe.getUTCDate() === d;
  }, "not a real calendar date");

export const ianaZone = z.string().refine((tz) => {
  try { new Intl.DateTimeFormat("en", { timeZone: tz }); return true; }
  catch { return false; }                      // the runtime's own zone database decides
}, "unknown IANA timezone");

The calendarDate refinement earns its keep: a pattern alone accepts 30 February, and a validator that accepts it hands an impossible date to whatever runs next.

Verification

test("an instant without an offset is rejected", () => {
  expect(() => instant.parse("2026-07-29T10:00:00")).toThrow();
  expect(instant.parse("2026-07-29T10:00:00.000Z")).toBeTruthy();
});

test("fixed precision keeps lexicographic sorting correct", () => {
  const stamps = ["2026-07-29T10:00:00.500Z", "2026-07-29T10:00:00.000Z"];
  expect([...stamps].sort()).toEqual(["2026-07-29T10:00:00.000Z", "2026-07-29T10:00:00.500Z"]);
});

test("an impossible calendar date is rejected", () => {
  expect(() => calendarDate.parse("2026-02-30")).toThrow();
  expect(calendarDate.parse("2026-02-28")).toBe("2026-02-28");
});

test("a calendar date does not shift across timezones", () => {
  const date = "2026-08-03";
  const render = (tz: string) =>
    new Intl.DateTimeFormat("en-CA", { timeZone: tz }).format(
      new Date(`${date}T12:00:00Z`));       // midday anchor, not midnight
  expect(render("America/Los_Angeles")).toBe("2026-08-03");
  expect(render("Pacific/Auckland")).toBe("2026-08-03");
});

test("unknown timezones are rejected", () => {
  expect(() => ianaZone.parse("Europe/Atlantis")).toThrow();
  expect(ianaZone.parse("Europe/Dublin")).toBe("Europe/Dublin");
});

The midday anchor in the fourth test is a practical trick worth knowing: if a calendar date must be carried through code that insists on instants, anchoring at 12:00 UTC keeps it on the correct day in every populated timezone.

Edge Cases and Caveats

  • Leap seconds. RFC 3339 permits :60 in the seconds field. Most parsers reject it. If you ingest data from a source that emits leap seconds, normalise before validating rather than discovering it during one.
  • Daylight-saving gaps and overlaps. On a spring-forward day, 02:30 local does not exist; on autumn-back, it happens twice. Scheduling logic must decide what to do, and the contract should say which.
  • Historical offsets. Zone rules change and libraries carry a database that ages. Two services on different library versions can disagree about a 1994 timestamp in some zones — relevant for long-lived records.
  • format: time without a zone. A bare time is a wall-clock reading and needs a zone from somewhere. Pair it explicitly rather than letting the consumer assume UTC.
  • Cursor encoding. Timestamps used as pagination cursors need both fixed precision and a unique tiebreaker, exactly as described in cursor vs offset pagination schema design — ties on identical timestamps are common and break page boundaries.

Storing What You Transport

The representation on the wire is only half the decision; the other half is the column it lands in, and a mismatch between the two reintroduces every problem the wire format solved.

An instant belongs in a timestamp-with-timezone column. Storing it without a zone means the database records a wall-clock reading whose meaning depends on the server’s configuration, which changes when the server does.

A calendar date belongs in a date column. Storing it as a timestamp forces a time and a zone that the value does not have, and the conversion happens twice — once on write and once on read — with a chance of moving the day on each.

A wall-clock time with a separate zone belongs in two columns, a time and a text zone identifier. Combining them into a timestamp discards exactly the information the pair exists to preserve.

The check that catches a mismatch is a round trip: write a value through the API, read it back, and compare it byte for byte with what you sent. Any difference is a conversion happening somewhere in between, and every conversion is a place the value can move.

The Question to Ask of Every Temporal Field

Before choosing a type, ask what would change if the reader were in Auckland. If the answer is nothing, it is an instant. If the answer is that the value would name a different day, it is a calendar date and must not become an instant anywhere in the stack.

Frequently Asked Questions

Is an ISO 8601 string enough?

Not by itself — ISO 8601 permits forms RFC 3339 forbids, including a timestamp with no offset. Require RFC 3339 with an explicit offset, which is what format: date-time means in JSON Schema, and prefer Z for instants.

When should a field be a date rather than a date-time?

Whenever the value is a calendar fact rather than a moment: a delivery date, a birthday, a billing period start. Converting those to instants shifts them by a day for anyone in a timezone on the other side of midnight.

Do I need an IANA timezone as well as an offset?

Only when future local meaning matters. An offset tells you what the clock read; a zone tells you which rules apply. A recurring 09:00 meeting needs the zone, because the offset changes when daylight saving does.

Should timestamps include milliseconds?

Include them when ordering matters, and state the precision in the contract. Mixed precision across a single field breaks lexicographic comparison, which is the cheapest way to sort ISO timestamps.

How should durations be represented?

Integer seconds for anything the code computes with, and an ISO 8601 duration string only when calendar-aware arithmetic is genuinely needed. Free-text durations like 2h are a parser everybody writes differently.