TechEarl

How to Store Money in MongoDB: Decimal128 vs Integer Cents

Never store money as a BSON double. Use the Decimal128 type for exact decimal amounts, or store integer cents in a 64-bit NumberLong for high-volume ledgers, and always keep the ISO 4217 currency code in its own field. Comparison, worked documents, and the NumberDecimal string gotcha.

Ishan Karunaratne⏱️ 13 min readUpdated
Share thisCopied
How to store money in MongoDB: why the BSON double drifts, the Decimal128 type for exact decimal amounts, integer cents in a NumberLong for high-volume ledgers, and a separate ISO 4217 currency field.

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.

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:

javascript
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:

javascript
{
  "_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.

javascript
NumberDecimal("9.99")   // correct: the exact decimal 9.99
NumberDecimal(9.99)     // WRONG: 9.99 is already a double, precision lost before conversion

NumberDecimal(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:

javascript
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

ApproachExact?PrecisionStorageErgonomicsUse when
BSON doubleNo, binary drift~15-17 binary digits8 bytesReads naturally but silently wrongNever for money
Decimal128 (NumberDecimal)Yes, decimal34 decimal digits16 bytesStores and reads as the real amount; decimal in the valueGeneral default, FX and sub-cent included
Integer cents (NumberLong)Yes, integer mathfull int64 range8 bytesDivide by 100 to display; scale tracked in appHigh-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:

javascript
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.)

javascript
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:

javascript
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:

javascript
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.29

You 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 the bson package), not new Decimal128(9.99). For integer cents, plain integer math is safe up to Number.MAX_SAFE_INTEGER. Never add two money amounts as plain JS numbers.
  • Python: use bson.decimal128.Decimal128 constructed from a decimal.Decimal, which you build from the string form, not from a float literal. PyMongo hands you Decimal128 objects back for decimal fields, which is what you want.
  • Java / others: use the driver's Decimal128 / BigDecimal types 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

Sources

Authoritative references this article was fact-checked against.

TagsMongoDBDecimal128CurrencyMoneySchema DesignBSONNumberDecimal

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

How to Store Money in MySQL: DECIMAL vs Integer Cents

Never use FLOAT or DOUBLE for money. Use DECIMAL(19,4) for exact fixed-point amounts, or BIGINT integer cents for high-volume ledgers, and always store the ISO 4217 currency code separately. Comparison, worked schema, and the rounding rules.