Dates, numbers and precision
Represent time unambiguously, keep money out of floating point, and know exactly when a JSON number stops being the number you sent.
Time as a string
{
"placed_at": "2026-09-18T11:04:22Z",
"placed_at_offset": "2026-09-18T12:04:22+01:00",
"delivery_date": "2026-09-21",
"trial_ends_epoch_ms": 1790000000000
}- JSON has no date type. A date is a string, and its format is a convention you must document.
- Use RFC 3339:
YYYY-MM-DDTHH:MM:SSZfor an instant,YYYY-MM-DDfor a calendar date with no time. Zand+01:00are both valid and mean different things: the same instant, expressed in different zones. Always include one.- Store the instant in UTC; convert to a local zone only for display.
- An epoch number is compact but unreadable, and the seconds-versus-milliseconds ambiguity has caused more bugs than it has saved bytes.
// the two mistakes that show up in production
JSON.parse('{"d":"2026-09-18 11:04:22"}').d // no zone: parsed as local time
new Date("2026-09-18").toISOString() // "2026-09-18T00:00:00.000Z" in UTC,
// but the previous day in a negative offset
// a date-only value is not an instant - keep it a string
const deliveryDate = "2026-09-21";
Number.isFinite(Date.parse(deliveryDate)); // true, but the instant is zone-dependent💡
A calendar date and an instant are different types. A birthday, a delivery date and a billing period start are dates; a log timestamp and a token expiry are instants. Storing a date as midnight UTC is the mistake that makes a delivery appear one day early for half the world.
Numbers that survive the trip
Number.MAX_SAFE_INTEGER; // 9007199254740991
Number.MAX_SAFE_INTEGER + 1; // 9007199254740992
Number.MAX_SAFE_INTEGER + 2; // 9007199254740992 - the same number
// a 64-bit identifier, as it round-trips through JSON
const id = 9007199254740993n;
JSON.parse('{"id":9007199254740993}').id; // 9007199254740992 - silently wrong
// floats are binary, so decimals are approximate
0.1 + 0.2; // 0.30000000000000004
JSON.parse('{"total":10.1}').total * 3; // 30.299999999999997| Value | Safe as a JSON number? | Encode as |
|---|---|---|
| Small integers | Yes | Number |
| Counts up to 2^53 | Yes | Number |
| 64-bit database ids | No | String |
| Money | No | Integer minor units, or a decimal string |
| Ratios and measurements | Usually | Number, with a documented tolerance |
NaN and Infinity | Not expressible | null, or a string sentinel |
| Big decimals | No | String, parsed with a decimal library |
// money as integer minor units
{ "total": 3998, "currency": "GBP", "exponent": 2 }
// 3998 with exponent 2 is 39.98, exactly
// or as a decimal string, when the scale varies
{ "total": "39.98", "currency": "GBP" }
// the wrong answer, which is also the common one
{ "total": 39.98, "currency": "GBP" }// reading big integers without losing them
import JSONbig from "json-bigint";
const parsed = JSONbig({ storeAsString: true }).parse(body);
parsed.id; // "9007199254740993", exact
// the pragmatic rule: any identifier that comes from a 64-bit column
// should be a string in the payload, and the reader will never noticeConventions worth writing down
- Every instant is RFC 3339 with an explicit zone, in UTC.
- Every date without a time is
YYYY-MM-DDand is never converted to an instant. - Every monetary value is an integer in minor units plus a currency code, or a decimal string - never a float.
- Every identifier that may exceed 2^53 is a string.
- Durations are integers with the unit in the name:
timeout_seconds, nottimeout. - Percentages state whether they are fractions or basis points:
rate_bpsfor 1/10000.
{
"expires_in_seconds": 3600,
"rate_bps": 250,
"total_minor": 3998,
"currency": "GBP",
"created_at": "2026-09-18T11:04:22Z",
"renews_on": "2026-10-18",
"account_id": "1234567890123456789"
}# check what your reader actually produced before blaming the API
curl -s https://api.example.com/orders/1 | jq '.account_id, (.account_id | type)'
# and compare the raw text with the parsed value
curl -s https://api.example.com/orders/1 | grep -o '"account_id":[0-9]*'FAQ
Should timestamps be epoch numbers or strings?
Strings, unless you control every reader and you are optimising payload size. A string is self-describing, readable in a log, unambiguous about its zone, and cannot be misread as seconds or milliseconds. Epoch numbers are compact and are consistently misread.
Why did my ID change when it went through my service?
It was a number larger than 2^53 and JavaScript's
JSON.parse rounded it to the nearest representable double. Serialise such identifiers as strings end to end, including in the database client, or the corruption happens before anything reaches your code.Related
Designing a JSON payload Security when parsing untrusted JSON
Last refreshed 2026-09-18.