Store money in MongoDB as an exact type, never a BSON double. The general default is the Decimal128 type, a 128-bit decimal floating-point number that holds decimal amounts exactly with 34 digits of precision. In mongosh you create it with NumberDecimal("9.99"). The high-volume alternative is to store the integer count of minor units (cents) in a 64-bit NumberLong and divide by 100 for display. Either way you also store the currency code separately in its own ISO 4217 string field, because an amount with no currency attached is a bug waiting to surface. What you must not do is reach for a plain double. It is binary floating point and cannot represent most decimal fractions exactly, so your totals slowly drift away from the truth.
Short answer: price: NumberDecimal("9.99") plus currency: "USD" for almost every application. Switch the amount to a NumberLong of integer cents only when you are running a high-throughput ledger and want integer arithmetic end to end. Do the math in the aggregation pipeline on Decimal128 (or in integer minor units), and never let a bare 9.99 JavaScript literal touch a monetary value, because that literal is already a double.
Why you must never use a double for money
A bare numeric literal like 9.99 in mongosh, in Node.js, or in any JSON document is a BSON double: a 64-bit IEEE 754 binary floating-point number. That is great for measurements where a tiny relative error is fine, and wrong for money where every cent is supposed to be exact.
The problem is that most decimal fractions have no exact binary representation. 0.1 in binary is a repeating fraction, the same way 1/3 is 0.333... in decimal. So MongoDB stores the nearest representable value, which is almost 0.1 but not quite, and adding enough of those almost-values together compounds the error. The canonical demonstration is that 0.1 + 0.2 does not equal 0.3 in floating point, it lands a hair above.
In a money field that surfaces as a 19.99 stored as 19.989999999999998, an aggregation total over ten thousand invoices that is off by a few cents, and an exact-match query like { price: 19.99 } that finds nothing because the stored value was never exactly 19.99. None of it is a MongoDB bug. It is what binary floating point does, and the fix is to not use it for money. MongoDB's own monetary-data guide says the same: use the decimal type or scale factors, not a double.
Option A: Decimal128, the recommended default
Decimal128 is a 128-bit IEEE 754 decimal floating-point type. Unlike a binary double it stores decimal values exactly, with 34 significant decimal digits of precision and a very wide exponent range, far more headroom than money will ever need. It was added in MongoDB 3.4 (released November 2016), so any supported server has it.
In mongosh you create one with the NumberDecimal() constructor:
db.products.insertOne({
name: "Widget",
price: NumberDecimal("9.99"),
currency: "USD"
})On the wire and in Extended JSON it serializes as $numberDecimal, so a stored document reads like this:
{
"_id": ObjectId("..."),
"name": "Widget",
"price": { "$numberDecimal": "9.99" },
"currency": "USD"
}The reason Decimal128 is safe where a double is not: it is decimal floating point. 0.1 is stored as the digits 1 at the right exponent, exactly, so 0.1 + 0.2 is exactly 0.3 and a hundred thousand of them sum to exactly the right total. Server-side arithmetic keeps that exactness too: the aggregation operators $sum, $multiply, $add and the update operators $inc / $mul all operate on Decimal128 values without ever converting to binary.
The NumberDecimal("string") gotcha
This is the one that bites people. Always pass a string to NumberDecimal(), never a numeric literal.
NumberDecimal("9.99") // correct: the exact decimal 9.99
NumberDecimal(9.99) // WRONG: 9.99 is already a double, precision lost before conversionNumberDecimal(9.99) looks identical but it is not. The 9.99 in that call is parsed as a JavaScript double first, so it has already drifted to the nearest binary value before NumberDecimal ever sees it. The constructor then faithfully converts that drifted binary number into a Decimal128, preserving the error instead of the value you meant. Passing the string "9.99" skips the binary round-trip entirely and stores the exact decimal. Same rule in every driver: build the decimal from a string or an exact decimal type, never from a float.
Option B: integer cents in a NumberLong, for high-volume ledgers
The other exact approach (MongoDB calls it the scale factor method) skips decimals entirely and stores the integer count of minor units. A US dollar's minor unit is the cent, so $19.99 is stored as the integer 1999, and you divide by 100 when you display it. This is exact for the same reason Decimal128 is exact (integers have no fractional rounding at all) and it is fast, because the database and the application both do plain integer arithmetic with no decimal machinery in the path.
Store it as a 64-bit NumberLong (BSON int64), not a bare number, so it never degrades into a double:
db.payments.insertOne({
orderId: 1,
amountMinor: NumberLong(1999), // $19.99, scale = 2
currency: "USD"
})The trade is that you carry two facts in your application rather than the field: the scale (how many minor units per major unit: 2 for dollars and euros, 0 for yen) and the currency itself. You multiply by 100 on input, divide by 100 on display, and you keep that consistent everywhere. A NumberLong holds up to about 9.2 quintillion, so even counting in cents you have room for trillions.
I reach for integer cents when the workload is a high-throughput payments or accounting ledger where every byte of document size and every bit of arithmetic speed counts, and the team is disciplined about the scale convention. For a normal application, Decimal128 is less error-prone because the decimal lives in the value, not in everyone's head.
Comparison: double vs Decimal128 vs integer cents
| Approach | Exact? | Precision | Storage | Ergonomics | Use when |
|---|---|---|---|---|---|
BSON double | No, binary drift | ~15-17 binary digits | 8 bytes | Reads naturally but silently wrong | Never for money |
Decimal128 (NumberDecimal) | Yes, decimal | 34 decimal digits | 16 bytes | Stores and reads as the real amount; decimal in the value | General default, FX and sub-cent included |
Integer cents (NumberLong) | Yes, integer math | full int64 range | 8 bytes | Divide by 100 to display; scale tracked in app | High-volume ledgers, integer arithmetic end to end |
Both exact options are correct. The choice between them is ergonomics versus throughput, not accuracy. The double row is in the table only so it is unambiguous: it is never the answer for a monetary value.
Always store the currency code separately
An amount is meaningless without a currency. 1000 could be a thousand dollars or a thousand yen, and those differ by two orders of magnitude. Store the currency alongside the amount in its own field holding the ISO 4217 three-letter code: "USD", "EUR", "GBP", "JPY". A plain string field is exactly right; the codes are always three uppercase letters.
Keeping currency in its own field also stops a subtle bug: if you ever sum a mixed-currency collection without grouping by currency, you add dollars to euros and get a nonsense number. With a currency field you group by it, and the schema makes the multi-currency reality visible instead of hiding it.
If you want the database itself to refuse a stray double, attach a $jsonSchema validator that pins the amount to bsonType: "decimal" and requires the currency. A bad insert then fails at write time instead of silently landing as a binary float:
db.createCollection("orders", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["price", "currency"],
properties: {
price: { bsonType: "decimal" },
currency: { bsonType: "string", pattern: "^[A-Z]{3}$" }
}
}
}
})For integer cents the same validator uses bsonType: "long" on the amountMinor field. The validator is the one place that catches the mistake regardless of which driver or shell wrote the document.
A worked example: orders and an exact aggregation total
Here is a minimal orders collection using the recommended default. Each line item carries an exact Decimal128 price and the documents carry the currency. (Whether those line items are embedded like this or referenced by id is the embed-vs-reference schema decision; either way the money rule is the same.)
db.orders.insertMany([
{ orderId: 1, item: "Widget", price: NumberDecimal("19.99"), currency: "USD" },
{ orderId: 1, item: "Setup", price: NumberDecimal("5.00"), currency: "USD" },
{ orderId: 1, item: "Fee A", price: NumberDecimal("0.10"), currency: "USD" },
{ orderId: 1, item: "Fee B", price: NumberDecimal("0.20"), currency: "USD" }
])Now total an order with an aggregation. Grouping by currency keeps the sum honest, and the $sum stays in Decimal128 the whole way:
db.orders.aggregate([
{ $match: { orderId: 1 } },
{ $group: { _id: "$currency", total: { $sum: "$price" } } }
])
// => { "_id": "USD", "total": NumberDecimal("25.29") }That total is exactly 25.29. The two line items of 0.10 and 0.20 add to exactly 0.30, because Decimal128 is decimal floating point and never went near a binary mantissa. Run the same data through a double field and the total can land a fraction off, which is the whole reason the type matters.
The integer-cents alternative
The same order stored as scaled integers looks like this, with the aggregation summing plain int64 values:
db.orders_minor.insertMany([
{ orderId: 1, item: "Widget", amountMinor: NumberLong(1999), currency: "USD" },
{ orderId: 1, item: "Setup", amountMinor: NumberLong(500), currency: "USD" },
{ orderId: 1, item: "Fee A", amountMinor: NumberLong(10), currency: "USD" },
{ orderId: 1, item: "Fee B", amountMinor: NumberLong(20), currency: "USD" }
])
db.orders_minor.aggregate([
{ $match: { orderId: 1 } },
{ $group: { _id: "$currency", totalMinor: { $sum: "$amountMinor" } } }
])
// => { "_id": "USD", "totalMinor": NumberLong(2529) } // 2529 cents = $25.29You divide 2529 by 100 in the display layer. The arithmetic is exact integer addition, and the scale (2) lives in your application's money formatting, not in the database.
Keep doubles out of the application and the drivers too
Picking the right BSON type only gets you halfway. If your application reads a Decimal128 out of MongoDB and parses it into a native float or double, you have thrown away the exactness at the boundary. Carry money through your code in an exact type, and build the BSON decimal from a string or exact decimal, never from a float:
- Node.js / JavaScript: JavaScript numbers are all IEEE 754 doubles, so build the value with the driver's
Decimal128.fromString("9.99")(from thebsonpackage), notnew Decimal128(9.99). For integer cents, plain integer math is safe up toNumber.MAX_SAFE_INTEGER. Never add two money amounts as plain JS numbers. - Python: use
bson.decimal128.Decimal128constructed from adecimal.Decimal, which you build from the string form, not from a float literal. PyMongo hands youDecimal128objects back for decimal fields, which is what you want. - Java / others: use the driver's
Decimal128/BigDecimaltypes end to end, and only render to a display string at the very edge.
The principle is one sentence: money is exact from the field, through the aggregation pipeline, into the application type, and back, with doubles nowhere on the path.
The relational equivalent uses fixed-point columns instead of Decimal128: in MySQL the same rule pushes you to DECIMAL(19,4) or BIGINT cents, and in Postgres numeric is the exact type to reach for over the legacy money type. The underlying reason is identical across all three: keep money off binary floating point.
FAQ
See also
- Storing money in MySQL: DECIMAL vs BIGINT cents is the relational version of this same decision, swapping
Decimal128for a fixed-point column. - The exact money type in PostgreSQL covers
numericand why the legacymoneytype is a trap. - Storing a UUID in MongoDB handles the other field every order document needs: a stable identifier in the right BSON type.
- Embedding vs referencing in a MongoDB schema decides whether those priced line items live inside the order or in their own collection.
- Running MongoDB in Docker gets you a throwaway server to try these
NumberDecimalandNumberLonginserts against.
Sources
Authoritative references this article was fact-checked against.





