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

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`:

``` yaml
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:

``` yaml
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`:

``` yaml
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`):

``` yaml
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:

``` yaml
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](https://www.powerduck.com/app/?ref=powerduck.com). For which formats are enforced by validators versus merely annotated, see [the JSON Schema constraints that actually work in OpenAPI](https://www.powerduck.com/blog/openapi-json-schema-formats-constraints-codegen-mocks/).

