# Date and Time on the Internet (RFC 3339)

> A reference for RFC 3339, the profile of ISO 8601 that JSON APIs use for timestamps: the date-time grammar, offsets, Z and -00:00, fractional and leap seconds, the RFC 9557 time zone suffix, and how it differs from ISO 8601 and HTTP-date.

Source: https://apiguide.dev/specifications/rfc-3339
Current version: RFC 3339 (updated by RFC 9557)
Official source: https://www.rfc-editor.org/rfc/rfc3339.html
Updated: 2026-10-17

## What is RFC 3339?

ISO 8601 is not one format but a family of them. `2026-10-17`, `20261017`, `2026-W42-6`, `2026-290`, `2026-10-17T14:30`, `T14:30:00,5` and `24:00` are all valid ISO 8601, and a parser that accepts all of them is large, slow and rarely complete. The standard itself is also paywalled, so most "ISO 8601 parsers" implement whatever subset their author guessed at.

RFC 3339, published on the Standards Track in July 2002, cuts that down to one unambiguous form for Internet protocols: a full calendar date, a full time, and an explicit offset from UTC. It describes itself as a profile of ISO 8601's extended format, so every RFC 3339 timestamp is an ISO 8601 timestamp, but not the reverse.

When an API says a field is a timestamp, this is the format it should mean. JSON Schema's `date-time` format and OpenAPI's `format: date-time` are both defined by reference to it. The [timestamps and formats](/guides/timestamps-and-formats) guide covers the API design decisions around it; this page covers the grammar.

---

## 1. The grammar

The ABNF from Section 5.6:

```text
date-fullyear   = 4DIGIT
date-month      = 2DIGIT  ; 01-12
date-mday       = 2DIGIT  ; 01-28, 01-29, 01-30, 01-31 based on month/year
time-hour       = 2DIGIT  ; 00-23
time-minute     = 2DIGIT  ; 00-59
time-second     = 2DIGIT  ; 00-58, 00-59, 00-60 based on leap second rules
time-secfrac    = "." 1*DIGIT
time-numoffset  = ("+" / "-") time-hour ":" time-minute
time-offset     = "Z" / time-numoffset

partial-time    = time-hour ":" time-minute ":" time-second [time-secfrac]
full-date       = date-fullyear "-" date-month "-" date-mday
full-time       = partial-time time-offset

date-time       = full-date "T" full-time
```

The examples from Section 5.8:

```text
1985-04-12T23:20:50.52Z
1996-12-19T16:39:57-08:00
1990-12-31T23:59:60Z
1937-01-01T12:00:27.87+00:20
```

Three things the grammar makes mandatory that ISO 8601 leaves optional: seconds, the separators (`-` and `:`), and the offset. A timestamp without an offset is not RFC 3339, and it is not a timestamp at all in any useful sense, because nobody can tell which instant it names.

---

## 2. RFC 3339 against ISO 8601

| Feature | ISO 8601 | RFC 3339 |
| :--- | :--- | :--- |
| Basic format (`20261017T143000Z`) | Allowed | Not allowed |
| Week dates (`2026-W42-6`) and ordinal dates (`2026-290`) | Allowed | Not in the grammar |
| Reduced precision (`2026-10`, `14:30`) | Allowed | Seconds required |
| Hour `24` | Allowed (end of day) | `00` to `23` only |
| Decimal comma (`30,5`) | Allowed | Full stop only |
| Missing offset (local time) | Allowed | Not allowed |
| Space instead of `T` | Not allowed | Allowed "for readability" |
| Lowercase `t` and `z` | Not allowed | Allowed, but generators SHOULD use uppercase |
| Offset `-00:00` | Not allowed | Allowed (see below) |
| Four-digit year | Expandable by agreement | Required |

The last three rows are where RFC 3339 is looser than ISO 8601, and they cause real interoperability bugs. Postgres and many log formats emit a space; strict ISO 8601 parsers reject it. **Generate the strict intersection**: uppercase `T`, uppercase `Z`, never a space, never `-00:00`. Be liberal only in what you parse, if you must.

---

## 3. Offsets, Z and -00:00

The offset is local time minus UTC: `16:39:57-08:00` is `00:39:57Z` the next day. RFC 3339 originally drew a distinction:

| Offset | RFC 3339 (2002) meaning | RFC 9557 (2024) meaning |
| :--- | :--- | :--- |
| `Z` | UTC is the preferred reference point | UTC time known, local offset unknown |
| `+00:00` | UTC is the preferred reference point | Unchanged: UTC is the preferred reference point |
| `-00:00` | UTC time known, local offset unknown | Not formally deprecated, but `Z` is preferred, since ISO 8601 forbids `-00:00` |

RFC 9557 moved the "unknown local offset" meaning onto `Z` because `-00:00` is invalid ISO 8601 and many parsers reject it. For APIs the practical rule is unchanged: store and send UTC with `Z`. Nothing in an API client should be inferring the user's local time from the offset on a timestamp.

---

## 4. Fractional and leap seconds

Fractional seconds take any number of digits after a full stop. The RFC notes that string comparison only sorts timestamps correctly when they all use the same offset and the same number of fractional digits, so pick a precision and pad to it. Three digits (milliseconds) or six (microseconds) are the usual choices; JavaScript's `Date` holds milliseconds, so a client in that language will truncate anything finer.

A second value of `60` is legal at the end of June or December when a leap second is inserted (`1990-12-31T23:59:60Z`). Most platforms cannot represent it and either reject it or smear it. Do not generate one; accept it if you can, and do not crash if you see one.

---

## 5. Time zones: RFC 9557

An offset is not a time zone. `-08:00` does not say whether the event is in Los Angeles or Vancouver, or what the offset will be next month after a daylight saving change. For future appointments and recurring events that matters.

RFC 9557 (Proposed Standard, April 2024) defines the Internet Extended Date/Time Format (IXDTF): an RFC 3339 timestamp followed by optional bracketed suffixes.

```text
1996-12-19T16:39:57-08:00[America/Los_Angeles]
1996-12-19T16:39:57-08:00[America/Los_Angeles][u-ca=hebrew]
2022-07-08T00:14:07Z[!Europe/London]
```

The bracket holds an IANA time zone name. A `!` marks the suffix as critical: if the offset is inconsistent with the zone at that instant, the recipient MUST reject the value or resolve the conflict, whereas an elective suffix may simply be ignored. `u-ca` names a calendar for display. Using `Z` with a zone avoids inconsistency entirely, because the zone rules then derive the local time.

An IXDTF string is not a valid RFC 3339 string, so a field validated as JSON Schema `date-time` will reject it. If you need a zone, send it as a separate field (`"timeZone": "America/Los_Angeles"`) unless every consumer is known to parse IXDTF.

---

## 6. Not the format for HTTP headers

HTTP fields do not use RFC 3339. [`Date`](/headers/date), [`Last-Modified`](/headers/last-modified), [`Expires`](/headers/expires) and [`Sunset`](/headers/sunset) use HTTP-date, whose preferred form is IMF-fixdate (`Sun, 06 Nov 1994 08:49:37 GMT`), and newer fields such as [`Deprecation`](/headers/deprecation) use the [Structured Fields](/specifications/structured-fields) Date type (`@1688169599`).

| Context | Format | Example |
| :--- | :--- | :--- |
| JSON bodies, query parameters | RFC 3339 | `2026-10-17T14:30:00Z` |
| Older HTTP fields | HTTP-date (IMF-fixdate) | `Sat, 17 Oct 2026 14:30:00 GMT` |
| New HTTP fields | Structured Field Date | `@1792247400` |

Do not put RFC 3339 in a header that is defined as HTTP-date, and do not put HTTP-date in a JSON body.

---

## Best Practices

* Emit `YYYY-MM-DDTHH:MM:SS[.fff]Z`, always UTC, always uppercase, always the same number of fractional digits.
* Reject timestamps with no offset rather than guessing a zone.
* Document the precision you emit and the precision you accept.
* For a date with no time (a birthday, a due date), use RFC 3339 `full-date` (`2026-10-17`), JSON Schema `format: date`, not midnight UTC.
