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 styleISO 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_atworks on strings without conversion. - Timezone is explicit. Either a
Zor 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’sdatetime.fromisoformat(3.11+ for theZsuffix), JavaScript’sDateconstructor — 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
Dateconstructor 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.fromisoformatreturns a naivedatetime— an object withtzinfo=None— which raises later when you try to compare it to an aware datetime. - Go’s
time.ParsewithRFC3339rejects 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:
-
Datetimes are RFC 3339 strings, always with
Zor an explicit offset. -
Date-only fields are date-only strings (
"2026-04-27"), never midnight UTC. - Document the precision once, at the API root. If clients are JS, promise no more than millisecond.
- Do not mix unix and ISO in one API. The cognitive cost is paid by every reader, forever.
- 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.