How do I store dates in JSON?

Dark navy cover card with the title 'How do I store dates in JSON?' in white and a green accent bar

JSON has no date type, so you store a date as a string that both sides agree to read as a date. The safest JSON date format is an ISO 8601 string in the RFC 3339 profile, written in UTC or with an explicit offset, such as "1985-04-12T23:20:50.52Z". Use a plain "YYYY-MM-DD" string for calendar dates like birthdays, and a number only when you control both ends.

Why JSON has no date type

JSON has six kinds of values: strings, numbers, booleans, null, objects and arrays. A date is none of them, so every API invents a convention, and the parser on the other side only knows about it if you tell it.

That is why the same "date" can show up as "2019-01-01", 1546300800000, or an old .NET-style "/Date(...)/" wrapper. All three are valid JSON. Only one of them is easy to read, sort and parse everywhere. If you're new to the basic value types, what JSON format is covers them.

The standard answer comes from the I-JSON profile. RFC 7493, section 4.3, says date and time values should be "string values in ISO 8601 format, as specified in [RFC3339]". It adds three rules on top:

  1. Uppercase letters. Write T and Z, not t and z.
  2. An explicit time zone. The zone must be "included not defaulted".
  3. Seconds, even when zero. Write 15:17:00, not 15:17.

RFC 3339 defines the shape itself: a full date, a T, a time and an offset. Its own example is 1985-04-12T23:20:50.52Z, which the RFC describes as 20 minutes and 50.52 seconds after the 23rd hour of April 12th, 1985 in UTC. The Z suffix means an offset of 00:00. Any other zone is written as +hh:mm or -hh:mm.

A payload that follows every rule:

{
  "id": 7,
  "createdAt": "1985-04-12T23:20:50.52Z",
  "updatedAt": "2019-05-18T15:17:00+00:00",
  "birthday": "2019-01-01"
}

With one shared offset, these strings sort correctly as plain text. You can read them in a log, and the JavaScript, Python and Go examples below parse them with only the standard library.

Unix timestamps: when a number is fine

A Unix timestamp stores a date as a count of time since 1970, which makes it compact and easy to compare. The catch is the unit. JavaScript counts milliseconds, so Date.parse("2019-01-01") returns 1546300800000, while many backends and databases count seconds.

Mixing the two is the classic bug. Treat a seconds value as milliseconds and every date lands in January 1970. Multiply by 1000 twice and dates jump thousands of years ahead. If you do send numbers, put the unit in the field name, such as createdAtMs, and keep the values as integers so they stay exact. Very large numbers have their own trap, covered in why JSON.parse loses precision on large numbers.

Option Readable Carries time zone Sorts as text Main risk
RFC 3339 string Yes Yes Yes, with one offset Missing offset
Date-only YYYY-MM-DD Yes No (not needed) Yes Read as UTC midnight
Unix seconds or ms No No (always UTC) As numbers Unit mix-ups

The JavaScript time zone trap

JavaScript parses two nearly identical strings in two different time zones. The MDN Date.parse page shows that Date.parse("2019-01-01") is treated as UTC "because it's date-only". But Date.parse("2019-01-01T00:00:00"), with no offset, is "set to 2019-01-01 at 00:00:00 in the local timezone of the system".

So a server that writes "2019-01-01T00:00:00" without a Z produces a different instant on every user's machine. A user in New York and a user in Tokyo get two timestamps hours apart, and nobody sees an error.

The fix is the I-JSON rule: never leave the offset off a date-time. The reverse problem hits date-only values. A birthday stored as "2019-01-01" parses as UTC midnight, then displays as the previous day for anyone west of Greenwich if you format it in local time. Keep date-only fields as strings in your app and only turn them into Date objects when you need to do date math.

One RFC 3339 oddity: an offset of -00:00 means the UTC time is known but the local offset isn't, which "differs semantically" from Z or +00:00.

How JavaScript, Python and Go write dates

All three standard libraries write the right JSON date format, as long as you hand them the right object.

JavaScript

JSON.stringify calls Date.prototype.toJSON automatically. Per MDN's toJSON page, it returns the same string as toISOString(), or null when the date is invalid. The toISOString page says the result uses the YYYY-MM-DDTHH:mm:ss.sssZ shape, and "the timezone is always UTC".

Reading is the hard part. MDN calls the serialization irreversible, because "it's not possible to distinguish between a string that represents a Date and a normal string." You revive dates yourself, by key:

const text = '{"createdAt":"1985-04-12T23:20:50.52Z","note":"hello"}';

const data = JSON.parse(text, (key, value) =>
  key === "createdAt" || key === "updatedAt" ? new Date(value) : value
);

Watch for the silent failure: an invalid Date becomes null in the output, not an error. That's the same quiet behaviour JavaScript has with NaN and Infinity in JSON.

Python

Python's json module refuses datetime objects, so you convert them yourself. The datetime documentation gives you isoformat() for writing, which produces strings like '2019-05-18T15:17:00+00:00' for an aware datetime. For reading, fromisoformat() has accepted any valid ISO 8601 string, with a few exceptions, since Python 3.11. Before that, it only read the formats isoformat() itself wrote.

from datetime import datetime, timezone
import json

now = datetime.now(timezone.utc)
payload = json.dumps({"createdAt": now.isoformat()})
back = datetime.fromisoformat(json.loads(payload)["createdAt"])

Always create aware datetimes, with timezone.utc or another zone. A naive datetime writes no offset, which recreates the JavaScript trap above.

Go

Go does it for you. The time package writes a time.Time as a quoted string "in the RFC 3339 format with sub-second precision", and its docs say RFC 3339 "should be preferred for new protocols."

Validate dates in your API contract

Formats drift once several teams touch an API. Write the rule down in your schema: JSON Schema has a "format": "date-time" keyword for RFC 3339 strings, and our guide to validating JSON against a JSON Schema shows how to enforce it. A schema catches a missing Z before it reaches a user in another time zone.

My take: store every instant as a UTC string ending in Z, store calendar dates as YYYY-MM-DD, and keep the user's time zone in its own field if you need to show local time later. A UTC instant tells you when something happened, but not what the clock on the wall said for the person who did it.

The limitation is size and speed. Strings are longer than integers, and parsing them costs more than reading a number. For a high-volume event stream where every byte counts, integer milliseconds with a documented unit are the better trade.

FAQ

Does JSON have a date type?

No. JSON only has strings, numbers, booleans, null, objects and arrays, so a date has to be written as a string or a number that both sides agree on.

What is the best date format for JSON?

An RFC 3339 string such as 1985-04-12T23:20:50.52Z. The I-JSON profile (RFC 7493) recommends ISO 8601 as specified in RFC 3339, with uppercase letters, an explicit time zone and seconds included.

Should I use a Unix timestamp or an ISO string in JSON?

Use an ISO string by default because it is readable and carries its time zone. Use a number only when you control both ends and document whether it counts seconds or milliseconds.

Why does JSON.stringify turn my Date into a string?

JSON.stringify calls Date.prototype.toJSON, which returns the same UTC string as toISOString, or null for an invalid date.

How do I get a Date object back from JSON.parse?

Pass a reviver to JSON.parse and call new Date(value) for the keys you know hold dates, such as createdAt and updatedAt.

Sources