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.
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"
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.
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
:60in 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: timewithout 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.