Data Format and Precision Contracts
Two services can agree on every field name and still disagree about the value. A price of 19.99 becomes 19.989999999999998 after one arithmetic step, a 64-bit identifier loses its last digits passing through a browser, and a timestamp without an offset means one thing in Dublin and another in Denver. This guide extends Schema Design & Validation Patterns and covers the representations that have to be pinned in the contract because the format itself does not pin them.
The reason this class of bug is expensive is that nothing fails. Validation passes, types check, tests are green, and the discrepancy surfaces weeks later in a reconciliation report.
When to Use This Approach
Pin representations explicitly when:
- Money, tax, or any quantity where a rounding difference is a real defect crosses the boundary.
- JavaScript is anywhere in the estate — the browser, a Node service, an edge function — and identifiers may exceed 53 bits.
- Consumers span more than one language, so what “a number” means differs between the two ends of the wire.
- Timestamps are compared, sorted or used for windows and expiry.
- Binary content — documents, images, signatures — travels inside JSON payloads.
Prerequisites
Examples use OpenAPI 3.1.1, Ajv 8.17 with ajv-formats 3.0, and TypeScript 5. The decisions below are language-neutral; the enforcement examples are not.
npm install ajv@8.17 ajv-formats@3.0
Step 1: Pin Money as Integer Minor Units
The default choice — a JSON number with two decimal places — is the one that fails. Most runtimes parse JSON numbers as IEEE 754 doubles, where many decimal fractions have no exact representation.
0.1 + 0.2 // 0.30000000000000004
19.99 * 3 // 59.97000000000001
(0.615).toFixed(2) // "0.61" — not "0.62"
Two representations survive contact with reality. Integer minor units are the simpler:
components:
schemas:
Money:
type: object
required: [amount, currency]
additionalProperties: false
properties:
amount:
type: integer
description: |
The value in the currency's minor unit — cents for EUR, pence for
GBP, and whole units for JPY, which has no minor unit. Never a
decimal. 1999 means EUR 19.99.
minimum: -1000000000000
maximum: 1000000000000
currency:
type: string
pattern: '^[A-Z]{3}$'
description: ISO 4217 code. Determines how many minor units per unit.
The alternative — a decimal string — suits currencies and quantities with more than two decimal places, and is unpacked in representing money and decimals in API schemas.
The subtlety the schema must carry: the number of minor units per unit depends on the currency. JPY has none, most currencies have two, and a few have three. Pairing amount with currency in one object is what makes the value interpretable at all.
Step 2: Transport Large Identifiers as Strings
JavaScript’s Number holds integers exactly up to 2⁵³ − 1. Beyond that, JSON.parse returns a nearby representable value and raises nothing.
JSON.parse('{"id": 7205759403792794123}').id // 7205759403792794000 ← changed
OrderRef:
type: object
required: [id]
properties:
id:
type: string # NOT integer
pattern: '^[0-9]{1,20}$'
description: |
A 64-bit identifier transported as a decimal string. Numeric in the
database, string on the wire, because JavaScript cannot hold it.
example: "7205759403792794123"
The rule generalises: any integer that can exceed 2⁵³ is a string on the wire. Snowflake ids, database bigints, financial reference numbers. The full treatment is in handling large integers and 64-bit IDs in JSON.
Step 3: Require an Explicit Offset on Every Timestamp
Order:
type: object
properties:
created_at:
type: string
format: date-time # RFC 3339 — offset is mandatory in the grammar
description: When the order was created. Always UTC, always with a Z.
example: "2026-07-29T10:00:00Z"
delivery_date:
type: string
format: date # a calendar date, deliberately not an instant
description: |
The customer's local delivery date. NOT a timestamp: converting it to
an instant in the wrong zone shifts it by a day.
example: "2026-08-03"
The distinction between date-time and date is the one teams collapse and regret. A delivery date is a calendar fact with no time zone; storing it as an instant at midnight UTC moves it a day for anyone west of Greenwich. The full set of traps is in specifying date, time and timezone formats.
Step 4: Keep Binary Out of the JSON Body
Base64 inside JSON inflates payloads by a third, parses entirely into memory, and defeats streaming. It is acceptable for small, bounded content and wrong for anything else.
# Small and bounded — inline is fine.
Signature:
type: object
required: [algorithm, value]
properties:
algorithm: { type: string, enum: [ed25519] }
value:
type: string
contentEncoding: base64
maxLength: 128 # a hard ceiling, in encoded characters
description: Base64-encoded 64-byte signature.
# Large or unbounded — a separate endpoint with a real media type.
paths:
/documents/{id}/content:
put:
requestBody:
required: true
content:
application/pdf:
schema: { type: string, contentMediaType: application/pdf, format: binary }
responses:
'204': { description: Stored. }
The rule of thumb: if the content can exceed roughly a hundred kilobytes, it gets its own endpoint.
Step 5: Enforce the Choice in the Runtime Validator
The schema states the contract; the runtime validator refuses violations. Both ends benefit from the same constraints:
import { z } from "zod";
export const money = z.object({
// int() is what makes "19.99 slipped in as a number" a validation failure
// rather than a silently truncated value.
amount: z.number().int().min(-1_000_000_000_000).max(1_000_000_000_000),
currency: z.string().regex(/^[A-Z]{3}$/),
});
export const orderId = z.string().regex(/^[0-9]{1,20}$/);
export const instant = z.string().datetime({ offset: true }); // rejects a bare local time
export const calendarDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
Representation Reference
| Value | Wire representation | Schema keywords | Why not the obvious choice |
|---|---|---|---|
| Money | integer minor units + currency | type: integer, pattern on currency |
JSON numbers are binary floats |
| High-precision decimal | decimal string | type: string, pattern |
doubles cannot hold it exactly |
| 64-bit identifier | decimal string | type: string, pattern |
JavaScript loses precision above 2⁵³ |
| Instant | RFC 3339 with offset | format: date-time |
a bare local time is ambiguous |
| Calendar date | YYYY-MM-DD |
format: date |
an instant shifts across time zones |
| Duration | integer seconds, or ISO 8601 | type: integer or format: duration |
“2h” needs a parser nobody agrees on |
| Percentage | integer basis points | type: integer, range |
0.07 is not representable exactly |
| Small binary | base64 string | contentEncoding, maxLength |
unbounded strings are a memory risk |
| Large binary | its own endpoint | contentMediaType, format: binary |
base64 in JSON defeats streaming |
| Boolean-ish | real boolean | type: boolean |
"true", "yes" and 1 all differ |
Verification
Round-trip tests at the boundaries are what prove a representation survives:
test("a 64-bit identifier survives the round trip", () => {
const id = "7205759403792794123";
const encoded = JSON.stringify({ id });
expect(JSON.parse(encoded).id).toBe(id); // string in, string out
expect(Number(id).toString()).not.toBe(id); // and this is why
});
test("money arithmetic is exact in minor units", () => {
const line = { amount: 1999, currency: "EUR" };
const total = line.amount * 3;
expect(total).toBe(5997); // not 59.97000000000001
});
test("a timestamp without an offset is rejected", () => {
expect(() => instant.parse("2026-07-29T10:00:00")).toThrow();
expect(instant.parse("2026-07-29T10:00:00Z")).toBe("2026-07-29T10:00:00Z");
});
test("a decimal slipped into an integer amount fails validation", () => {
expect(() => money.parse({ amount: 19.99, currency: "EUR" })).toThrow();
});
Troubleshooting
Totals differ by a cent between two services
One of them is doing arithmetic in floating point. Search for division and percentage calculations first — a VAT computation is the usual culprit — and move the arithmetic into integer minor units with an explicit, documented rounding rule at the single point where rounding happens.
An identifier “changes” between the API and the browser
Precision loss above 2⁵³. Confirm by comparing the raw response body with what the parsed object holds; the response is correct and the parse is not. Change the field to a string on the wire; there is no client-side fix that does not involve a custom parser.
Dates are off by one for some users
A calendar date is being stored or transported as an instant. Look for new Date("2026-08-03") and equivalents, which produce midnight UTC. Model calendar dates as format: date and never convert them to an instant.
A schema says integer but decimals get through
Either the validator is coercing, or the field is type: number rather than integer. Check both, and add a test asserting that a decimal value is rejected — coercion settings are easy to change accidentally.
Base64 uploads exhaust memory under load
The body is parsed fully before your handler runs, so a size limit on the JSON body parser is the first control, and moving large content to a dedicated endpoint is the real fix. Remember that base64 is about 1.37 times the byte size of the original.
Auditing an Existing API for Representation Bugs
Most teams reach this page with an API already in production, and the useful first move is an audit rather than a redesign. Four passes over the specification find nearly everything.
Every numeric field. For each one, ask what it measures and whether the value can exceed 2⁵³ or require a fractional part. Identifiers, byte counts and cumulative counters are the ones that cross the integer limit; prices, rates and measurements are the ones that need fractions. A numeric field that is neither an identifier nor a measurement is often an enumeration wearing the wrong type.
Every temporal field. Classify each as an instant, a calendar date, a wall-clock time or a duration. Any field typed as date-time that a human would describe as “a date” is a bug waiting for a customer in a distant timezone.
Every string that carries a number. These are usually the result of a previous fix and are worth confirming rather than reverting — a decimal string is correct for money at high precision, and a numeric string is correct for a large identifier. What matters is that the pattern is anchored so the string cannot carry scientific notation or whitespace.
Every field that is a resource identifier. Check the wire type against what the database stores. A BIGINT column surfaced as a JSON number is the single most common instance of this whole class of bug, and it is invisible until a table grows past two billion rows.
The audit produces a list, and the list is almost never urgent — these are latent bugs that surface at a threshold. Fixing them ahead of that threshold is dramatically cheaper than fixing them during the incident, because the migration can use a full deprecation window instead of a hotfix.
Writing the Decisions Down
A representation decision that lives only in a schema is enforced but not explained, and the next engineer changing the schema has no way to know why amount is an integer. Two short pieces of documentation prevent the regression.
A description on the field stating the unit and the reason — “the value in the currency’s minor unit; never a decimal, because JSON numbers are binary floats” — travels with the contract and appears in generated documentation. It is read by exactly the person who is about to change it.
A conventions section in the document’s info.description collects the decisions once: how money is represented, how timestamps are formatted, which identifiers are strings and why. It gives a new integrator the whole picture in a paragraph, and gives a reviewer something concrete to check a new schema against.
Neither is enforcement. Enforcement is the schema keywords and the runtime validator. Both exist so that the enforcement is understood rather than worked around by someone who assumes it was arbitrary.
Getting Agreement Across Teams
These decisions are cheap to make and expensive to make twice. In an estate with several teams, the representations belong in a short, shared document that every new API is written against — one page, one decision per ambiguous type, with the reasoning attached.
The reasoning matters more than the rule. An engineer who reads “money is an integer in minor units” without the explanation will reasonably conclude it is an arbitrary house style and will make a different choice under pressure. The same engineer, having read that JSON numbers are binary floats and that two services can compute different totals from identical inputs, will apply the rule to a case the document never anticipated.
Two mechanisms keep a shared document from becoming shelfware. A lint rule per decision turns the convention into a build failure rather than a review comment — a rule that rejects type: number on a field whose name contains amount or price catches most money mistakes for a few lines of configuration. And a shared schema library for the recurring value objects — Money, Instant, CalendarDate, Identifier — means most new APIs inherit the decisions rather than reimplementing them.
Where the estate is polyglot, publish the library as a set of JSON Schema files rather than as a package in one language. Every generator can read a schema file; only one runtime can read a TypeScript module.
Reviewing a New Schema Against These Decisions
A short checklist makes the review mechanical. Does every monetary value carry its currency, and is the amount an integer? Is every timestamp an instant with an offset, and every calendar date a date? Is every identifier a string with an anchored pattern? Is every array bounded and every string length-capped? Does any field claim a format the validator is not configured to assert?
Five questions, and they catch the overwhelming majority of representation defects before the schema ships — which is the only point at which fixing them is free.
Why These Bugs Survive So Long
Every failure on this page shares one property: nothing throws. A validator that accepts a decimal where cents were meant, a parser that rounds an identifier, a conversion that moves a date by a day — each produces a plausible value that flows onward and is stored. The defect surfaces days or weeks later as a reconciliation mismatch or a support ticket, by which time the corrupted data is spread across several systems and the change that caused it is long merged. That delay is precisely why the representations belong in the contract, enforced by keywords, rather than in a convention people are trusted to remember.
Frequently Asked Questions
Why not just use a JSON number for money?
Because JSON numbers are parsed as IEEE 754 doubles by most runtimes, and 0.1 + 0.2 is not 0.3 in that representation. Two services can compute different totals from identical inputs. Use integer minor units, or a decimal string, and say which in the schema.
What breaks with 64-bit identifiers in JavaScript?
Numbers above 2⁵³ lose precision silently. A snowflake-style identifier round-trips through JSON.parse as a different number, and no error is raised anywhere. Transport large identifiers as strings.
Should timestamps carry an offset or always be UTC?
Always include an explicit offset, and prefer Z. A timestamp with no offset is ambiguous by an amount that changes twice a year, and every consumer resolves the ambiguity differently.
How should binary data travel in a JSON API?
Base64 in a string field for small payloads, with contentEncoding declared, and a separate upload endpoint with a real media type for anything large. Base64 inflates by a third and is parsed entirely into memory.
Do these choices belong in the schema or in documentation?
In the schema. A note saying amounts are in cents is invisible to a generator and to a validator; type: integer with a minimum and a description is enforceable and travels with the contract.
How do I change a representation that is already published?
As a breaking change with a deprecation window: add the new field alongside the old, populate both, migrate consumers, then remove the old one. Silently changing a representation in place is the fastest way to corrupt downstream data.