# Modeling money and big integers in OpenAPI: int64 precision, decimal amounts, and why number fails for currency

Two data-loss bugs show up months after an API ships. An order id over 2^53 arrives rounded, so a client fetches the wrong receipt. A ledger total of `19.99` becomes `19.989999999999998` after one round trip, and a reconciliation job flags every transaction. Both come from the same mistake: treating JSON `number` as if it carried arbitrary integer or decimal precision. It does not.

## What JSON numbers actually are

JSON itself has no integer type. Every number is a sequence of digits, but nearly every parser converts it to an IEEE 754 double-precision float, which has 53 bits of mantissa. That gives exact integers only up to `Number.MAX_SAFE_INTEGER`, which is 9,007,199,254,740,991 (about 16 decimal digits).

| Value                                   | `type` / `format`   | Wire form                 | Risk                              |
|-----------------------------------------|---------------------|---------------------------|-----------------------------------|
| Small integer (count, age, qty)         | `integer` / `int32` | `42`                      | Safe                              |
| 64-bit id, snowflake, timestamp-key     | `integer` / `int64` | `8421937123456789012`     | **Loses precision in JS clients** |
| Money, tax, exchange rates              | `number` (any)      | `19.99`                   | **Binary float rounding**         |
| Exact decimal the client must not round | `string`            | `"19.99"`                 | Safe, needs a parser              |
| Arbitrarily large integer               | `string`            | `"987654321098765432109"` | Safe                              |

`format: int64` is an annotation, not a guarantee. It tells a generator in a 64-bit language to use `long` or `Int64`, but JavaScript and TypeScript still decode the JSON number into a `number` and silently round it. If any consumer is a browser, a Node process, or a language without a native 64-bit safe integer, an `int64` on the wire is unsafe regardless of what your server language does.

## Large identifiers belong in strings

Snowflake ids, database sequences over 16 digits, blockchain values, and file sizes that can exceed 9 petabytes should be modeled as strings with a pattern and a description that says why:

``` yaml
OrderId:
  type: string
  pattern: '^[0-9]{1,20}$'
  description: >-
    64-bit order identifier serialized as a string to preserve precision in
    JavaScript clients. Treat as an opaque numeric id; do not parse as a float.
  example: "8421937123456789012"
```

A TypeScript generator emits `orderId: string`, so the digits survive. If you also offer the raw numeric form for systems that need it, expose it under a separate field and document which one clients must use for identity. Never ask a browser client to compare two int64 numbers parsed as floats; equality breaks at the same boundary.

If you control every consumer and they all use a parser with bigint support (for example a server-to-server API whose clients use `JSON.parse(text, reviver)` or a codegen runtime with a bigint option), `type: integer, format: int64` is acceptable. State that requirement in the description; it is not the default for a public API.

## Money is not type: number

Binary floating point cannot represent one tenth exactly, so `0.1 + 0.2` is not `0.3`. Money represented as `type: number` invites rounding into every client, every mock, and every AI-generated test. There are three defensible patterns; pick one and use it everywhere.

**Minor units as an integer.** The amount in the smallest currency unit, with the currency on a sibling field:

``` yaml
Money:
  type: object
  required: [amount_minor, currency]
  additionalProperties: false
  properties:
    amount_minor:
      type: integer
      format: int64
      description: Amount in minor units (cents for USD and EUR, whole yen for JPY).
      example: 1999
    currency:
      type: string
      enum: [USD, EUR, JPY]
      description: ISO 4217 code. Check the currency's decimal exponent; JPY has zero.
```

This is exact, easy to add in integers, and unambiguous as long as you document zero-decimal currencies. Do not assume every currency has two decimals; JPY and KRW have zero, and a handful of currencies use three.

**Exact decimal as a string.** Required when you must carry sub-minor precision, variable scale, or crypto amounts:

``` yaml
DecimalAmount:
  type: string
  pattern: '^-?[0-9]+(\\.[0-9]+)?$'
  description: Exact decimal amount as a string; parse with a decimal library, never a float.
  example: "19.9900"
```

**A fixed-precision object** with explicit scale, common in ledgers and payment networks:

``` yaml
amount: { type: integer, example: 19990 }
scale: { type: integer, enum: [0, 2, 3], example: 3 }
```

Whichever you choose, the rules that save you are: never mix patterns in one API, always send the currency or scale next to the amount, and never let a client infer decimals from the currency without a documented table.

## Percentages, rates, and totals

The same trap applies to non-money decimals. Tax rates, exchange rates, discounts, and utilization ratios need a documented scale. A 7.5 percent tax sent as `0.075` is exact-looking but still a float; send a fixed basis-points integer (`750` basis points) or a decimal string when exactness matters. For totals, document the rounding rule (half-up, half-even, round per line item or round the sum), because two correct clients can otherwise produce different cents.

## What codegen, validators, and mocks do

-   `integer/int32` generates a plain number safely. `integer/int64` generates `number` in TypeScript (unsafe above 2^53), `long` in Java, `int` in Go; only runtimes with a bigint decoder preserve it.
-   `string` ids generate `string`, which is always safe but shifts parsing to callers; the pattern keeps them honest.
-   `number` money generates a float everywhere and gives validators nothing to check about scale; a decimal string with a pattern lets validation reject malformed amounts.
-   A spec-driven mock should generate realistic minor-unit integers and quoted decimal strings from your examples, not random floats like `12.340000000000001`. When an AI agent or mock produces a float money value against a decimal-string schema, that is a signal the schema was ignored, which is exactly what a contract check should catch.

## Checklist

1.  Treat every JSON number as a double; exact integers are safe only up to 2^53.
2.  Serialize ids and integers above 16 digits as strings with a numeric pattern, unless every client provably decodes bigint.
3.  Do not model money as `type: number`; choose minor-unit integers, decimal strings, or an amount-plus-scale object and use it consistently.
4.  Always send the ISO 4217 currency next to an amount and document zero- and three-decimal currencies.
5.  Document the scale and rounding rule for rates, tax, and totals.
6.  Generate the client and confirm large ids stay strings and money never becomes a float.
7.  Have mocks and contract tests assert amounts round-trip exactly, including a value that exposes float error.

Get these right and the digits that matter, identity and money, survive every client and every retry unchanged.

You can define reusable `Money` and `OrderId` components, generate clients that preserve precision, and assert exact round-trips against a mock in one local-first workspace, [right in your browser](https://www.powerduck.com/app/?ref=powerduck.com). For a DRY component library these money types belong in, see [reusable JSON Schema components in OpenAPI](https://www.powerduck.com/blog/reusable-json-schema-components-openapi-ref/).

