All posts

Why money, IDs, and dates break across APIs: JSON precision and partial-update contracts

· 8 min read

Correct database values can change in a browser or update request. Four tips follow an order app through monetary rounding, large IDs, instants versus business dates, and omission versus null.

Why amounts, IDs, and dates change across an API

In a fictional order app, the database ID is correct but the browser links to another order. Totals have rounding discrepancies, midnight orders fall into the previous day, and changing a name disables notifications. These need not be JSON syntax errors. Treat storage type, transport format, client type, and business meaning as one contract.

Boundaries to check for both meaning and representation

Database types and constraints → API values, units, and formats → Client parsing and variables → Display and partial updates → Server validation before persistence

Separate floating-point error from business rounding

Binary floating point cannot represent decimals such as 0.1 exactly; JavaScript evaluates 0.1 + 0.2 as 0.30000000000000004. Use integer minor units or decimal types such as NUMERIC/DECIMAL for amounts. Integers still have range limits, including at the API boundary. Rounding only for display does not repair intermediate comparisons or totals.

Define currency and units together. USD 10.99 is 1,099 cents and JPY 500 is 500 yen. These are standard Stripe minor-unit examples; check currency- and operation-specific exceptions. A custom API can transport integer minor units as decimal strings, validating sign, digit count, and bounds rather than accepting arbitrary strings.

Division remainders are a separate business decision. Splitting 1,000 cents into three shares of 333 leaves a total of 999. An illustrative rule assigns the extra cent to the first person, giving 334, 333, and 333, totaling 1,000. Decide whether ordering and fairness matter. A decimal type does not choose the allocation rule.

Send large integer IDs as strings before precision is lost

RFC 8259 allows implementation limits on number range and precision and identifies ±(2^53−1) as an interoperable integer range. Reading 9007199254740993 into a JavaScript Number rounds it to 9007199254740992. Some larger integers remain representable, but consecutive integers are no longer reliably distinguishable.

JavaScript reproduction; the final line preserves the original digits
JSON.parse('{"id":9007199254740993}').id
// 9007199254740992

JSON.parse('{"id":"9007199254740993"}').id
// "9007199254740993"

Converting to String after JSON.parse cannot restore lost digits. Keep identifiers as strings through URLs, comparisons, and logs, without parsing or treating their contents as numbers. Such identifiers are called opaque because callers do not interpret their contents. When migrating an API that accepted numbers, coordinate client and format changes; do not guess how to repair a rounded identifier.

Represent instants separately from calendar dates

October 27, 2026 at 00:30 JST is October 26 at 15:30 UTC. One instant can belong to different calendar days by region. For Japanese sales on the 27th, convert local midnight on the 27th and 28th to a half-open UTC interval. Converting a date-only birthday into UTC midnight can incorrectly display the preceding date elsewhere.

PostgreSQL 18 predicate excerpt for October 27, 2026 in Japan; bind $1/$2 to the UTC start/end
created_at >= $1::timestamptz
AND created_at < $2::timestamptz
-- $1 = 2026-10-26T15:00:00Z
-- $2 = 2026-10-27T15:00:00Z

PostgreSQL timestamptz represents an instant without retaining the original time-zone name. A recurring 9 a.m. appointment needs regional clock rules as well. In regions with daylight saving, construct the next boundary from the local date rather than adding 24 UTC hours. Require offsets for instants and define date-only values as YYYY-MM-DD.

Keep omission, null, and false distinct in partial updates

InputMeaning in this APIValidation
Field absentRetain the current valuePreserve presence while decoding
false, zero, empty stringExplicit valueValidate whether allowed
nullDefined by the selected formatMember removal in Merge Patch
Unknown or forbidden fieldRejectCheck authorization and writable fields

JSON Merge Patch uses null for member removal and replaces arrays as a whole. JSON Patch explicitly expresses operations such as remove and replace. The PATCH method alone does not define these semantics. Both require writable-field restrictions and validation of the resulting business state, with separate version conditions for concurrent updates.

Put the contract into one order API example

Illustrative custom response: amount is integer USD cents as a string; billing_date is a date
{
  "id": "9007199254740993",
  "amount_minor": "1099",
  "currency": "USD",
  "created_at": "2026-10-26T15:30:00Z",
  "billing_date": "2026-10-27",
  "notifications_enabled": true
}

This JSON separates identifier and amount digits from instant and billing-date semantics. Correct types alone do not authorize an order. Check object authorization, server-calculated amounts, currency agreement, and update preconditions. Contract changes also require compatibility checks with stored data and older clients.

Verify boundary values through the actual client

  1. Round-trip integer boundary values and 64-bit IDs through storage, JSON, the browser, and update requests.
  2. Test allocation remainders and rounding boundaries against line-total and order-total rules.
  3. Test JST date boundaries and daylight-saving transitions separately, including different server time zones.
  4. Exercise omission, null, false, zero, empty strings, and arrays independently; verify unrelated fields remain unchanged.

Primary sources

The official sources below were checked on October 27, 2026. Scenarios and numerical examples are illustrative assumptions. Configuration examples are explanatory; they have not been run against live services or benchmarked.

Tips this post digs into