← Essays
§ Essay

JSON has no dates

JSON has six types — string, number, boolean, null, object, array — and a date is not one of them. RFC 8259 is two pages of grammar and zero of them describe a moment in time. Every date you see in a payload was encoded into one of those six by convention, and the convention is a contract you usually inherited rather than chose.

Look at two real APIs returning conceptually the same value — when a thing was created — in two different shapes:

// Stripe charge: unix seconds, integer
{ "id": "ch_abc", "amount": 5000, "created": 1714198800 }

// GitHub issue: ISO 8601 string
{ "id": 1, "title": "bug", "created_at": "2024-04-27T10:20:00Z" }

Both work. Neither is wrong. But the cost of the choice does not stop at the byte count. Every consumer downstream — the TypeScript client, the Go service, the Python ETL job, the analyst writing a SQL report — has to know which encoding is in play, and the type system cannot help if the API documents it as just “a number” or “a string.”

JSON has six types. Date is not one of them. How you encode dates is a contract; write it down before you ship, because the consumers cannot guess.

— the house style

ISO 8601, or really RFC 3339

Most modern APIs default to a string in ISO 8601 format. The relevant standard is actually RFC 3339, a strict subset of ISO 8601 that removes the ambiguous corners. The shape you usually want:

"2026-04-27T10:30:00Z"           // UTC, the Z form
"2026-04-27T19:30:00+09:00"     // with offset (KST)
"2026-04-27T10:30:00.123Z"      // with millisecond fractional
"2026-04-27"                    // date only, no time

Why this wins as a default:

  • Lexicographic sort equals chronological sort. The format was designed for this. ORDER BY created_at works on strings without conversion.
  • Timezone is explicit. Either a Z or an offset is on every value. There is no “naive” case to misinterpret.
  • Human-readable in logs. When something breaks at 02:13 UTC on a Tuesday, you can read the timestamp without a parser.
  • Stdlib parsers exist. Go’s time.RFC3339, Python’s datetime.fromisoformat (3.11+ for the Z suffix), JavaScript’s Date constructor — no extra dependency.

The cost is bytes (about 20 vs 10 for unix) and a parsing step. Both are negligible until you are streaming millions of events.

When unix timestamps still earn their keep

Stripe is the famous holdout, and they have a reason. Their API is old, their payloads are everywhere, and the integer encoding is the smallest possible representation that round-trips losslessly. For high-volume log streams or telemetry pipelines where every byte costs, unix wins.

// unix seconds — Stripe style
"created": 1714198800

// unix milliseconds — JS Date.now() style
"created": 1714198800000

// unix microseconds — some Postgres exports
"created": 1714198800000000

The trap is right there on the page. Three numbers, three different units, no way to tell from the value alone which one a 13-digit number means — is that milliseconds in 2024 or microseconds in 1970? APIs that mix units in different endpoints are the worst kind of consistent-looking bug.

If you ship unix timestamps, document the unit at the API root, not on each field. If you inherit unix timestamps from someone else’s API, write the conversion at the boundary and never let a raw number travel further into your code than the parser.

The timezone trap

The single most expensive mistake in date encoding is the naive datetime — a string with no Z, no offset, and no documented assumption.

"created_at": "2026-04-27T10:30:00"     // what timezone?

The string is unambiguous as a sequence of characters. The moment it represents is not. Different parsers, defensibly citing different specs, will read it differently:

  • JavaScript’s Date constructor historically interpreted bare ISO strings without timezone as local. Modern engines (post-ES2015) read date-time strings without zone as UTC, but date-only strings still as UTC, and old code in the wild does either. Test before you trust.
  • Python’s datetime.fromisoformat returns a naive datetime — an object with tzinfo=None — which raises later when you try to compare it to an aware datetime.
  • Go’s time.Parse with RFC3339 rejects the string outright; the format requires a zone designator.

The fix is one rule, applied without exception: every datetime field on your API ends in either Z or ±HH:MM. Pick one form for the API and stick to it. Z and +00:00 mean the same instant; mixing them in one response is a code-review smell.

A date is not a datetime

A user’s birthday is not a moment in time. A holiday calendar is not a moment in time. A “day of stock close” is not a moment in time. The frequent bug is to encode them as datetimes anyway:

{ "name": "Sumi", "birthday": "1990-05-15T00:00:00Z" }

What looks like 1990-05-15 in UTC becomes 1990-05-14 in any timezone west of London the moment a client formats it for display. The user’s birthday silently shifts by a day for half the world. The fix is the date-only form:

{ "name": "Sumi", "birthday": "1990-05-15" }

A date-only string carries no timezone because the concept it encodes has no timezone. Most languages have a separate type for this — java.time.LocalDate, Python’s datetime.date, Go’s civil.Date from the cloud.google.com/go/civil package. JavaScript famously does not, and using a Date object for it is the source of half the timezone bugs in browser apps.

Precision is part of the contract

Different systems hold different precision for an instant in time:

JavaScript Date              millisecond
PostgreSQL timestamp         microsecond (default)
Go time.Time                 nanosecond
Linux clock_gettime          nanosecond
protobuf Timestamp           nanosecond (seconds + int32 nanos)

If your backend stores microseconds and your wire format is JSON for a JavaScript client, the client’s new Date(s) silently truncates everything below the millisecond. Most of the time nobody notices. The time it does matter is when two events are written within the same millisecond; in your database, one strictly preceded the other; in the browser they look simultaneous, and your “happens before” logic is wrong.

The way out is to send the high-precision timestamp as a string, deliberately, when precision matters more than convenience:

// to JS: lossy but ergonomic
{ "created_at": "2026-04-27T10:30:00.123Z" }

// to a service that needs nanos: a string the caller parses explicitly
{ "created_at": "2026-04-27T10:30:00.123456789Z" }

Sub-millisecond precision should never travel through a JavaScript Date. If the client is JS, drop the precision at the API layer with intent.

How each language hands them back

The other half of the contract is what each runtime does on the way out and on the way in. The shorthand:

JavaScript. JSON.stringify(new Date()) calls Date.prototype.toJSON and returns an ISO 8601 string. But JSON.parse does not rehydrate; you get the string back, not a Date. You need a reviver, a Zod transform, or a hand-rolled parse step. Forget this and you will compare a string to a Date somewhere downstream and silently get false.

const raw = await fetch(url).then(r => r.json());
typeof raw.created_at // "string", not "object"
raw.created_at.getTime()     // TypeError: not a function

Python. json.dumps raises TypeError on a datetime by default; you have to provide default=str or a custom encoder. On the way in, datetime.fromisoformat handles RFC 3339 strings, and Pydantic auto-coerces ISO strings to datetime on model_validate. The three-way essay has the longer story on when to reach for which.

Go. time.Time has built-in MarshalJSON and UnmarshalJSON that emit and accept RFC 3339 with nanoseconds by default. You almost never write custom parsing. The catch is the same naive-datetime trap: if the incoming string lacks a zone, UnmarshalJSON errors out. Most teams treat that as a feature.

Rust. serde does not know about dates out of the box; you reach for chrono or time and pull in a serde-feature flag. Both default to RFC 3339. The explicitness is on-brand.

The default that works

For an API you are designing today, with no inherited shape to honor:

  1. Datetimes are RFC 3339 strings, always with Z or an explicit offset.
  2. Date-only fields are date-only strings ("2026-04-27"), never midnight UTC.
  3. Document the precision once, at the API root. If clients are JS, promise no more than millisecond.
  4. Do not mix unix and ISO in one API. The cognitive cost is paid by every reader, forever.
  5. If you must accept legacy unix, accept it at one boundary function and convert immediately. Inside your code, dates are always your language’s native type.

The short version: JSON gave you no date type; you are picking one. Pick it once, write it down, and let the rest of the program forget the encoding ever existed.