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:SSZ for an instant, YYYY-MM-DD for a calendar date with no time.
  • Z and +01:00 are 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
ValueSafe as a JSON number?Encode as
Small integersYesNumber
Counts up to 2^53YesNumber
64-bit database idsNoString
MoneyNoInteger minor units, or a decimal string
Ratios and measurementsUsuallyNumber, with a documented tolerance
NaN and InfinityNot expressiblenull, or a string sentinel
Big decimalsNoString, 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 notice

Conventions worth writing down

  1. Every instant is RFC 3339 with an explicit zone, in UTC.
  2. Every date without a time is YYYY-MM-DD and is never converted to an instant.
  3. Every monetary value is an integer in minor units plus a currency code, or a decimal string - never a float.
  4. Every identifier that may exceed 2^53 is a string.
  5. Durations are integers with the unit in the name: timeout_seconds, not timeout.
  6. Percentages state whether they are fractions or basis points: rate_bps for 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.

Designing a JSON payload Security when parsing untrusted JSON

Last refreshed 2026-09-18.