Skip to main content

Command Palette

Search for a command to run...

Dates, times, and time zones in OpenAPI: date-time, date, duration, and the timezone mistakes that shift bookings

A timestamp with no offset is ambiguous, a calendar date is not a timestamp, and a duration is not a number of days. Teams conflate all three and silently shift reservations across time zones. Here is the RFC 3339 decision table, when to send an IANA zone versus an offset, and ho

Updated
•6 min read•View as Markdown

The most expensive data bug in scheduling APIs is a meeting that moves by an hour or a day for exactly half your users. It happens because "2026-11-03T09:00:00" is not a complete fact. Nine in the morning where? In which zone, under which daylight-saving rules, relative to what instant? OpenAPI gives you distinct formats for instants, calendar dates, and durations; using the wrong one is what corrupts the data.

Instant, date, or duration: three different facts

Concept Format Example Meaning
Absolute instant string / date-time 2026-11-03T09:00:00Z One exact moment on the UTC timeline
Calendar date string / date 2026-11-03 A whole day, independent of zone
Elapsed duration string / duration (RFC 3339 §5.6 / ISO 8601) PT2H30M A length of time, not a point
Recurring time string / RRULE or iana zone + local fields RRULE:FREQ=WEEKLY;BYDAY=MO A rule, not a single timestamp

date-time is RFC 3339 (a profile of ISO 8601). date is YYYY-MM-DD. duration in OpenAPI 3.1 follows the JSON Schema duration format and uses ISO 8601 durations. Pick the type from the meaning, not from what your language's DateTime happens to serialize.

An instant must carry an offset or a Z

A date-time without an offset is not anchored to the timeline. The safest wire form for a recorded event (created, paid, logged) is UTC with a trailing Z:

created_at:
  type: string
  format: date-time
  example: "2026-11-03T09:00:00Z"
  description: Absolute instant in UTC. Always send an offset or a trailing Z.

Store instants in UTC, send them in UTC, and let the client render them in the viewer's zone. Do not store "local server time with no offset"; the moment you deploy in a second region or a daylight-saving transition occurs, the meaning changes.

When you accept a timestamp on input, require the offset. A naive value like 2026-11-03T09:00:00 should be rejected or resolved against a documented zone, never silently assumed to be UTC and then displayed as local.

A booking is usually two facts: an instant plus a zone

Scheduling is subtler than logging. "Every Tuesday team sync at 9 AM Berlin time" and "a dentist appointment on November 3 at 2 PM" are not pure UTC instants, because they must stay at the same local wall-clock time even when daylight-saving rules change. Model the local wall time and the IANA time zone separately:

ScheduledMeeting:
  type: object
  required: [start_local, time_zone]
  properties:
    start_local:
      type: string
      description: Local wall-clock date-time in the given IANA zone, with no offset.
      example: "2026-11-03T09:00:00"
    time_zone:
      type: string
      description: IANA time zone name.
      enum: [Europe/Berlin, America/New_York, Asia/Tokyo]
      example: Europe/Berlin

Why an IANA name (Europe/Berlin) rather than a fixed offset (+01:00)? An offset is a snapshot; an IANA zone carries the full daylight-saving and historical-rule set, so the scheduler resolves the correct offset for each occurrence. Send a fixed numeric offset only for a single already-resolved instant. For recurring meetings, store local time plus zone and compute the UTC instant per occurrence.

A date is not a timestamp

Birthdays, billing days, check-in dates, and "report for 2026-11-03" are whole calendar days. Use format: date:

check_in:
  type: string
  format: date
  example: "2026-11-03"
check_out:
  type: string
  format: date
  example: "2026-11-05"

If you send 2026-11-03T00:00:00Z for a date, a client in San Francisco renders it as November 2 at 5 PM and the guest appears to arrive a day early. A date has no time and no zone; converting it to an instant requires an arbitrary convention ("start of day in which zone?") that the API should not smuggle in. When you must turn a date into a range, send the half-open interval explicitly: from 2026-11-03T00:00 in the hotel's zone to 2026-11-05T00:00.

Durations and recurring rules

A length of time is an ISO 8601 duration: PT30M (30 minutes), P1D (one day), PT2H30M, P1Y6M. Do not send duration: 1800 and make clients guess the unit; if you must use a number, name the unit in the field (timeout_seconds):

grace_period:
  type: string
  format: duration
  example: PT30M
timeout_seconds:
  type: integer
  minimum: 0
  example: 1800

Note that P1D is a calendar day, which is not always 24 hours across a DST transition. For elapsed engineering time (timeouts, TTLs), prefer fixed units like seconds; for human calendar periods (trials, billing cycles), use the duration string and resolve it in the relevant zone.

Recurring schedules do not fit a single timestamp. Send an RFC 5545 RRULE string or explicit recurrence fields, plus the zone:

recurrence:
  type: string
  description: RFC 5545 rule, interpreted in time_zone.
  example: "RRULE:FREQ=WEEKLY;BYDAY=TU;COUNT=8"

What codegen, validators, and mocks do

  • Generators map date-time to Date, OffsetDateTime, or Instant depending on language; a Date in JavaScript is an instant and will render in the host zone, which is why naive strings cause the shift.
  • format: date often generates a string (correct) rather than a Date, because a calendar date is not an instant.
  • Validators that understand format reject a missing offset only if your tooling enforces it; JSON Schema treats date-time as an annotation unless your validator validates formats, so add an explicit pattern or a contract test for the offset when it matters.
  • A spec-driven mock should generate Z-suffixed instants for event timestamps, whole dates for format: date, and valid duration strings for duration. An AI agent generating test data that emits a timestamp for a birthdate or a unitless number for a duration is producing a value that breaks the contract, which a scenario test should catch.

Checklist

  1. Classify each temporal field as an absolute instant, a calendar date, a duration, or a recurrence; choose the format from that meaning.
  2. Recorded instants are stored and sent in UTC with a trailing Z; require an offset on input.
  3. Scheduled wall-clock times send local time plus an IANA zone name, never a frozen offset for recurring events.
  4. Use format: date for whole days; never encode a date as a midnight timestamp.
  5. Use ISO 8601 durations or explicitly named *_seconds fields; never a bare number with an assumed unit.
  6. Express recurrence as an RRULE or recurrence fields plus the zone.
  7. Validate formats in your toolchain and add a scenario that renders an instant in two zones to prove no day-boundary shift.

Get these seven right and a 9 AM meeting stays at 9 AM for everyone, and a check-in never arrives the night before.

You can define these temporal schemas, generate typed clients, and run cross-zone scenario tests against a mock in one local-first workspace, right in your browser. For which formats are enforced by validators versus merely annotated, see the JSON Schema constraints that actually work in OpenAPI.

More from this blog

P

Powerduck Blogs

117 posts

Essays on local-first API tooling, OpenAPI contracts, MCP, and agentic coding failures. We build Powerduck, a local-first OpenAPI studio where the spec stays the source of truth. Topics: AI code review trust, testing AI-generated code, context engineering, and what actually breaks when AI writes your code.