BSON vs JSON

Every document you insert into MongoDB looks like JSON when you type it into mongosh, but MongoDB never actually stores JSON on disk. It stores BSON (Binary JSON) — a binary-encoded superset of the JSON data model. BSON keeps the same nested, document-shaped structure as JSON but adds a set of richer, more precise data types and a compact binary layout designed for fast machine parsing rather than human readability. Understanding the difference matters because it explains why a query works, why ObjectId comparisons silently fail, and why storing money as a plain number can quietly lose precision.

Overview: what BSON actually is

JSON is a text format. It has exactly six data types: string, number, boolean, null, array, and object. Every number in JSON — whether it represents an integer counter or a fractional price — is just “number”, with no way to distinguish a 32-bit integer from a 64-bit double, and no native date, binary, or unique-identifier type. Anything richer has to be encoded as a string by convention (an ISO date string, a base64-encoded blob) and reparsed by the application.

BSON fixes this by tagging every field with an explicit type byte and encoding values in binary rather than text. A BSON document is a length-prefixed sequence of type-tagged, name-value pairs. Because each element carries its own type and (for variable-length types) its own byte length up front, the database engine can skip over fields it doesn’t need, jump directly to a field’s bytes, or determine a document’s total size without scanning every character — something a text parser can’t do nearly as efficiently. This is part of why MongoDB can traverse and index documents quickly at scale.

BSON adds types JSON simply doesn’t have, including:

  • ObjectId — a 12-byte unique identifier (4-byte timestamp + 5-byte random value + 3-byte incrementing counter), used as the default _id.
  • Date — a genuine 64-bit millisecond timestamp, not a string you have to reparse.
  • Int32 / Int64 (NumberLong) — distinct fixed-width integer types, separate from floating-point doubles.
  • Decimal128 (NumberDecimal) — a 128-bit decimal type for exact base-10 arithmetic, critical for money.
  • Binary data (BinData) — raw bytes stored natively, no base64 bloat.
  • Regular expression, Timestamp (internal, distinct from Date), MinKey/MaxKey — used internally and for sharding/replication bookkeeping.

So when you type a JavaScript object literal into mongosh, you are writing plain JavaScript. The MongoDB driver underneath mongosh serializes that JS object into BSON before it goes over the wire to the server. The server stores that BSON on disk. When you query it back, the driver deserializes the BSON bytes into JavaScript values again and mongosh prints them using constructor-style helpers — ObjectId("..."), ISODate("..."), Decimal128("...") — so you can see, and even copy-paste, the real BSON type rather than a plain string.

Extended JSON

Because BSON is binary, tools that need to move MongoDB data through text-based systems (log exports, REST APIs, mongoexport, backups you want to diff in git) use MongoDB Extended JSON: plain JSON where BSON-only types are represented as small wrapper objects, e.g. an ObjectId becomes {"$oid": "64f1a2b3c4d5e6f7a8b9c0d1"} and a date becomes {"$date": "2026-08-03T14:22:01Z"}. There are two flavors: canonical extended JSON, which is unambiguous but verbose (every number is wrapped, e.g. {"$numberInt": "3"}), and relaxed extended JSON, which is easier to read because native JSON numbers and booleans are left alone where no precision is lost. Neither is what mongosh shows you interactively — that’s a JS-literal-like shell representation, not extended JSON text.

Syntax: BSON type helpers in mongosh

mongosh gives you constructor functions to explicitly create BSON types when a plain JS literal wouldn’t be precise enough:

Helper BSON type Why you’d use it
ObjectId() ObjectId Build/parse a 12-byte id, e.g. from a string param
new Date() / ISODate() Date Store a real timestamp, not a string
NumberInt() Int32 Force a 32-bit integer instead of a double
NumberLong() Int64 Store integers beyond the safe 2^53 range
NumberDecimal() Decimal128 Exact decimal math, e.g. currency
BinData() Binary Store raw byte data

Note that a plain JS number literal like 3 or 129.99 is always stored as a BSON double unless you explicitly wrap it — this is one of the most common sources of confusion covered in Common Mistakes below.

Examples

Example 1: Inserting a document with several BSON-only types.

use shop
db.orders.insertOne({
  customer: "Priya Sharma",
  total: NumberDecimal("129.99"),
  itemCount: NumberInt(3),
  placedAt: new Date(),
  notes: null,
  tags: ["priority", "gift-wrap"]
});
{
  acknowledged: true,
  insertedId: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1")
}

Notice MongoDB generated the _id automatically as an ObjectId because we didn’t supply one. total was stored as an exact Decimal128, not a floating-point double, and placedAt is a true BSON Date, not a string.

Example 2: Reading the document back shows the real BSON types.

db.orders.findOne({ customer: "Priya Sharma" });
{
  _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1"),
  customer: 'Priya Sharma',
  total: Decimal128("129.99"),
  itemCount: 3,
  placedAt: ISODate("2026-08-03T14:22:01.000Z"),
  notes: null,
  tags: [ 'priority', 'gift-wrap' ]
}

mongosh isn’t printing JSON here — it’s printing a shell representation of real JavaScript/BSON values. ObjectId(...) and ISODate(...) are constructor calls you could paste right back into another query.

Example 3: Exporting to real (extended) JSON text with mongoexport.

mongoexport --uri="mongodb://<user>:<password>@<cluster-url>/shop" --collection=orders --jsonArray --out=orders.json
[{"_id":{"$oid":"64f1a2b3c4d5e6f7a8b9c0d1"},"customer":"Priya Sharma","total":{"$numberDecimal":"129.99"},"itemCount":3,"placedAt":{"$date":"2026-08-03T14:22:01.000Z"},"notes":null,"tags":["priority","gift-wrap"]}]

This is genuine, parseable JSON text — note how ObjectId, Decimal128, and Date each became a small wrapper object so no type information is lost when leaving BSON’s binary form.

How it works step by step

  1. You write a JavaScript object literal in mongosh (or your app calls insertOne via the driver).
  2. The driver walks the object and serializes it into BSON: each field gets a one-byte type code, its name, and its value encoded per that type’s binary layout (little-endian, length-prefixed for strings/arrays/subdocuments).
  3. The BSON bytes are sent over the wire and written to the storage engine (WiredTiger) as-is, inside compressed data files.
  4. On a read, the same process runs in reverse: WiredTiger returns raw BSON bytes, the driver deserializes them into native JS values (or Node/Mongoose objects), reconstructing ObjectId, Date, and Decimal128 wrapper objects rather than plain strings or numbers.
  5. Because every field carries a type code, operators like $type can filter on it directly, e.g. db.orders.find({ placedAt: { $type: "date" } }) matches only documents where that field is a genuine BSON date — no value-parsing required.
db.orders.find({ placedAt: { $type: "date" } });

Common Mistakes

1. Comparing an ObjectId to a plain string. A value pulled from a URL parameter or a form is always a JS string. Querying with it directly never matches, because BSON compares by type as well as value.

// req.params.id is always a string, e.g. "64f1a2b3c4d5e6f7a8b9c0d1"
const id = req.params.id;
db.orders.findOne({ _id: id }); // never matches -- string !== ObjectId

Fix it by explicitly converting the string into an ObjectId before querying:

const id = req.params.id;
db.orders.findOne({ _id: new ObjectId(id) });

2. Storing money (or any value needing exact precision) as a plain JS number. Plain numbers become BSON doubles, which use binary floating point and cannot represent many decimal fractions exactly.

db.orders.insertOne({ product: "Laptop", price: 0.1 + 0.2 });
{ product: 'Laptop', price: 0.30000000000000004 }

For currency and any value where rounding errors are unacceptable, use NumberDecimal, which stores an exact base-10 value:

db.orders.insertOne({ product: "Laptop", price: NumberDecimal("0.30") });

3. Assuming JSON.stringify() preserves BSON type fidelity. Running JSON.stringify(doc) on a document you fetched turns its Date into a plain ISO string and can mangle ObjectId/Decimal128 objects into their internal representation rather than a usable value. If you need a text form that round-trips BSON types correctly, use extended JSON (e.g. via mongoexport, or EJSON.stringify() in application code) instead of the built-in JSON.stringify.

Best Practices

  • Let MongoDB auto-generate _id as an ObjectId unless you have a specific reason to supply your own key.
  • Always wrap an _id string coming from outside the database (URL params, form input, JWT claims) in new ObjectId(...) before querying.
  • Use NumberDecimal for any field involved in financial math; never rely on plain doubles for money.
  • Store real dates with new Date() / ISODate(), not ISO strings, so you can use date range queries and indexes correctly.
  • Use NumberLong for integers that might exceed 2^53 (JavaScript’s safe integer limit) to avoid silent precision loss.
  • When you need a text representation of a document that preserves BSON types, use MongoDB Extended JSON tooling, not generic JSON.stringify.
  • Use $type in queries when you need to filter or audit documents by their underlying BSON type, especially in schema-flexible collections with mixed historical data.

Practice Exercises

  • Insert two documents into a payments collection: one with amount as a plain JS number (e.g. 19.99) and one with amount as NumberDecimal("19.99"). Query both back with find() and note how mongosh displays each differently.
  • Given a hypothetical string "64f1a2b3c4d5e6f7a8b9c0d1" representing an order id from a URL, write the correct findOne query against db.orders that will actually match the stored _id.
  • Run db.orders.find({ total: { $type: "decimal" } }) against a collection with mixed-type total fields (some doubles, some Decimal128) and predict which documents it returns before checking.

Summary

  • MongoDB stores documents as BSON, a binary, type-tagged superset of the JSON data model — not text JSON.
  • BSON adds types JSON lacks: ObjectId, Date, Int32/Int64, Decimal128, and Binary, among others.
  • mongosh’s shell output (ObjectId(...), ISODate(...)) is a JS-literal-style view of real BSON values, not raw JSON text.
  • MongoDB Extended JSON (canonical or relaxed) is how BSON types are represented in actual JSON text, e.g. by mongoexport.
  • Comparing an ObjectId to a plain string, or storing money as a plain double, are the two most common BSON-related bugs — both are fixed by using the right BSON constructor.