skip to content

Which numeric types does BSON provide, and when is double the wrong choice?

level: middleimportance: should knowfreq 58%

answer

  1. a number is not one type here
  2. JavaScript only has one of them
  3. 0.1 has no exact binary form
  4. 2^53 is where exact integers stop
  5. one helper name starts with NumberD

basics

~20 s

BSON has int (32-bit), long (64-bit), double (64-bit binary float) and decimal (Decimal128). Double is wrong for money and for integers beyond 2^53, because binary floating point cannot represent decimal fractions or large integers exactly.

solid answer

~40 s

BSON distinguishes four numeric types: 32-bit `int`, 64-bit `long`, 64-bit binary floating-point `double`, and `decimal` — a 128-bit IEEE decimal value with 34 significant digits. The trap is that JavaScript has a single number type, a double, so values written from JS-based clients can land as doubles unless you say otherwise; mongosh gives you `NumberInt()`, `NumberLong()` and `NumberDecimal()` to write the type explicitly. Doubles cannot represent values like 0.1 exactly, so repeated addition of monetary amounts drifts, and they cannot represent integers above 2^53 exactly, so large external ids silently lose their low bits. For money, use `NumberDecimal` or store integer minor units in a `long`. One more consequence: MongoDB compares numeric types by exact value, so a query written with the double literal `9.99` will not match a stored `NumberDecimal("9.99")`.

code

javascript · 5 lines
javascript
db.t.insertOne({ d: 0.1, m: NumberDecimal("0.1") })
db.t.aggregate([{ $project: {
  dSum: { $add: ["$d", "$d", "$d"] },   // 0.30000000000000004
  mSum: { $add: ["$m", "$m", "$m"] }    // NumberDecimal("0.3")
} }])

go deeper

for a junior

Know that BSON has separate int, long, double and decimal types, and that mongosh writes them with NumberInt, NumberLong and NumberDecimal rather than a bare numeric literal.

for a middle

Explain why binary floating point cannot hold 0.1 or integers above 2^53 exactly, and pick the right type for money, counters and measurements with a reason for each.

for a senior

Show how you would audit and repair a collection whose numeric field holds mixed types, and explain the double-versus-decimal equality trap that makes correct-looking queries return nothing.

for a principal

Own the numeric contract across services: one representation for money, where scale and currency are recorded, and the migration path when a field must move from double to Decimal128 without breaking existing readers.

## Four numeric types, not one BSON is a strongly typed format, and "a number" is four different things: - **`int`** — 32-bit signed integer, 4 bytes. mongosh helper: `NumberInt()`. - **`long`** — 64-bit signed integer, 8 bytes. Helper: `NumberLong()`. This is the right home for large exact integers, counters and external 64-bit ids. - **`double`** — 64-bit IEEE 754 **binary** floating point, 8 bytes. The default numeric shape in most JavaScript contexts. Roughly 15–17 significant decimal digits, and exact only for values representable as a binary fraction. - **`decimal`** — Decimal128, a 128-bit IEEE 754-2008 **decimal** floating-point value with 34 significant decimal digits, 16 bytes. Helper: `NumberDecimal()`. The query operator `$type` accepts each of these by name (`"int"`, `"long"`, `"double"`, `"decimal"`) and also the alias `"number"`, which matches all four — indispensable when auditing a collection whose numeric fields have drifted. ## Why double is wrong for money A binary floating-point number stores a binary fraction. Decimal fractions like 0.1, 0.2 or 9.99 have no finite binary representation, so what gets stored is the nearest representable value. Add three doubles each holding 0.1 and the result is not 0.3 but 0.30000000000000004. Over a ledger of millions of rows, those last-bit errors accumulate into visible discrepancies, and totals computed two different ways stop agreeing. Decimal128 stores a decimal significand, so 9.99 is stored as exactly 9.99 and decimal arithmetic behaves the way an accountant expects, up to 34 significant digits. It costs 16 bytes instead of 8 and is slower to compute with than a hardware double, but for monetary and other exact-decimal quantities that is the right trade. The common alternative is to store **integer minor units** — cents, or thousandths — in a `long`, and keep the scale as a convention or a sibling field. That is exact, compact and fast, but every reader must know the scale, and it becomes awkward when currencies have different exponents or when you need fractional rates. Decimal128 keeps the value self-describing. ## Why double is wrong for large integers A double can represent integers exactly only up to 2^53. Beyond that, consecutive integers start mapping to the same double. A 64-bit identifier coming from another system, written from a JavaScript client without a `NumberLong()` wrapper, can therefore be stored with its low bits altered — silently, with no error. The symptom appears much later, as a lookup that finds nothing or two distinct source records collapsing into one. Wrap such values in `NumberLong()` (or use the driver's 64-bit type) at the boundary where they enter your code. ## Comparison across numeric types MongoDB sorts and compares all four numeric types together, by numeric value, and does so **exactly**. That mostly does what you want — `{ qty: { $gt: 5 } }` matches an int, a long and a double alike. But it produces one sharp edge: comparing a `double` with a `decimal` converts the double to its *exact* value, which for a literal like `9.99` is 9.9900000000000002131628… and therefore is **not** equal to `NumberDecimal("9.99")`. So a collection that stores prices as Decimal128 must be queried with `NumberDecimal("9.99")`, not with the bare literal. Range predicates still behave sensibly; it is equality that surprises people. ## Mixed types in one field Nothing stops a collection from holding `qty` as an int in some documents and a double or even a string in others, especially after an import or a client-library change. Because comparison across numeric types is by value, mixed *numeric* types query correctly; a stray **string** does not, since a number never equals a string in BSON. `db.c.find({ qty: { $type: "string" } })` finds the offenders, and an update using an aggregation pipeline with `$toInt` or `$toDecimal` repairs them. ## Choosing Use `int`/`long` for counts, quantities and exact identifiers; `decimal` for money and any quantity where decimal exactness is part of the contract; `double` for genuinely approximate physical measurements — coordinates, temperatures, scores — where the last bit does not carry meaning and the speed and compactness are worth having.

  • Why might a query for { price: 9.99 } miss a document whose price is NumberDecimal("9.99")?
    Because MongoDB compares a double to a decimal by the double's exact value, and the double written as 9.99 is really 9.9900000000000002…, which is not equal to the decimal 9.99. Query a Decimal128 field with NumberDecimal("9.99"). Range predicates still work as expected; it is equality that breaks.
  • When is storing integer cents in a long preferable to Decimal128?
    When every amount shares one fixed scale and you want the smallest, fastest exact representation — high-volume ledgers and counters. The cost is that the scale lives in convention rather than in the data, so every reader must know it, and multi-currency or fractional-rate work becomes awkward. Decimal128 keeps the value self-describing.
  • How do you find documents whose numeric field was accidentally stored as a string?
    Query with the $type operator: { field: { $type: "string" } }. The alias { $type: "number" } matches int, long, double and decimal together, so its negation isolates everything non-numeric. Repair the rows with an update that uses an aggregation pipeline and $toInt, $toLong or $toDecimal.

saying these in an interview costs you the question

  • Treats all MongoDB numbers as one interchangeable type
  • Says double has plenty of precision for currency amounts
  • Stores money as a string to avoid rounding problems
  • Assumes a 64-bit id survives a round trip through a JavaScript number
  • Expects a double literal to equal a stored Decimal128 value

context