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
- You write a JavaScript object literal in mongosh (or your app calls
insertOnevia the driver). - 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).
- The BSON bytes are sent over the wire and written to the storage engine (WiredTiger) as-is, inside compressed data files.
- 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, andDecimal128wrapper objects rather than plain strings or numbers. - Because every field carries a type code, operators like
$typecan 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
_idas anObjectIdunless you have a specific reason to supply your own key. - Always wrap an
_idstring coming from outside the database (URL params, form input, JWT claims) innew ObjectId(...)before querying. - Use
NumberDecimalfor 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
NumberLongfor 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
$typein 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
paymentscollection: one withamountas a plain JS number (e.g.19.99) and one withamountasNumberDecimal("19.99"). Query both back withfind()and note how mongosh displays each differently. - Given a hypothetical string
"64f1a2b3c4d5e6f7a8b9c0d1"representing an order id from a URL, write the correctfindOnequery againstdb.ordersthat will actually match the stored_id. - Run
db.orders.find({ total: { $type: "decimal" } })against a collection with mixed-typetotalfields (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, andBinary, 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
ObjectIdto 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.
