How to Format Dates and Times in JSON

JSON knows strings, numbers, booleans and null, but not dates, so every API picks a convention. Two are worth using: RFC 3339 timestamps such as “2026-10-06T09:00:00Z”, and Unix time as a number. Here is how they differ and how to avoid the bugs each one invites.

The recommendation in one paragraph

For instants in time, send an RFC 3339 string in UTC with a Z suffix: "2026-10-06T09:00:00Z", with fractional seconds when you need them. It is unambiguous, readable in logs, sorts correctly as text when every value uses the same precision, and is what JSON Schema’s "format": "date-time" means. Use a Unix timestamp only when a protocol requires it, as JWT does, and then name the field so its unit is obvious. For calendar dates without a time, such as a birthday, send "2026-10-06" and do not attach a time zone at all.

ISO 8601 and RFC 3339

ISO 8601 is a large family of date and time notations: week dates (2026-W41-2), ordinal dates (2026-279), durations (P3DT4H), intervals, and a compact “basic” form without separators (20261006T090000Z). Supporting all of it is unusual, so “ISO 8601” in an API document says less than it seems.

RFC 3339 is a strict profile of ISO 8601 for internet protocols. A full timestamp has this shape:

2026-10-06T09:00:00Z
2026-10-06T11:00:00+02:00
2026-10-06T09:00:00.125Z

The rules that matter in practice:

  • Date and time are both required, with a four-digit year and two-digit fields.
  • The time zone offset is mandatory: Z for UTC or +hh:mm / -hh:mm. A timestamp without an offset is not RFC 3339.
  • Fractional seconds are allowed with any number of digits.
  • The RFC allows lowercase t and z, and notes that applications may use a space instead of T for readability; many parsers accept that, but not all, so emit the uppercase T form.
  • -00:00 is a special value meaning “the time is in UTC but the local offset is unknown”.

If your documentation says “ISO 8601”, specify which subset you produce; “RFC 3339 with Z” is the clearest short answer.

Unix time: seconds or milliseconds?

Unix time counts seconds since 1970-01-01T00:00:00Z, ignoring leap seconds. Systems disagree on the unit:

  • Seconds: Unix and POSIX tools, Python’s time.time(), Go’s time.Unix(), and JWT claims such as exp and iat.
  • Milliseconds: JavaScript’s Date.now() and getTime(), Java’s System.currentTimeMillis(), and many analytics and logging tools.
  • Micro- and nanoseconds: database exports, tracing systems and Go’s UnixNano().

You can usually tell them apart by size. For dates between 2001 and 2286, seconds have 10 digits, milliseconds 13, microseconds 16 and nanoseconds 19. A value like 1791277200 is seconds (2026-10-06T09:00:00Z); 1791277200000 is the same instant in milliseconds. Interpreting milliseconds as seconds lands tens of thousands of years in the future; the reverse lands in January 1970. Both are classic bugs, and the Unix timestamp converter shows the unit it detected for each value.

Nanosecond values exceed 2^53, so a JavaScript client silently rounds them; the guide to big integers covers why. Systems that store seconds in a signed 32-bit integer overflow on 2038-01-19T03:14:07Z, the “Year 2038 problem”, so make sure every hop uses 64-bit integers.

Time zones and offsets

An offset is not a time zone. +02:00 says how far a wall clock was from UTC at one instant; Europe/Berlin is a set of rules that produces +01:00 in winter and +02:00 in summer, and those rules change when governments decide so. Three practical consequences:

  1. Store instants in UTC. Converting to local time is a display concern.
  2. Keep the zone name when local time matters. A meeting “every Monday at 09:00 in Berlin” must be stored as a local time plus Europe/Berlin; storing a UTC instant would shift the meeting by an hour after the daylight-saving change.
  3. Never drop the offset. "2026-10-06T09:00:00" with no Z or offset means different instants on different servers.

Be careful with future events: if you store a UTC instant for a concert next year and the country changes its daylight-saving rules, the instant no longer matches the advertised local time. Store the local time and zone name, and compute the instant when you need it.

Parsing traps in JavaScript and Python

JavaScript’s Date parser treats a date-only string as UTC but a date-time without an offset as local time:

new Date('2026-10-06').toISOString();          // '2026-10-06T00:00:00.000Z'
new Date('2026-10-06T09:00:00').toISOString(); // depends on the machine's zone
new Date('2026-10-06T09:00:00Z').toISOString(); // '2026-10-06T09:00:00.000Z'
JSON.stringify({ at: new Date(0) });           // '{"at":"1970-01-01T00:00:00.000Z"}'

JSON.stringify calls Date.prototype.toJSON, which always produces UTC with milliseconds, but JSON.parse does not turn the string back into a Date; you get a string unless you convert it yourself, for example in a reviver.

Python’s standard json module cannot serialise datetime at all and raises TypeError: Object of type datetime is not JSON serializable. Convert explicitly, and attach a time zone first:

from datetime import datetime, timezone
datetime(2026, 10, 6, 9, 0, tzinfo=timezone.utc).isoformat()
# '2026-10-06T09:00:00+00:00'
datetime(2026, 10, 6, 9, 0).isoformat()
# '2026-10-06T09:00:00'  (naive: no offset, avoid in APIs)
datetime.fromisoformat('2026-10-06T09:00:00Z')  # Python 3.11+ accepts Z

Note that isoformat() writes +00:00 rather than Z. Both are valid RFC 3339; if a consumer insists on Z, replace the suffix when formatting.

Validating dates in a JSON document

JSON Schema can check the format with {"type": "string", "format": "date-time"} for RFC 3339 timestamps, "format": "date" for 2026-10-06, and "format": "time" for times of day. Format checking is optional in many validators and often needs enabling explicitly, so test that an invalid value such as "2026-13-45" is really rejected. For numeric timestamps, add a sensible minimum and maximum: a range from 946684800 (the year 2000) to 4102444800 (the year 2100) catches values in the wrong unit immediately. The JSON Schema basics guide shows how to wire this into a schema.

Frequently asked questions

What is the best date format for JSON?

An RFC 3339 string in UTC, such as “2026-10-06T09:00:00Z”. It is unambiguous, readable and supported by every mainstream date library.

What is the difference between ISO 8601 and RFC 3339?

RFC 3339 is a strict subset of ISO 8601 for internet use: full date and time, a mandatory offset, and none of the week, ordinal or compact forms. Every RFC 3339 timestamp is valid ISO 8601, but not the other way round.

How do I tell whether a timestamp is in seconds or milliseconds?

Count the digits. For current dates, seconds have 10 digits and milliseconds have 13. A converter that shows the resulting date makes a wrong unit obvious.

Should I include milliseconds in timestamps?

Include them when events can happen within the same second and order matters. Keep the precision consistent across a field, because strings with different numbers of fractional digits do not sort correctly as text.

Why does JSON.parse not return Date objects?

JSON has no date type, so a timestamp is just a string to the parser. Convert known fields after parsing, or in a reviver function that recognises them.

Related