Type something to search...
Storing Money and Decimals in MongoDB with Decimal128

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") and Decimal128("19.9") compare as equal, but they're stored with different exponents, and the first one displays as 19.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:

ConsiderationDecimal128Integer minor units
ExactnessExact for decimal valuesExact, as long as values stay integers
Fractional cents (rates, FX)NaturalAwkward: needs a smaller unit or separate handling
Language supportNeeds decimal types or libraries in the appNative integers everywhere
JSON APIsSerialize as stringsPlain numbers (watch 2^53 in JavaScript)
Readability in the database89.99 reads naturally8999 requires knowing the unit
Aggregation arithmeticDecimal results, $round for scaleInteger math, but division creates fractions
Storage16 bytes4 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.

Tags :
Share :

Related Posts

A Complete Guide to MongoDB Query Operators

A Complete Guide to MongoDB Query Operators

Your first MongoDB queries are usually simple equality filters: find the user with this email, find orders with this status. That covers a surprising

Continue Reading
Async MongoDB in Python with Motor and FastAPI

Async MongoDB in Python with Motor and FastAPI

FastAPI runs your endpoints on an event loop. That's what lets a single worker juggle hundreds of concurrent requests: while one request waits on the

Continue Reading
Atlas Online Archive: Tiering Cold Data to Cut Costs

Atlas Online Archive: Tiering Cold Data to Cut Costs

Look at almost any production database and you'll find the same shape. A small slice of recent data gets nearly all the reads and writes: this week's

Continue Reading