explainer
ISO 8601 vs RFC 3339: Which Timestamp Format Should Your API Return?
RFC 3339 is a strict profile of ISO 8601. Eight timestamps checked against both, plus the no-offset string that is valid in one and ambiguous in the other.
People say “ISO 8601” when they mean “a string like 2026-10-14T10:00:00Z”. That string is ISO 8601. So is 2026-W42-3, and so is 20261014T100000Z, and most parsers you use will reject both. ISO 8601 is a big menu of date and time notations; RFC 3339 is the short, strict profile of it written for Internet protocols, and when an API says “ISO 8601” it almost always means “RFC 3339”. The two disagree on a handful of strings, and one of them (a timestamp with no offset) is valid ISO 8601 that JavaScript will happily read as the wrong instant.
This post is only about the two formats: what each allows, where they differ, and what to emit. Whether you should store UTC is a separate question, covered in store UTC, display local. What a Unix timestamp is lives in Unix timestamps explained.
What each one is
ISO 8601 is the international standard (first published 1988, revised through ISO 8601-1:2019 and a 2022 amendment). It covers calendar dates, week dates (2026-W42-3), ordinal dates (2026-287), a compact “basic” format without separators (20261014T100000Z), durations, intervals, reduced precision, and comma or dot decimal fractions. It’s a family of notations, not one format.
RFC 3339 (July 2002) picks one shape out of that family. Section 5.5 says it plainly: the full ISO grammar “is deemed too complex for most Internet protocols,” so the RFC defines “a profile of ISO 8601 for use on the Internet” where “simplicity is achieved by making most fields and punctuation mandatory.” Section 5.6 gives the whole grammar in about 15 lines:
date-time = full-date "T" full-time
full-date = date-fullyear "-" date-month "-" date-mday ; 2026-10-14
full-time = partial-time time-offset
partial-time = time-hour ":" time-minute ":" time-second [time-secfrac]
time-secfrac = "." 1*DIGIT
time-offset = "Z" / ("+" / "-") time-hour ":" time-minute
time-hour = 2DIGIT ; 00-23
Read it for what it requires: a four-digit year, hyphens, colons, seconds, and an offset. Fractions use a dot. The offset is Z or ±HH:MM with the colon. Nothing is optional except the fraction.
Eight timestamps, checked against both
The ISO column follows the current standard as summarized on Wikipedia’s ISO 8601 page (the standard itself is paywalled) and the ISO grammar collected in RFC 3339 Appendix A. The RFC column follows the section 5.6 grammar. The last column is what new Date(s) returned in Node 22.
| String | Valid ISO 8601? | Valid RFC 3339? | new Date() in Node 22 |
|---|---|---|---|
2026-10-14T10:00:00Z | Yes | Yes | 10:00Z |
2026-10-14T10:00:00.123+05:30 | Yes | Yes | 04:30:00.123Z |
2026-10-14T10:00:00 | Yes (local time) | No, offset is required | Local time of the machine |
2026-10-14T10:00:00-00:00 | No, zero offset must be + | Yes, means “offset unknown” | 10:00Z |
2026-10-14 10:00:00Z | No, ISO uses T | Not in the grammar; a NOTE permits it | 10:00Z (engine fallback) |
20261014T100000Z | Yes (basic format) | No, needs separators | Invalid Date |
2026-W42-3 | Yes (week date, Wed 14 Oct) | No | Invalid Date |
2026-10-14T24:00:00Z | Yes (end of day, per 2022 amendment) | No, hours stop at 23 | 2026-10-15T00:00Z |
Three rows deserve a closer look.
The no-offset string: valid ISO, invalid RFC 3339
2026-10-14T10:00:00 is a legal ISO 8601 local time. It just doesn’t say whose local time. RFC 3339 makes the offset mandatory for exactly this reason. Section 4.4 says interpretation of an unqualified local time “will fail in approximately 23/24 of the globe” and calls the result unacceptable for the Internet.
JavaScript shows the problem nicely, because it handles the two ISO shapes without an offset in opposite ways. The ECMAScript spec (section 21.4.1.32, Date Time String Format, and the Date.parse rules that follow it) says: “When the UTC offset representation is absent, date-only forms are interpreted as a UTC time and date-time forms are interpreted as a local time.” Run under three TZ settings in Node 22:
new Date('2026-10-14T10:00:00').toISOString()
// TZ=UTC '2026-10-14T10:00:00.000Z'
// TZ=America/New_York '2026-10-14T14:00:00.000Z'
// TZ=Asia/Tokyo '2026-10-14T01:00:00.000Z'
new Date('2026-10-14').toISOString()
// '2026-10-14T00:00:00.000Z' in all three
Same string, three different instants, depending on the server. Add a time to a date-only string and it silently moves from UTC to local. If your API returns 2026-10-14T10:00:00, every client gets to guess, and Node gives a different answer on a laptop than on a server running in UTC.
The same rule applies to a browser’s <input type="datetime-local">, which produces an offset-less string like 2026-10-14T10:00. Our duration calculator and the Date → Timestamp side of the timestamp converter both pass that value to new Date(), so they read it as your local wall time. That’s what you want for a form you’re filling in yourself. It’s the wrong choice for an API payload.
-00:00 is not Z
RFC 3339 section 4.3, in full: “If the time in UTC is known, but the offset to local time is unknown, this can be represented with an offset of ‘-00:00’. This differs semantically from an offset of ‘Z’ or ‘+00:00’, which imply that UTC is the preferred reference point for the specified time.”
So 10:00:00Z means “10:00 UTC, and UTC is the frame we meant.” 10:00:00-00:00 means “10:00 UTC, and we don’t know what the local clock said.” The convention came from email (RFC 2822 has the same idea). ISO 8601 forbids it: a zero offset must carry a plus sign. This is the main place where RFC 3339 is not a pure subset of ISO.
Your parser almost certainly ignores the difference. Node turns both strings into the same Date, and the “unknown” flag is gone. If that distinction matters to your data, you have to carry it in a separate field.
The space separator
The sources ranking for this question disagree on this one, so here is the actual text. The ABNF in section 5.6 says full-date "T" full-time. Below it, a NOTE says: “ISO 8601 defines date and time separated by ‘T’. Applications using this syntax may choose, for the sake of readability, to specify a full-date and full-time separated by (say) a space character.”
So a space is permitted by the RFC’s prose, but it is not in the grammar. Anything that validates against the grammar will reject it. JSON Schema is the common case: its date-time format is defined as “a valid representation according to the ‘date-time’ ABNF rule” from RFC 3339 section 5.6. So 2026-10-14 10:00:00Z fails a strict format: date-time check, even though plenty of databases print timestamps that way.
Two smaller notes from the same section. Lowercase t and z are allowed, but “applications that generate this format SHOULD use upper case letters.” And Node parsed the space and lowercase forms only through V8’s implementation-specific fallback. They’re outside ECMAScript’s own format, so don’t count on every engine doing the same.
Hour 24 and leap seconds
Section 5.7: “Although ISO 8601 permits the hour to be ‘24’, this profile of ISO 8601 only allows values between ‘00’ and ‘23’ for the hour in order to reduce confusion.” ISO’s history here is messy: 24:00 was allowed in older editions, dropped in 8601-1:2019, then brought back by the 2022 amendment to mean the end of a day.
JavaScript sides with ISO here. The ECMAScript format allows HH from 00 to 24, and new Date('2026-10-14T24:00:00Z') comes back as midnight on the 15th. Going the other way, RFC 3339 allows a second value of 60 for leap seconds (its own example is 1990-12-31T23:59:60Z), and ECMAScript caps seconds at 59. new Date('2026-12-31T23:59:60Z') is an Invalid Date. So the profile your API promises and the parser your client uses do not accept quite the same set.
What your API should return
Pick the narrow end of both specs, the strings that every row above agrees on:
YYYY-MM-DDTHH:MM:SS, optionally with.and exactly three fractional digits (milliseconds).- Uppercase
Tbetween date and time. - Always an offset:
Zor±HH:MMwith the colon. Never leave it off. - No week dates, ordinal dates, basic format, comma fractions, or hour 24.
That shape is valid RFC 3339, valid ISO 8601, and valid ECMAScript format all at once. You probably already produce it: Date.prototype.toISOString() returns 2026-10-14T10:00:00.000Z, and so does JSON.stringify on a Date, because it calls toISOString(). Document the field as “RFC 3339 date-time” rather than “ISO 8601,” because the first is a contract and the second is a menu.
To check a string by hand, paste it into the date format converter. It runs the input through new Date(), so you see exactly what a JavaScript client will make of it, and shows the ISO 8601 form (from toISOString(), which is also valid RFC 3339) alongside Unix seconds, milliseconds and local time. An offset-less string there shows you your own zone’s reading, which is the bug in miniature. For a column of epoch values, epoch batch turns one-per-line timestamps into the same Z-suffixed form, and the timezone converter shows one wall time across many zones with their offsets, handy when you need the right ±HH:MM to put on the end.