
Storing Money and Decimals in MongoDB with Decimal128
Store a price as 19.99 in MongoDB, sum a few thousand orders, and eventually a report shows a total like 48213.899999999994. Nothing is broken. JavaScript numbers and the default BSON number type are both binary floating point (IEEE 754 doubles), and binary floating point can't represent most decimal fractions exactly. For scientific measurements, the tiny error doesn't matter. For money, it matters a lot: invoices that are off by a cent, reconciliation reports that never balance, and tax calculations that auditors question.
MongoDB offers a purpose-built solution: Decimal128, a 128-bit decimal floating-point type that stores base-10 numbers exactly. There's also a simpler, older approach: store amounts as integers in the smallest currency unit, like cents. Both work well when used correctly, and both can go wrong in subtle ways.
This guide covers why doubles fail for money, how to create and query Decimal128 values from mongosh, Node.js, Mongoose, and Python, how arithmetic and rounding behave in aggregations, when integer minor units are the better choice, and how to migrate existing double fields.
Why Doubles Are Wrong for Money
In mongosh, numbers default to doubles, just like in JavaScript:
db.orders.insertMany([{ total: 0.1 }, { total: 0.2 }]);
db.orders.aggregate([{ $group: { _id: null, sum: { $sum: "$total" } } }]);
[{ _id: null, sum: 0.30000000000000004 }];
The value 0.1 has no exact binary representation, in the same way that one third has no exact decimal representation. The stored double is the closest binary fraction, and the error shows up after arithmetic. Each individual error is tiny, but they accumulate across sums, and they make equality checks unreliable: a query for { total: 0.3 } won't match a document whose total was computed as 0.1 + 0.2.
You can round at display time, and many systems do. But rounding at the edges doesn't fix totals that were computed from already-imprecise values, and it pushes the correctness burden onto every piece of code that touches the number.
What Decimal128 Is
Decimal128 is the IEEE 754-2008 128-bit decimal format. It stores a coefficient of up to 34 significant decimal digits and a base-10 exponent, which means values like 19.99 or 0.1 are represented exactly. Its range is enormous (exponents from roughly -6143 to +6144), far beyond anything a currency needs.
Two properties are worth understanding:
- It preserves trailing zeros.
Decimal128("19.90")andDecimal128("19.9")compare as equal, but they're stored with different exponents, and the first one displays as19.90. That's often useful for money, since the value remembers its scale. - It's still floating point, just in base 10. Operations like division can produce results that need rounding (one third still isn't exact), but anything you can write as a finite decimal string with 34 or fewer digits round-trips without error.
Decimal128 values take 16 bytes versus 8 for a double, and arithmetic on them is slower than on native doubles. For typical business workloads, neither difference is noticeable.
Creating Decimal128 Values
mongosh
Use Decimal128() (or its alias NumberDecimal()), and always pass a string:
db.products.insertOne({
sku: "LMP-204",
name: "Brass Desk Lamp",
price: Decimal128("89.99"),
currency: "USD",
});
db.products.findOne({ sku: "LMP-204" }, { _id: 0, price: 1 });
{
price: Decimal128("89.99");
}
Passing a number instead, like Decimal128(89.99), means the value becomes a double first and is then converted, so any binary error can come along for the ride. Strings avoid the problem entirely. The same rule applies in every language.
Node.js Driver
The driver re-exports the BSON Decimal128 class:
import { MongoClient, Decimal128 } from "mongodb";
const client = new MongoClient(process.env.MONGODB_URI);
const orders = client.db("shop").collection("orders");
await orders.insertOne({
orderId: "ORD-10492",
lines: [
{ sku: "LMP-204", qty: 2, unitPrice: Decimal128.fromString("89.99") },
{ sku: "BLB-011", qty: 4, unitPrice: Decimal128.fromString("4.25") },
],
currency: "USD",
});
const doc = await orders.findOne({ orderId: "ORD-10492" });
console.log(doc.lines[0].unitPrice.toString()); // "89.99"
Reading a Decimal128 gives you a Decimal128 object, not a JavaScript number. Call toString() to get the exact string. Resist the urge to convert it to a Number for arithmetic in your application, because that reintroduces binary floating point. If you need to do money math in JavaScript, use a decimal library (such as decimal.js or big.js) and construct values from the string.
Mongoose
Mongoose supports Decimal128 as a schema type. By default, toJSON() serializes it as { "$numberDecimal": "89.99" }, which is rarely what an API client wants. A getter fixes that:
import mongoose from "mongoose";
const productSchema = new mongoose.Schema(
{
sku: { type: String, required: true, unique: true },
price: {
type: mongoose.Schema.Types.Decimal128,
required: true,
get: (v) => (v == null ? v : v.toString()),
},
currency: { type: String, required: true, default: "USD" },
},
{ toJSON: { getters: true }, toObject: { getters: true } },
);
const Product = mongoose.model("Product", productSchema);
await Product.create({ sku: "LMP-204", price: "89.99" }); // strings are cast
Mongoose casts strings to Decimal128 on save, which is convenient for request bodies. Remember that getters don't run with .lean() queries, so lean results still contain raw Decimal128 objects.
Python and PyMongo
PyMongo pairs Decimal128 with Python's standard decimal.Decimal:
from decimal import Decimal
from bson.decimal128 import Decimal128
db.products.insert_one({
"sku": "LMP-204",
"price": Decimal128(Decimal("89.99")),
"currency": "USD",
})
doc = db.products.find_one({"sku": "LMP-204"})
price = doc["price"].to_decimal() # Decimal('89.99')
print(price * 2) # 179.98
PyMongo doesn't encode a raw decimal.Decimal automatically by default, so wrap it in Decimal128(...) when writing and call .to_decimal() when reading. If you'd rather not do that by hand, you can register a custom type codec with the client's TypeRegistry to convert both ways automatically. When doing math in Python, use Decimal throughout, and never pass floats into it: Decimal(89.99) captures the binary error, while Decimal("89.99") doesn't.
Querying Decimal128 Fields
Comparison queries work across numeric types, so { price: { $gt: 50 } } matches a Decimal128 price of 89.99. MongoDB compares numbers by value regardless of whether they're ints, longs, doubles, or decimals.
That cross-type comparison hides a sharp edge for equality:
db.products.find({ price: 89.99 }); // may not match Decimal128("89.99")
db.products.find({ price: Decimal128("89.99") }); // matches
The double 89.99 is actually 89.9899999999999948840923025272786617279052734375. Compared exactly against the decimal 89.99, they're different numbers. Always query Decimal128 fields with Decimal128 values. The same applies to range boundaries where the exact edge matters, like "orders of at least 100.00."
Indexes on Decimal128 fields work normally, including in compound indexes and for sorting.
Arithmetic and Rounding in Aggregation
Aggregation operators like $add, $subtract, $multiply, $divide, and $sum accept Decimal128. When any operand is a Decimal128, the result is a Decimal128:
db.orders.aggregate([
{ $match: { orderId: "ORD-10492" } },
{ $unwind: "$lines" },
{
$group: {
_id: "$orderId",
subtotal: { $sum: { $multiply: ["$lines.unitPrice", "$lines.qty"] } },
},
},
{
$set: {
tax: { $round: [{ $multiply: ["$subtotal", Decimal128("0.0875")] }, 2] },
},
},
{ $set: { total: { $add: ["$subtotal", "$tax"] } } },
]);
[
{
_id: "ORD-10492",
subtotal: Decimal128("196.98"),
tax: Decimal128("17.24"),
total: Decimal128("214.22"),
},
];
Two details matter. First, the tax rate is also a Decimal128. Writing 0.0875 as a plain number would make it a double, and a double operand contaminates the calculation with binary error even though the result type is decimal. Second, $round uses round-half-to-even (banker's rounding): 2.345 rounds to 2.34 and 2.355 rounds to 2.36. That's the standard for many financial contexts because it avoids a systematic upward bias, but if your business rules require round-half-up, you'll need to implement that explicitly or do the rounding in application code. $trunc truncates toward zero if you need that instead.
Decide where rounding happens and write it down. Rounding each line item and then summing gives a different result than summing and rounding once, and both are legitimate depending on your tax jurisdiction and invoicing rules.
Always Store the Currency
An amount without a currency is a bug waiting to happen. Store them together:
{
orderId: "ORD-10492",
total: { amount: Decimal128("214.22"), currency: "USD" }
}
Currencies also have different minor units. The US dollar and euro use two decimal places, the Japanese yen uses zero, and the Kuwaiti dinar uses three. Decimal128 handles all of these naturally, since the precision isn't fixed. Your rounding logic, however, needs to look up the right number of places per currency (the ISO 4217 standard defines them).
Never sum amounts across currencies in an aggregation without converting them first. Group by currency:
db.orders.aggregate([
{ $group: { _id: "$total.currency", revenue: { $sum: "$total.amount" } } },
]);
The Alternative: Integer Minor Units
Before Decimal128 existed, the standard advice was to store money as integers in the smallest unit: 8999 cents instead of 89.99 dollars. It's still a perfectly valid design, and some teams prefer it.
{ sku: "LMP-204", priceCents: NumberLong(8999), currency: "USD" }
Here's how the two compare:
| Consideration | Decimal128 | Integer minor units |
|---|---|---|
| Exactness | Exact for decimal values | Exact, as long as values stay integers |
| Fractional cents (rates, FX) | Natural | Awkward: needs a smaller unit or separate handling |
| Language support | Needs decimal types or libraries in the app | Native integers everywhere |
| JSON APIs | Serialize as strings | Plain numbers (watch 2^53 in JavaScript) |
| Readability in the database | 89.99 reads naturally | 8999 requires knowing the unit |
| Aggregation arithmetic | Decimal results, $round for scale | Integer math, but division creates fractions |
| Storage | 16 bytes | 4 or 8 bytes |
Integer cents are a great fit when you only ever handle whole minor units: simple e-commerce prices, wallet balances, and ledgers. They get awkward when you need sub-cent precision, such as per-unit pricing for API calls (0.0004 dollars per request), interest calculations, or currency conversion. Decimal128 handles those without inventing new units.
If you choose integers, use 64-bit integers (NumberLong in mongosh, Long or BigInt handling in Node.js) for totals that can grow large, and remember that JavaScript numbers are only exact up to 2^53, about 9 quadrillion. Cents rarely get there, but tiny units like micro-dollars can.
Whichever you pick, be consistent. A collection where some documents store price as Decimal128 and others as a double is harder to reason about than either design on its own.
Enforcing the Type with Schema Validation
Consistency is easier to maintain when the database rejects the wrong type. With JSON Schema validation, the BSON type name for Decimal128 is decimal:
db.runCommand({
collMod: "products",
validator: {
$jsonSchema: {
bsonType: "object",
required: ["sku", "price", "currency"],
properties: {
price: { bsonType: "decimal", description: "must be Decimal128" },
currency: { bsonType: "string", pattern: "^[A-Z]{3}$" },
},
},
},
validationLevel: "strict",
validationAction: "error",
});
Now an insert with price: 89.99 (a double) fails validation instead of silently creating an inconsistent document. The post on schema validation with JSON Schema covers validation levels and rollout strategies for existing collections.
Migrating Existing Double Fields
If your collection already stores prices as doubles, convert them with an update pipeline. $toDecimal converts the double, and because the double may carry binary noise, round the result to the currency's scale:
db.products.updateMany({ price: { $type: "double" } }, [
{ $set: { price: { $round: [{ $toDecimal: "$price" }, 2] } } },
]);
Before running this on production data, check it on a sample: compare old and new values with an aggregation, look for anything that rounded unexpectedly, and make sure your application code already reads Decimal128 correctly. Deploy the reading code first, then migrate, then add the validator, so there's never a moment when the app sees a type it can't handle.
For a large collection, run the migration in batches (for example by _id ranges) to avoid one enormous write that stresses replication. Bulk write operations are a good fit if you need per-document logic.
Common Pitfalls
Passing numbers instead of strings to Decimal128 constructors. Decimal128.fromString("19.99") is exact. Anything that starts as a JavaScript number or Python float may already be wrong.
Converting to native numbers for math. Reading a Decimal128 and calling Number() or float() on it throws away the entire benefit. Keep values in a decimal type end to end.
Mixing doubles into decimal arithmetic. A single double operand, like a tax rate written as 0.0875, brings binary error into the result. Make every operand a Decimal128.
Querying with doubles. Equality and exact-boundary queries must use Decimal128 values.
Assuming round-half-up. $round uses banker's rounding. Confirm which rule your business needs.
Serializing as numbers in JSON. If your API emits 89.99 as a JSON number, clients parse it into a double and you're back where you started. Send money as strings ("89.99") or as integer minor units.
Conclusion
Doubles are the wrong type for money, and MongoDB gives you two good alternatives. Decimal128 stores base-10 values exactly, handles fractional cents and exchange rates naturally, and works with indexes, queries, and aggregation arithmetic. Integer minor units are simpler and faster when you only ever need whole cents. Either way, keep the currency next to the amount, keep every operand in the same exact type, decide explicitly where rounding happens, and let schema validation enforce the rule.
Run db.orders.countDocuments({ "total.amount": { $type: "double" } }) against your own data. If the number is above zero, you've found your first migration, and a validator with bsonType: "decimal" will make sure it's the last one.


