Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Data Formats (JSON, YAML, TOML, XML, CSV)  Module 2 – JSON in depth

Numbers: precision, big integers and decimals

Why JSON numbers change value when parsed, how to keep 64-bit ids and money exact in JavaScript and Python, and how JSON.rawJSON and reviver source text help.

  • Intermediate
  • 15 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7, Ruby 4.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Predict when a JSON number changes value after parsing in JavaScript and Python
  • Parse and emit 64-bit identifiers and money amounts without losing digits
  • Use JSON.rawJSON and the reviver's source text where supported, with a tested fallback
  • Choose a representation for amounts from integer minor units, decimal strings and decimal types

Before you start

What you’ll learn · Before you start

What you will learn

  • Predict when a JSON number changes value after parsing in JavaScript and Python
  • Parse and emit 64-bit identifiers and money amounts without losing digits
  • Use JSON.rawJSON and the reviver's source text where supported, with a tested fallback
  • Choose a representation for amounts from integer minor units, decimal strings and decimal types

Before you start

On this page

JSON.parse("9007199254740993") returns 9007199254740992, and the 20-digit order id 12345678901234567890 comes back as 12345678901234567000, both without an error. JSON numbers are decimal text with no size limit, and the reader decides what they become, which is why the same text can be exact in Python and quietly different in JavaScript.

You will predict which numbers change on the way through each parser, keep 64-bit ids and money amounts exact, use ECMAScript 2026’s JSON.rawJSON and the reviver’s source text with a fallback for engines that lack them, and choose a representation for amounts that every reader handles.

A JSON number is decimal text, and JavaScript reads it as binary64

The grammar of RFC 8259 puts no limit on the digits of a number. Its section 6 warns instead that software which implements IEEE 754 binary64: The IEEE 754 64-bit floating-point format, also called double precision, with 53 bits of precision. Every JavaScript number and every Python float is a binary64 value., double precision, is common, and that good interoperability comes from expecting no more precision or range than binary64 provides. Every JavaScript number is a binary64 value. Here are seven JSON numbers through JSON.parse:

Seven JSON numbers through JSON.parse JavaScript · probe/precision_probe.mjs
// The same JSON numbers through JSON.parse: every one becomes a binary64 Number.
const texts = ['9007199254740991', '9007199254740992', '9007199254740993', '12345678901234567890', '1.10', '1e400', '-0'];
for (const text of texts) {
  const n = JSON.parse(text);
  const shown = Object.is(n, -0) ? '-0' : String(n);
  console.log(`${text.padEnd(22)} -> ${shown.padEnd(22)} safe integer: ${Number.isSafeInteger(n)}`);
}
console.log('0.1 + 0.2 =', JSON.parse('0.1') + JSON.parse('0.2'));
console.log('MAX_SAFE_INTEGER =', Number.MAX_SAFE_INTEGER);

Output

9007199254740991       -> 9007199254740991       safe integer: true
9007199254740992       -> 9007199254740992       safe integer: false
9007199254740993       -> 9007199254740992       safe integer: false
12345678901234567890   -> 12345678901234567000   safe integer: false
1.10                   -> 1.1                    safe integer: false
1e400                  -> Infinity               safe integer: false
-0                     -> -0                     safe integer: true
0.1 + 0.2 = 0.30000000000000004
MAX_SAFE_INTEGER = 9007199254740991

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node precision_probe.mjs

Go two past the limit

Press Edit, add '9007199254740995' to texts and run it again. It comes back as 9007199254740996: halfway between two binary64 neighbours, the tie goes to the one whose last bit is 0.

Up to 9007199254740991, 2^53 − 1, every integer survived, and Number.isSafeInteger says so: that is Number.MAX_SAFE_INTEGER, the largest safe integer: An integer that binary64 holds exactly and shares with no other integer: from -(2^53 - 1) to 2^53 - 1, which is 9007199254740991. Number.isSafeInteger tests for it.. One step further, 9007199254740993 became 9007199254740992, and the 20-digit id came back ending in 000 instead of 890. 1.10 became 1.1, which is the same number, but 1e400 became Infinity, because it is beyond the largest binary64 value, and -0 stayed negative zero. Step through what happened to the id:

Interactive · stage by stage A 20-digit id on its way through JSON.parse Step through what happens to 12345678901234567890, then to 9007199254740993. Each step names the number the probes printed.
A 20-digit id on its way through JSON.parse, step 6 of 6. Showing The JSON text (9007199254740993), The value it means (exact: 2^53 + 1), The nearest binary64 (9007199254740992), The number printed back (9007199254740992). In focus: The number printed back.The JSON text9007199254740993The value it meansexact: 2^53 + 1The nearest binary649007199254740992The number printed back9007199254740992

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Twenty digits arrive. The text is 12345678901234567890, a valid JSON number. JSON itself sets no limit on its digits, so the text means exactly that integer.
  2. Binary64 cannot hold it. A binary64 number has 53 bits of precision. Near 1.2 × 10^19 neighbouring values lie 2048 apart, so most integers there have no binary64 value of their own.
  3. The nearest one is taken. JSON.parse takes the nearest binary64 value, 12345678901234567168, which is 722 below the integer the text meant. No error is raised.
  4. Printed with the fewest digits. Printing chooses the shortest digits that read back as the same binary64 value: 12345678901234567000. The id now ends in 000 instead of 890.
  5. One above 2 to the 53rd. At 2^53 the gap between neighbours is 2. 9007199254740993 lies exactly halfway between 9007199254740992 and 9007199254740994, and the tie goes to 9007199254740992.
  6. A different id, the same Number. JSON.parse returned 9007199254740992 for both 9007199254740992 and 9007199254740993: two different ids became one Number, so lookups by id can return the wrong order.

Two different ids, 9007199254740992 and 9007199254740993, became one Number. The ECMAScript specification gives exactly that pair as its example: integers above Number.MAX_SAFE_INTEGER share their value with at least one other integer. RFC 7493, the I-JSON profile, draws the practical conclusion: senders cannot expect receivers to handle integers whose absolute value is greater than 9007199254740991 exactly, and it recommends sending such numbers, such as 64-bit ids, as strings.

Key idea

A JSON reader that uses binary64 keeps integers exact only up to 2^53 − 1, 9007199254740991; above that, and for most decimal fractions, it keeps the nearest binary64 value, silently.

Checkpoint

Check: Binary64 and the safe integer limit

  1. Question 1 of 2 What is the largest integer n such that every integer from 0 to n is exact in binary64, the value of Number.MAX_SAFE_INTEGER?

    Type a number.

    A hint

    It is 2 to the 53rd, minus one.

    Show the answer: Question 1 of 2

    Answer: 9007199254740000

    2^53 − 1 is 9007199254740991, as precision_probe.mjs printed. Above it, neighbouring binary64 values are at least 2 apart, so integers start to share values.

  2. Question 2 of 2 Which two of these ids does JSON.parse turn into the same Number?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 9007199254740992 and 9007199254740993

    Integers above Number.MAX_SAFE_INTEGER share their value with at least one other integer, and the ECMAScript specification uses this very pair as its example.

Python keeps integers exact, and reads decimals exactly when asked

Python’s json module reads a number without a fraction or exponent as an int, and Python integers have no fixed size, so every id stays exact. Numbers with a fraction or an exponent become float, the same binary64 as in JavaScript:

The same numbers through json.loads Python · probe/precision_probe.py
"""The same JSON numbers through json.loads: integers stay exact, the rest become binary64 floats."""
import json
import math

texts = ["9007199254740991", "9007199254740992", "9007199254740993", "12345678901234567890", "1.10", "1e400", "-0"]
for text in texts:
    value = json.loads(text)
    print(f"{text:<22} -> {value!r:<22} {type(value).__name__}")
print("0.1 + 0.2 =", json.loads("0.1") + json.loads("0.2"))

# What a binary64 reader such as JSON.parse keeps of the 20-digit id: the nearest float, and its neighbours.
nearest = float(12345678901234567890)
print("nearest binary64 to 12345678901234567890:", int(nearest), "gap to the next:", math.ulp(nearest))
print("gap between floats at 2**53:", math.ulp(2.0**53))

Output

9007199254740991       -> 9007199254740991       int
9007199254740992       -> 9007199254740992       int
9007199254740993       -> 9007199254740993       int
12345678901234567890   -> 12345678901234567890   int
1.10                   -> 1.1                    float
1e400                  -> inf                    float
-0                     -> 0                      int
0.1 + 0.2 = 0.30000000000000004
nearest binary64 to 12345678901234567890: 12345678901234567168 gap to the next: 2048.0
gap between floats at 2**53: 2.0

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 precision_probe.py

All four integers came back exact, the 20-digit id included, but 0.1 + 0.2 is 0.30000000000000004, as it is in JavaScript, because both are binary64 sums. The last two lines show why: the binary64 value nearest to the id is 12345678901234567168, and neighbouring values there are 2048 apart. -0 became the integer 0: an int has no negative zero.

For decimal amounts, Python can skip floats altogether: json.loads(text, parse_float=Decimal) hands every number with a fraction to decimal.Decimal as its text. This program reads the same three amounts three ways:

Floats, Decimal and whole paise Python · decimal/decimal_amounts.py
"""Three ways to read the amounts of an order: as floats, as Decimal, and as whole paise."""
import json
from decimal import Decimal

text = '{"amounts": [0.10, 0.20, 0.30], "amounts_paise": [10, 20, 30]}'

a, b, c = json.loads(text)["amounts"]
print("floats, a + b + c:   ", a + b + c)
print("floats, sum():       ", sum([a, b, c]), "(compensated, but still binary floats)")

decimals = json.loads(text, parse_float=Decimal)["amounts"]
print("Decimal, sum():      ", sum(decimals))

paise = sum(json.loads(text)["amounts_paise"])
print("whole paise, sum():  ", paise, "->", f"{paise // 100}.{paise % 100:02d}")

print("1.15 * 100 as floats:", 1.15 * 100, "| as Decimal:", Decimal("1.15") * 100)
try:
    json.loads("1" * 4301)
except ValueError as e:
    print("an integer of 4,301 digits:", str(e).split(";")[0])

Output

floats, a + b + c:    0.6000000000000001
floats, sum():        0.6 (compensated, but still binary floats)
Decimal, sum():       0.60
whole paise, sum():   60 -> 0.60
1.15 * 100 as floats: 114.99999999999999 | as Decimal: 115.00
an integer of 4,301 digits: Exceeds the limit (4300 digits) for integer string conversion: value has 4301 digits

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 decimal_amounts.py

Adding the floats with + gave 0.6000000000000001. sum() gave 0.6, because since Python 3.12 it uses Neumaier summation, a compensated sum, for floats; its result is still a binary64 value, only a closer one. The Decimal sum kept the two decimal places, 0.60, and so did the whole paise. The last line is a limit of a different kind: Python refuses to turn a string of more than 4,300 digits into an int, by default, to stop slow conversions of huge numbers, so json.loads of a 4,301-digit integer raises ValueError.

Trusting a float that prints correctly

Wrong: amount = float("1.15") and then int(amount * 100), which gives 114, because 1.15 * 100 is 114.99999999999999, as the run printed. Right: parse with parse_float=Decimal, or receive whole paise, and keep binary floats away from money.

Checkpoint

Check: Python's int, float and Decimal

  1. Question 1 of 2 What does Python's json.loads("12345678901234567890") return?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The exact int 12345678901234567890

    precision_probe.py printed the id unchanged, type int. Only numbers with a fraction or an exponent become binary64 floats in Python.

  2. Question 2 of 2 What does this print?

    Read the code, then choose one answer.

    import json
    from decimal import Decimal
    print(repr(json.loads('{"amount": 0.10}', parse_float=Decimal)["amount"]))
    Show the answer: Question 2 of 2

    Answer: Decimal('0.10')

    parse_float is called with the string of every number that has a fraction or an exponent, so Decimal sees exactly what the sender wrote, as decimal_amounts.py did with 0.10, 0.20 and 0.30.

JavaScript keeps big integers exact with the reviver’s source text

ECMAScript 2026 includes the proposal “JSON.parse source text access”. It gives a reviver a third argument, a context object whose source is the original text of each primitive value, and it adds JSON.rawJSON, which puts a piece of JSON text into JSON.stringify’s output unchanged. Together they read and write 64-bit ids exactly. Because older engines lack them, test for them first and decide what happens without them:

Exact ids with the reviver's source text and JSON.rawJSON JavaScript · bigint/exact_ids.mjs
// Read and write 64-bit order ids exactly: the reviver's source text and JSON.rawJSON where the engine has them
// (ECMAScript 2026), and a refusal instead of a silently changed id where it does not.
const text = '{"orders": [{"id": 12345678901234567890, "qty": 2}, {"id": 9007199254740993, "qty": 1}]}';

const hasSourceText = typeof JSON.rawJSON === 'function';
console.log('JSON.parse source text access:', hasSourceText ? 'supported' : 'not supported');

function exactIds(key, value, context) {
  if (key !== 'id' || typeof value !== 'number') return value;
  if (hasSourceText) return BigInt(context.source); // the digits as written, before rounding
  if (!Number.isSafeInteger(value)) throw new RangeError(`id ${value} is not exact: send it as a string`);
  return BigInt(value);
}

try {
  const data = JSON.parse(text, exactIds);
  for (const o of data.orders) console.log('id', o.id.toString(), 'qty', o.qty);
  // Writing back: JSON.rawJSON puts the digits into the text unchanged.
  const out = JSON.stringify(data, (key, value) => (typeof value === 'bigint' ? JSON.rawJSON(value.toString()) : value));
  console.log(out);
} catch (e) {
  console.log(`${e.name}: ${e.message}`);
}
console.log('without a reviver:', JSON.stringify(JSON.parse(text).orders.map((o) => o.id)));

Output

JSON.parse source text access: supported
id 12345678901234567890 qty 2
id 9007199254740993 qty 1
{"orders":[{"id":12345678901234567890,"qty":2},{"id":9007199254740993,"qty":1}]}
without a reviver: [12345678901234567000,9007199254740992]

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node exact_ids.mjs

Version note

The Run button runs JavaScript in QuickJS, which has neither the reviver’s context nor JSON.rawJSON, so the program takes its other path there: it refuses the first id, which a Number cannot hold, instead of returning a wrong one. Node.js 24.21.0, which recorded the output above, supports both.

In your browser, QuickJS (quickjs-emscripten 0.32.0) prints:

JSON.parse source text access: not supported
RangeError: id 12345678901234567000 is not exact: send it as a string
without a reviver: [12345678901234567000,9007199254740992]

With the feature, the reviver turned the digits of each id into a BigInt, BigInt(context.source), before any rounding, and the replacer wrote them back with JSON.rawJSON(value.toString()), so the output text holds the same digits as the input. The last line is the same text through JSON.parse with no reviver: both ids changed.

The fallback matters as much as the feature. A program that silently uses the rounded Number when the engine lacks source text access gives wrong ids on exactly the browsers it was not tested in. Refusing with a clear error, or asking the sender for strings, is the safe choice.

Strings for ids, always

If you control the API, send 64-bit ids as strings, "id": "12345678901234567890", as I-JSON recommends. Every language and engine reads a string exactly, and an id is a label, not a quantity you add up.

Checkpoint

Check: Exact big integers in JavaScript

  1. One question In Node.js 24.21.0, what does context.source hold when the reviver is called for the number 12345678901234567890?

    Choose one answer.

    Show the answer: One question

    Answer: The string "12345678901234567890", the digits as written

    exact_ids.mjs read both ids exactly in Node.js. Where an engine has no source text access, as QuickJS behind the Run button, the program refused the id instead of returning a wrong one.

Money travels as whole minor units or decimal strings

Amounts need exact sums, and binary64 cannot hold most decimal fractions, so a contract for money chooses one of three representations, all of which every JSON reader handles:

Representation In the JSON Exact in Cost
Whole minor unit: The smallest unit of a currency that amounts are counted in, such as the cent or the paisa. Storing amounts as whole minor units keeps every sum exact. "amount_paise": 37900 Every language, up to 2^53 − 1 minor units in JavaScript Every reader divides by 100 for display, and must know the currency’s minor unit
Decimal string "amount": "379.00" Every language that parses it with a decimal type or as paise Each reader needs a parser for the string; numbers are not numbers in the JSON
JSON number, read as a decimal type "amount": 379.00 Python with parse_float=Decimal; JavaScript only with source text access Every reader must be configured, and one careless reader rounds

Whole minor units are the simplest contract: integers are exact everywhere, sums are integer sums, and the decimal point appears only when an amount is shown. Decimal strings suit amounts with more decimal places, such as unit prices, and amounts that people read in the raw JSON. A JSON number with a fraction is the riskiest, because it is exact only for readers that were told to keep it so. Whichever you choose, name the currency in the same object, with its three-letter code, such as "currency": "INR".

Rupees in paise

₹1,23,456.50, one lakh twenty-three thousand four hundred fifty-six rupees and fifty paise, is the whole number 12345650 in paise. The grouping of lakhs and crores is a matter of display: the JSON carries 12345650 and the currency, and each page formats it for its reader.

Checkpoint

Check: Representations for money

  1. Question 1 of 2 Which representation of an amount is exact in every language, with no parser setting at all?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Whole minor units, "amount_paise": 37900

    Integers of minor units are the simplest contract: exact everywhere up to 2^53 − 1, added with integer arithmetic, formatted only for display. Name the currency beside them.

  2. Question 2 of 2 How many paise is the amount 1234.50, as a whole number?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 123450 paise

    1234 rupees are 123400 paise, plus 50 paise: 123450. The exercise's to_paise does this with integer arithmetic, never multiplying a float by 100.

TOML, CSV and spreadsheets set number limits of their own

JSON is not the only format whose numbers outrun their readers. TOML recommends that parsers accept and handle at least 64-bit signed integers, from −2^63 to 2^63 − 1, losslessly, and requires an error for an integer that cannot be represented losslessly. CSV has no numbers at all, only text, so the limit is whatever the reader converts to:

The same big numbers in TOML and CSV Python · beyond/other_formats.py
"""The same big numbers in TOML and CSV, read with Python's standard library."""
import csv
import io
import tomllib

doc = tomllib.loads("max64 = 9223372036854775807\nbeyond = 9223372036854775808\nratio = 0.1")
for key, value in doc.items():
    print(f"TOML {key:<7} {value!r:<22} {type(value).__name__}")

rows = list(csv.DictReader(io.StringIO("id,amount\n12345678901234567890,379.10\n")))
row = rows[0]
print("CSV  id     ", repr(row["id"]), "-> float():", float(row["id"]), "-> int():", int(row["id"]))
print("CSV  amount ", repr(row["amount"]), "-> float():", float(row["amount"]))

Output

TOML max64   9223372036854775807    int
TOML beyond  9223372036854775808    int
TOML ratio   0.1                    float
CSV  id      '12345678901234567890' -> float(): 1.2345678901234567e+19 -> int(): 12345678901234567890
CSV  amount  '379.10' -> float(): 379.1

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 other_formats.py

tomllib returned both integers as exact Python ints, even 2^63, one past the 64-bit range, because a Python int can hold it losslessly. A JavaScript TOML parser that keeps integers as Number cannot hold 9223372036854775807 exactly, so by TOML’s rule it must refuse it, unless it uses BigInt. From CSV, the id came back as a string, which int() keeps exact and float() rounds to 1.2345678901234567e+19. Spreadsheets add one more limit: Excel keeps 15 significant digits, so a 16-digit or 20-digit id typed as a number loses digits the moment the file is opened.

IEEE 754 Floating-Point Converter Type 9007199254740993 or 0.1 and see the bits binary64 stores, and the value they really hold.

Checkpoint

Check: Number limits in TOML, CSV and spreadsheets

  1. One question What did Python's tomllib return for beyond = 9223372036854775808, one past the 64-bit range?

    Choose one answer.

    Show the answer: One question

    Answer: The exact int 9223372036854775808

    other_formats.py printed both integers exactly. A JavaScript TOML parser that keeps integers as Number cannot even hold 9223372036854775807, so by TOML's rule it must refuse it, unless it uses BigInt.

Practise: amounts as exact paise

The exercise implements the representation this lesson recommends, in Python or JavaScript: a parser that turns a decimal string into whole paise without any float, refusing what is not an amount, and its inverse for display:

Exercise · Easy · Python, JavaScript

Convert decimal amounts to whole paise exactly

An API sends amounts as decimal strings, the way RFC 7493 advises for values that binary64 cannot hold exactly: "19.99", "-5.50", "100". Write two functions, in Python (paise.py) or JavaScript (paise.mjs):

  • to_paise(amount) (toPaise in JavaScript) returns the amount as a whole number of paise, exactly: "19.99" is 1999, "0.1" is 10, "100" is 10000 and "-5.50" is -550. It raises an error (ValueError in Python, any Error in JavaScript) for anything else: more than two decimal places, an exponent, a plus sign, spaces, an empty string or a lone full stop.
  • from_paise(paise) (fromPaise) writes paise back as a string with exactly two decimal places: 1999 is "19.99", 5 is "0.05" and -550 is "-5.50".

Do not go through a float: parseFloat("19.99") * 100 is not 1999.

Python · Starter code · paise.py

import re


def to_paise(amount):
    """Whole paise in a decimal string with at most two decimal places, such as "19.99"."""
    return int(float(amount) * 100)


def from_paise(paise):
    """Paise written as a decimal string with exactly two decimal places."""
    return f"{paise / 100:.2f}"
The sample tests · test_paise.py
from paise import from_paise, to_paise


def refused(amount):
    """Whether to_paise(amount) raises ValueError."""
    try:
        to_paise(amount)
    except ValueError:
        return True
    return False


def test_exact_amounts():
    """converts amounts that a float would get wrong"""
    assert to_paise("19.99") == 1999
    assert to_paise("1.15") == 115
    assert to_paise("0.1") == 10
    assert to_paise("100") == 10000


def test_negative_and_large():
    """keeps the sign, and every digit of a large amount"""
    assert to_paise("-5.50") == -550
    assert to_paise("-0.05") == -5
    assert to_paise("12345678901234.56") == 1234567890123456


def test_refuses_bad_text():
    """refuses three decimals, exponents, signs, spaces and empty text"""
    for amount in ["1.005", "1e3", "+1.00", " 1.00", "1.00 ", "", ".", "1.", "-", "1,00"]:
        assert refused(amount), amount


def test_from_paise():
    """writes exactly two decimal places, with the sign"""
    assert from_paise(1999) == "19.99"
    assert from_paise(5) == "0.05"
    assert from_paise(-550) == "-5.50"
    assert from_paise(1234567890123456) == "12345678901234.56"

JavaScript · Starter code · paise.mjs

/** Whole paise in a decimal string with at most two decimal places, such as "19.99". */
export function toPaise(amount) {
  return Math.trunc(parseFloat(amount) * 100);
}

/** Paise written as a decimal string with exactly two decimal places. */
export function fromPaise(paise) {
  return (paise / 100).toFixed(2);
}
The sample tests · paise.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { fromPaise, toPaise } from './paise.mjs';

const refused = (amount) => {
  try {
    toPaise(amount);
  } catch {
    return true;
  }
  return false;
};

test('converts amounts that a float would get wrong', () => {
  assert.equal(toPaise('19.99'), 1999);
  assert.equal(toPaise('1.15'), 115);
  assert.equal(toPaise('0.1'), 10);
  assert.equal(toPaise('100'), 10000);
});

test('keeps the sign, and every digit of a large amount', () => {
  assert.equal(toPaise('-5.50'), -550);
  assert.equal(toPaise('-0.05'), -5);
  assert.equal(toPaise('12345678901234.56'), 1234567890123456);
});

test('refuses three decimals, exponents, signs, spaces and empty text', () => {
  for (const amount of ['1.005', '1e3', '+1.00', ' 1.00', '1.00 ', '', '.', '1.', '-', '1,00']) assert.ok(refused(amount), JSON.stringify(amount));
});

test('writes exactly two decimal places, with the sign', () => {
  assert.equal(fromPaise(1999), '19.99');
  assert.equal(fromPaise(5), '0.05');
  assert.equal(fromPaise(-550), '-5.50');
  assert.equal(fromPaise(1234567890123456), '12345678901234.56');
});
A hint

Check the whole text with one regular expression first, an optional minus, digits, and an optional full stop with one or two digits: ^(-?)([0-9]+)(?:\.([0-9]{1,2}))?$. Then build the result from the parts with integer arithmetic: rupees times 100, plus the fraction padded to two digits, with the sign applied at the end.

The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Then test yourself on binary64, Python’s numbers, exact ids, money and the limits of other formats. The questions explain themselves as you answer, and keep returning to the idea you find hardest:

Practice that adapts to you

Practise this lesson

15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 15 What does JSON.parse("1e400") return?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: Infinity

    Python's json.loads gives inf for the same text. JSON.stringify then writes Infinity as null, so the value is lost twice on a round trip.

  2. Question 2 of 15 How far apart are neighbouring binary64 values near 12345678901234567890, as the probe printed?

    Type a number.

    A hint

    Look at the last lines of precision_probe.py.

    Show the answer: Question 2 of 15

    Answer: 2048

    math.ulp printed 2048.0 there, so the id, whose nearest binary64 value is 12345678901234567168, cannot be told apart from about two thousand integers around it.

  3. Question 3 of 15 A page stores orders in a Map keyed by o.id, with ids parsed by plain JSON.parse. Orders 9007199254740992 and 9007199254740993 arrive. What does the Map hold?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: One entry, the second order, under 9007199254740992

    This is how rounded ids cause wrong orders, not just wrong numbers. Keep ids as strings, or read them with the reviver's source text.

  4. Question 4 of 15 What type and value does json.loads("1.10") return in Python?

    Choose one answer.

    Show the answer: Question 4 of 15

    Answer: The float 1.1

    precision_probe.py printed 1.1, float. The trailing zero is gone because a float has no notion of decimal places; Decimal keeps them.

  5. Question 5 of 15 In Python 3.14, why did a + b + c give 0.6000000000000001 while sum([a, b, c]) gave 0.6 for the floats 0.1, 0.2 and 0.3?

    Choose one answer.

    Show the answer: Question 5 of 15

    Answer: Since Python 3.12, sum() uses Neumaier summation for floats, a compensated sum that is closer, but still binary

    decimal_amounts.py printed both. A more accurate float sum is welcome, but money needs an exact sum: Decimal or whole minor units.

  6. Question 6 of 15 A service's json.loads raises ValueError for a 5,000-digit integer in a request. What is the right reading of that error?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: A deliberate default limit of 4,300 digits for int conversion, which you raise only if you truly need such numbers

    The run printed Exceeds the limit (4300 digits) for integer string conversion. sys.set_int_max_str_digits() can change it, but refusing such input is usually the safer answer.

  7. Question 7 of 15 What does JSON.rawJSON("12345678901234567890") give JSON.stringify?

    Choose one answer.

    Show the answer: Question 7 of 15

    Answer: A piece of JSON text that it writes unchanged, so the output holds those digits

    JSON.rawJSON accepts the text of a primitive JSON value only, and exact_ids.mjs used it to write the BigInt ids back with all their digits.

  8. Question 8 of 15 An engine lacks the reviver's source text. What should a program that needs exact ids do with an id above 2^53?

    Choose one answer.

    Show the answer: Question 8 of 15

    Answer: Refuse it with a clear error, or ask the sender for string ids

    In QuickJS, behind the Run button, exact_ids.mjs refused the first id with RangeError: id ... is not exact. A tested fallback is part of using a new language feature.

  9. Question 9 of 15 Why does exact_ids.mjs test typeof JSON.rawJSON === "function" instead of checking the browser's name or version?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: Testing for the feature itself works in every engine, including ones the author never saw

    JSON.rawJSON and the reviver's context come from the same proposal, so one test covers both. Engines such as QuickJS take the fallback path without any list of names.

  10. Question 10 of 15 Why should amounts not travel as binary floats?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: Most decimal fractions have no exact binary64 value, so sums and products drift

    A float is the nearest binary value to a decimal, not the decimal itself. Money needs exact values, so it travels as minor units or decimal strings.

  11. Question 11 of 15 What is missing from {"amount_paise": 37900} as a contract for money?

    Choose one answer.

    Show the answer: Question 11 of 15

    Answer: The currency, for example "currency": "INR", which tells the reader what the minor unit is

    Name the currency beside the amount, with its three-letter code, so a reader can check the minor unit instead of guessing it from a field name.

  12. Question 12 of 15 A wholesale API sends unit prices with four decimal places, such as 0.3725 per item. Which representation fits best?

    Choose one answer.

    Show the answer: Question 12 of 15

    Answer: A decimal string, "unit_price": "0.3725", read with a decimal type

    Whole minor units suit amounts in the currency's own unit; prices with more decimal places travel as decimal strings, or as integers with a stated scale.

  13. Question 13 of 15 What did float("12345678901234567890") give for the id read from CSV?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: 1.2345678901234567e+19

    CSV hands the reader text, and the reader's choice of conversion decides what survives. Ids should stay strings, or become int, never float.

  14. Question 14 of 15 How many significant digits does Excel keep for a number, by its specifications and limits?

    Type a number.

    Show the answer: Question 14 of 15

    Answer: 15 digits

    Excel's number precision is 15 digits, so a 16-digit or 20-digit id opened as a number loses digits, before any program reads the file.

  15. Question 15 of 15 A JavaScript TOML parser keeps integers as Number. What must it do with max64 = 9223372036854775807?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: Refuse it, because it cannot represent the value losslessly, unless it uses BigInt

    TOML recommends 64-bit integers and forbids silent loss. A JavaScript parser therefore needs BigInt for large integers, or it must refuse them with an error.

Interview questions

Warm-up (fresher to 2–5 years): why does JSON.parse(“9007199254740993”) give 9007199254740992?

Every JavaScript number is an IEEE 754 binary64 value, which has 53 bits of precision. Integers are exact up to 2^53 − 1, 9007199254740991, Number.MAX_SAFE_INTEGER; above it, neighbouring binary64 values are 2 apart, so 9007199254740993 has no value of its own. JSON.parse takes the nearest one, and since 9007199254740993 lies exactly halfway between 9007199254740992 and 9007199254740994, the tie goes to 9007199254740992. No error is raised, so two different ids can become the same number. Python’s json.loads keeps the integer exact, because Python integers have no fixed size. The fix is to send such ids as strings, as RFC 7493 recommends, or to read them with the reviver’s source text where the engine supports it.

Two harder questions follow, about ids that change between a back end and a browser and about totals that do not match a bank’s. Decide what you would check first, then open each first step:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A back end sends 64-bit order ids as JSON numbers. In the browser, two orders sometimes show the same id, and a link opens the wrong order. Explain the cause and fix it end to end, for browsers with and without the newest JSON features.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A nightly job reads 2 million line amounts such as 1.15 from a JSON export as floats, adds them up and compares the total with the bank's total in paise. The two differ by a few paise on some nights. Where can the difference come from, and how do you make the comparison exact?

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. Up to which integer is a JavaScript number exact?

    9007199254740991, which is 2^53 − 1 and Number.MAX_SAFE_INTEGER. Past it, neighbouring binary64 values are at least 2 apart.

  2. Why does JSON.parse("9007199254740993") return 9007199254740992?

    The text sits exactly halfway between two binary64 values, and the tie goes to 9007199254740992. Nothing warns you.

  3. How does Python's json module read numbers?

    Integers stay exact at any size, and fractions become floats. parse_float=Decimal keeps decimal amounts exact; integers longer than 4,300 digits are refused by default.

  4. How can JavaScript read and write a big integer in JSON without rounding it?

    With the reviver's context.source, the number's original text, and with JSON.rawJSON, which writes digits as given. Test that the engine has both, and refuse rather than round where it does not.

  5. How should an API send amounts of money and 64-bit ids?

    Money as whole minor units, such as paise, or as a decimal string with its currency, never as a binary float. 64-bit ids as strings, which is what I-JSON recommends.

  6. How many significant digits does Excel keep for a number?

    15. A longer number, such as an 18-digit reference, loses its last digits once a spreadsheet reads it as a number.

  7. What does TOML require of a parser for 64-bit integers?

    To keep each one losslessly or to refuse it: rounding is not an option.

Key takeaways

  • JSON numbers are decimal text with no limit, but a binary64 reader such as JSON.parse keeps integers exact only up to 2^53 − 1 and rounds the rest, silently, so 9007199254740993 and 9007199254740992 become one Number.
  • Python’s json keeps integers exact and reads fractions as floats; parse_float=Decimal keeps decimal amounts exact, and integers of more than 4,300 digits are refused by default.
  • In ECMAScript 2026 the reviver’s context.source and JSON.rawJSON read and write big integers exactly; test for them, and refuse rather than round where they are missing.
  • Send money as whole minor units or decimal strings with the currency, never as binary floats, and send 64-bit ids as strings, as I-JSON recommends.
  • TOML asks parsers to keep 64-bit integers losslessly or refuse them, CSV leaves every number to its reader, and Excel keeps only 15 significant digits.

Terms in this lesson

binary64
The IEEE 754 64-bit floating-point format, also called double precision, with 53 bits of precision. Every JavaScript number and every Python float is a binary64 value.
minor unit
The smallest unit of a currency that amounts are counted in, such as the cent or the paisa. Storing amounts as whole minor units keeps every sum exact.
safe integer
An integer that binary64 holds exactly and shares with no other integer: from -(2^53 - 1) to 2^53 - 1, which is 9007199254740991. Number.isSafeInteger tests for it.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.