Skip to content
jsonbeautifiers
English

JSON has no date type, so pick one and write it down

JSON has six types and none of them is a date, so every codebase invents one. Only one of the four common answers is safe.

Every claim here is either measured or sourced. Where it is neither, it says so.

Someone reports that every record in the admin table was created on 21 January 1970. The payload looks fine:

{ "created_at": 1735689600 }

That is 1 January 2025 in epoch seconds. The front end did new Date(1735689600), which takes milliseconds, so it read 1,735,689 seconds after the epoch and landed three weeks into 1970. Nothing threw. The number was valid, the type was correct, and the meaning was lost between two services because JSON has no way to carry it.

RFC 8259 gives you objects, arrays, strings, numbers, booleans and null. There is no date. Whatever you send is a string or a number that both sides have privately agreed to interpret, and that agreement lives in documentation, or in nobody’s head.

The four conventions you will meet

Convention Example What it is
RFC 3339 string "2025-01-01T00:00:00Z" Self describing, sortable, unambiguous
Epoch seconds 1735689600 10 digits today, no offset, no units label
Epoch milliseconds 1735689600000 13 digits today, the JavaScript default
ASP.NET AJAX "\/Date(1735689600000)\/" Milliseconds wrapped in a string, still in older .NET APIs

Digit count is the only field level clue about the last two. A current epoch in seconds is 10 digits and stays 10 digits until 2286; the same instant in milliseconds is 13. If you inherit a feed with no schema, count the digits before you guess, and remember that a seconds value fed to a milliseconds API always lands in early 1970, which is why that specific bug is so recognisable.

The fourth is a genuine string in the JSON sense. The backslashes are a legal escape for /, so after parsing you hold the literal text /Date(1735689600000)/ and have to run a regex over it. Some variants carry an offset, as in /Date(1735689600000-0800)/, where the offset is decoration: the number is already UTC.

RFC 3339 is not quite ISO 8601

People use the names interchangeably, and then one side accepts something the other rejects. RFC 3339 is a profile of ISO 8601: a smaller, stricter grammar chosen so that machines cannot disagree.

ISO 8601 permits things RFC 3339 does not:

  • Basic format with no separators, 20250101T000000Z
  • Week dates (2025-W01-3) and ordinal dates (2025-001)
  • Reduced precision, such as 2025-01 or just 2025
  • A comma as the decimal separator on seconds, 00:00:00,5
  • A local time with no offset at all

RFC 3339 requires a complete date, a complete time and an offset, always. It also allows one thing ISO 8601 forbids: the offset -00:00, meaning the instant is known but the local offset is not. If you are writing a parser or a validator, -00:00 and +00:00 are the same instant and different claims.

Practical rule: emit RFC 3339, with T uppercase, Z uppercase, and either whole seconds or exactly three fractional digits. Accept a little more than that if you must, but never emit it.

Z is an offset, not the absence of one

Z means the offset is +00:00. It is a fact about the instant. It is not a way of saying “no timezone”, and it is not a way of saying “UTC is this record’s timezone”. Those are different things, and the difference is what makes this hard.

"2025-01-01T00:00:00Z" and "2025-01-01T09:00:00+09:00" are the same instant. If you normalise everything to Z on the way in, you have kept the instant and thrown away where the user was. That is usually correct for created_at and usually wrong for a calendar appointment, where the local wall clock is the thing the user cares about and the offset may not even be known until the day arrives. For those, store the local time and the IANA zone name (Europe/Berlin, not +01:00) in separate fields; offsets change twice a year and governments change them at short notice.

Never emit a timestamp with no offset. "2025-01-01T00:00:00" is a string whose meaning depends on which machine reads it, and JavaScript and Python resolve it differently.

Calendar dates are not timestamps

A birthday, an invoice due date and a public holiday are not instants. They have no time and no offset, and attaching one is a bug that shows up as an off by one for half your users.

new Date('1990-07-14').toLocaleDateString('en-GB')
// '13/07/1990' anywhere west of UTC

The ECMAScript spec parses a date only form as UTC midnight, then the local formatter walks it backwards. Send "1990-07-14" as a plain string, keep it a string, and format it without going through Date at all. If a value can never be “wrong by a few hours”, it should not be carrying hours.

JavaScript specifics

Serialising works out of the box, because Date.prototype.toJSON calls toISOString:

JSON.stringify({ at: new Date(0) })
// '{"at":"1970-01-01T00:00:00.000Z"}'

JSON.stringify({ at: new Date(NaN) })
// '{"at":null}'   toJSON returns null for a non-finite date, it does not throw

Parsing does not work at all. JSON.parse has no idea a string is a date, so a round trip gives you back a string, and the bug appears later when something calls .getTime() on it. The usual patch is a reviver:

const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;

JSON.parse(text, (key, value) =>
  typeof value === 'string' && RFC3339.test(value) ? new Date(value) : value
);

Two warnings about that pattern. It is a heuristic: any string that looks like a timestamp becomes one, including a user’s free text field. And Date collapses the offset, so +09:00 comes back as a UTC instant and the original offset is gone. Prefer reviving by key path, or not reviving at all and converting explicitly at the point of use.

Temporal, the replacement for Date, has types that model these distinctions properly (Instant, PlainDate, ZonedDateTime) and a PlainDate is exactly the calendar date type this article keeps asking for. It has started shipping in browsers as of writing; check current support before you depend on it, and check whether your polyfill’s bundle size is acceptable.

Python specifics

datetime is not JSON serialisable, and the workaround people reach for first is subtly wrong:

import json
from datetime import datetime, timezone

now = datetime.now(timezone.utc)

json.dumps({"at": now})
# TypeError: Object of type datetime is not JSON serializable

json.dumps({"at": now}, default=str)
# '{"at": "2025-01-01 00:00:00+00:00"}'   space separator, not RFC 3339

json.dumps({"at": now}, default=lambda o: o.isoformat())
# '{"at": "2025-01-01T00:00:00+00:00"}'   correct

default=str gives you str(datetime), which uses a space instead of T. It is readable and it is not RFC 3339, so a strict consumer will reject it.

Reading back, datetime.fromisoformat handles the Z suffix from Python 3.11 onwards. On 3.10 and earlier it raises ValueError: Invalid isoformat string, which is why so much older code carries a .replace("Z", "+00:00") before the call. Note also that isoformat() emits +00:00 rather than Z; if your consumer insists on Z, do that substitution on the way out.

Schema will not save you by default

The obvious move is to declare the shape:

{
  "type": "object",
  "properties": {
    "created_at": { "type": "string", "format": "date-time" },
    "due_on":     { "type": "string", "format": "date" }
  },
  "required": ["created_at"]
}

In JSON Schema 2019-09 and 2020-12, format is an annotation by default, not an assertion. Out of the box most validators will happily accept "created_at": "yesterday" against that schema, because it is a string and the format keyword only describes intent. You have to turn assertion on explicitly (in Ajv that means adding ajv-formats). See JSON Schema explained for how the annotation and assertion vocabularies split, and generate a first draft from a real payload with the schema generator.

What to send, what to accept

Send RFC 3339 with an explicit offset for instants, Z normalised unless the local offset is meaningful to the reader. Send plain YYYY-MM-DD strings for calendar dates. Name fields so the type is obvious: created_at for an instant, due_on for a date, and if you really must ship an epoch, call the field expires_at_ms so the units travel with it.

Accept RFC 3339 with or without fractional seconds, offsets in either +HH:MM or Z form, and reject anything with no offset rather than guessing. Validate the string before you construct anything from it, because new Date("nonsense") gives you an Invalid Date that propagates silently.

Paste a live payload into the validator to confirm the structure is sound, then read the date fields with your own eyes: count the digits on every number, and check that every timestamp string ends in an offset. Those two checks catch most of what this article describes. The rest is covered in API response design, where the field naming decision is made once and never revisited.