Documents and BSON
You write documents as JSON, but MongoDB does not store them as JSON text. It stores them as BSON — short for Binary JSON — a compact binary format that adds the things JSON lacks: real numeric types, dates, binary data, and a few special types of its own. BSON is what makes a document fast to scan, precise about numbers, and able to carry types that plain JSON text cannot represent.
You almost never see the raw BSON. The shell and drivers translate between BSON and the natural objects of your language for you. But knowing what types are available — and which one you are actually storing — saves you from a whole category of subtle bugs, especially around numbers and dates.
The common BSON types
Section titled “The common BSON types”A document field can hold any of these. The everyday ones first:
- String — UTF-8 text, like a name or a title.
- Int32 — a 32-bit integer, the default for whole numbers in many drivers.
- Int64 — a 64-bit integer, for whole numbers too large for 32 bits.
- Double — a 64-bit floating-point number, the default for numbers with a decimal point.
- Boolean —
trueorfalse. - Date — a moment in time, stored as milliseconds since the Unix epoch.
- Array — an ordered list of values, which may themselves be any BSON type.
- Embedded document — a full document nested inside a field, with its own fields.
- ObjectId — a compact 12-byte identifier, the default type of the
_idfield. - Null — an explicit empty value (different from the field being absent).
- Decimal128 — a 128-bit decimal for exact base-10 arithmetic, the right type for money.
A document is just these types composed together. Here is a rich example that uses most of them at once, inserted for real, followed by the server’s own answer to “what type is each field?” (the $type operator, using the aliases from the BSON types reference):
db.members.insertOne({ _id: ObjectId("65f0b3d4e4b0a1c2e4b0a1d1"), name: "Ada", active: true, fines: 0, balance: Decimal128("12.50"), visits: Long("4815162342"), joined: ISODate("2023-04-12T09:30:00Z"), borrowed: ["Compilers", "Algorithms"], address: { city: "Cambridge", zip: "02139" }, nickname: null,});console.log(db.members.findOne());
const types = db.members.aggregate([{ $project: { _id: 0, t: { $arrayToObject: { $map: { input: { $objectToArray: "$$ROOT" }, in: { k: "$$this.k", v: { $type: "$$this.v" } },} } } } }]).toArray()[0].t;console.log(types);assert.strictEqual(types.fines, "int");assert.strictEqual(types.visits, "long");assert.strictEqual(types.balance, "decimal");{ _id: ObjectId('65f0b3d4e4b0a1c2e4b0a1d1'), name: 'Ada', active: true, fines: 0, balance: Decimal128('12.50'), visits: Long('4815162342'), joined: ISODate('2023-04-12T09:30:00.000Z'), borrowed: [ 'Compilers', 'Algorithms' ], address: { city: 'Cambridge', zip: '02139' }, nickname: null}{ _id: 'objectId', name: 'string', active: 'bool', fines: 'int', balance: 'decimal', visits: 'long', joined: 'date', borrowed: 'array', address: 'object', nickname: 'null'}name is a string, active a boolean, fines an int32 ('int'), balance a Decimal128, visits an int64 ('long'), joined a Date, borrowed an array, address an embedded document ('object'), and nickname an explicit null. Note that mongosh printed Decimal128('12.50'), Long('4815162342') and ISODate(...) - not the plain strings a JSON dump would show. That is BSON, not JSON.
How many bytes does each type cost?
Section titled “How many bytes does each type cost?”bsonsize() returns the encoded size of a document. An empty document is 5 bytes; each one-field document below adds the type byte, the field name and the value:
const sizes = { empty: bsonsize({}), int32: bsonsize({ a: 1 }), double: bsonsize({ a: 1.5 }), int64: bsonsize({ a: Long("1") }), decimal128: bsonsize({ a: Decimal128("1") }), string_abc: bsonsize({ a: "abc" }), date: bsonsize({ a: new Date() }), objectId: bsonsize({ a: new ObjectId() }), bool: bsonsize({ a: true }),};console.log(sizes);assert.strictEqual(sizes.int32, 12);assert.strictEqual(sizes.double, 16);{ empty: 5, int32: 12, double: 16, int64: 16, decimal128: 24, string_abc: 16, date: 16, objectId: 20, bool: 9}An int32 field costs 4 bytes of value, a double or an int64 8, a Decimal128 16 and an ObjectId 12 (plus the 1 type byte, the name a and its terminator). Picking int32 where it fits is a small but free saving across millions of documents.
How the types nest
Section titled “How the types nest”The power of the document model is that arrays and embedded documents can contain more arrays and documents, to whatever depth your data needs.
flowchart TD
Doc["Document"] --> Scalars["Scalar fields
string, int32, double, bool, date"]
Doc --> Id["_id: ObjectId"]
Doc --> Arr["Array field
borrowed[ ]"]
Doc --> Emb["Embedded document
address { }"]
Emb --> EmbF["city, zip"] Numbers are where the types bite
Section titled “Numbers are where the types bite”The shell and the drivers decide the BSON type of a number from the language value, and they do not all decide the same way. We inserted the same kinds of numbers from mongosh and from Node.js 24 with driver 7.7.0 and asked the server for each stored $type:
| Value written | mongosh | Node driver |
|---|---|---|
1 or 1.0 | int | int (JavaScript cannot tell 1.0 from 1) |
Double(1) | double | - (no separate type; use a fractional value) |
1.5 | double | double |
2**31 (2 147 483 648) or 3000000000 | double | double |
2**53 | double | double |
Long("2147483648") (NumberLong) | long | Long.fromNumber(2**31) -> long |
BigInt(4815162342) / 4815162342n | long | long |
const c = db.collection("n");await c.insertOne({ i: 1, whole: 1.0, half: 1.5, big: 2 ** 31, safe: 2 ** 53, l: Long.fromNumber(2 ** 31), bi: 4815162342n, dec: Decimal128.fromString("12.50") });const t = await c.aggregate([{ $project: { _id: 0, i: { $type: "$i" }, whole: { $type: "$whole" }, half: { $type: "$half" }, big: { $type: "$big" }, safe: { $type: "$safe" }, l: { $type: "$l" }, bi: { $type: "$bi" }, dec: { $type: "$dec" } } }]).next();console.log(t);console.log(await c.findOne({}, { projection: { _id: 0, l: 1, bi: 1, dec: 1 } }));console.log(await c.findOne({}, { projection: { _id: 0, l: 1 }, promoteLongs: false }));if (t.whole !== "int" || t.big !== "double" || t.l !== "long") throw new Error("unexpected types");{ i: 'int', whole: 'int', half: 'double', big: 'double', safe: 'double', l: 'long', bi: 'long', dec: 'decimal'}{ l: 2147483648, bi: 4815162342, dec: new Decimal128('12.50') }{ l: new Long('2147483648') }Three consequences you can act on: (1) a JavaScript number above 2^31 is stored as a double, so use Long or a BigInt for a real int64 counter; (2) reading a long back gives a JavaScript number by default (promoteLongs), which silently loses precision above 2^53 - pass promoteLongs: false (or useBigInt64: true) to get a Long back; (3) a schema validator with bsonType: "int" will reject 2**31 from Node because it arrives as a double.
Double vs Decimal128 for money
Section titled “Double vs Decimal128 for money”console.log(0.1 + 0.2);db.prices_double.insertMany([{ v: 0.1 }, { v: 0.2 }]);db.prices_dec.insertMany([{ v: Decimal128("0.1") }, { v: Decimal128("0.2") }]);const sum = (c) => db[c].aggregate([{ $group: { _id: null, sum: { $sum: "$v" } } }]).toArray()[0].sum;console.log(sum("prices_double"), sum("prices_dec"));assert.strictEqual(sum("prices_double") === 0.3, false);assert.strictEqual(sum("prices_dec").toString(), "0.3");0.300000000000000040.30000000000000004 Decimal128('0.3')The server’s own $sum of two doubles gives 0.30000000000000004; the same numbers as Decimal128 give exactly 0.3 (34 decimal digits of precision, per the BSON types reference).
The _id field and ObjectId
Section titled “The _id field and ObjectId”Every document has an _id field, and it is the document’s primary key - unique within its collection. If you do not supply one when you insert, MongoDB generates an _id for you, and its default type is an ObjectId: a 12-byte value designed to be unique without coordinating with the server.
Those 12 bytes are not random. Per the BSON types reference, an ObjectId is:
- A 4-byte timestamp - seconds since the Unix epoch when the id was created.
- A 5-byte random value, generated once per client-side process (unique to the machine and process; regenerated when the process restarts).
- A 3-byte incrementing counter per client-side process, initialised to a random value.
You can take one apart yourself. This decodes a real id in mongosh:
const id = ObjectId("65f0b3d4e4b0a1c2e4b0a1d1");const hex = id.toHexString();console.log(id.getTimestamp());console.log(hex.slice(0, 8), parseInt(hex.slice(0, 8), 16), new Date(parseInt(hex.slice(0, 8), 16) * 1000).toISOString());console.log("random:", hex.slice(8, 18), "counter:", hex.slice(18));assert.strictEqual(hex.length, 24); // 12 bytes = 24 hex charactersassert.strictEqual(id.getTimestamp().toISOString(), "2024-03-12T19:58:12.000Z");ISODate('2024-03-12T19:58:12.000Z')65f0b3d4 1710273492 2024-03-12T19:58:12.000Zrandom: e4b0a1c2e4 counter: b0a1d1And the “counter” and “random” claims, proven with three ids generated back to back by one Node process (driver 7.7.0):
const ids = [new ObjectId(), new ObjectId(), new ObjectId()];for (const id of ids) { const h = id.toHexString(); console.log(h, "ts", h.slice(0, 8), "rand", h.slice(8, 18), "counter", h.slice(18), parseInt(h.slice(18), 16));}await db.collection("ord").insertMany(Array.from({ length: 1000 }, (_, i) => ({ seq: i })));const bySeq = await db.collection("ord").find({}).sort({ _id: 1 }).project({ seq: 1, _id: 0 }).toArray();console.log("sorted by _id equals insertion order:", bySeq.every((d, i) => d.seq === i));if (!bySeq.every((d, i) => d.seq === i)) throw new Error("_id order differs from insertion order");6abca5dd3c98eeca8695c79f ts 6abca5dd rand 3c98eeca86 counter 95c79f 98159676abca5dd3c98eeca8695c7a0 ts 6abca5dd rand 3c98eeca86 counter 95c7a0 98159686abca5dd3c98eeca8695c7a1 ts 6abca5dd rand 3c98eeca86 counter 95c7a1 9815969sorted by _id equals insertion order: trueSame timestamp, same random part, counter +1 each: that is why ids created in the same second by one process still differ and sort in creation order. Two different processes in the same second share only the timestamp, so across processes the order is only “roughly” by time.
You can also set _id yourself to any unique value - a string, a number, an email - when your data already has a natural key. MongoDB only requires that it exists and is unique; it does not have to be an ObjectId. But the type then matters to every lookup, as the next fence shows:
db.s.insertOne({ _id: "65f0b3d4e4b0a1c2e4b0a1d1", name: "string-id" });console.log(db.s.find({ _id: ObjectId("65f0b3d4e4b0a1c2e4b0a1d1") }).toArray());console.log(db.s.find({ _id: "65f0b3d4e4b0a1c2e4b0a1d1" }).toArray());console.log(db.s.aggregate([{ $project: { t: { $type: "$_id" } } }]).toArray());assert.strictEqual(db.s.countDocuments({ _id: ObjectId("65f0b3d4e4b0a1c2e4b0a1d1") }), 0);[][ { _id: '65f0b3d4e4b0a1c2e4b0a1d1', name: 'string-id' } ][ { _id: '65f0b3d4e4b0a1c2e4b0a1d1', t: 'string' } ]The same 24 hex characters, stored as a string, are not found by an ObjectId query (and vice versa).
Null is not absent
Section titled “Null is not absent”db.z.insertMany([{ _id: 1, nickname: null }, { _id: 2 }, { _id: 3, nickname: "Al" }]);console.log("nickname: null ", db.z.find({ nickname: null }, { _id: 1 }).toArray());console.log("$exists: false ", db.z.find({ nickname: { $exists: false } }, { _id: 1 }).toArray());console.log("$type: 'null' ", db.z.find({ nickname: { $type: "null" } }, { _id: 1 }).toArray());console.log("$exists: true, $eq: null", db.z.find({ nickname: { $exists: true, $eq: null } }, { _id: 1 }).toArray());assert.strictEqual(db.z.countDocuments({ nickname: null }), 2);nickname: null [ { _id: 1 }, { _id: 2 } ]$exists: false [ { _id: 2 } ]$type: 'null' [ { _id: 1 } ]$exists: true, $eq: null [ { _id: 1 } ]{ nickname: null } matches both the explicit null (_id: 1) and the missing field (_id: 2). To tell them apart use $exists or $type: "null".
Size: the 16 MiB ceiling
Section titled “Size: the 16 MiB ceiling”A single document is capped at 16 MiB (hello.maxBsonObjectSize = 16777216). We inserted a 16 MiB string from Node:
try { await db.collection("big").insertOne({ blob: "x".repeat(16 * 1024 * 1024) });} catch (e) { console.log(e.name + ":", e.message); }const ok = await db.collection("big").insertOne({ blob: "x".repeat(16 * 1024 * 1024 - 100) });console.log("just under the limit:", ok.acknowledged);if (!ok.acknowledged) throw new Error("expected the near-limit insert to succeed");MongoServerError: object to insert too large. size in bytes: 16777249, max size: 16777216just under the limit: trueThe limit counts the whole encoded document (field names, type bytes, _id), not just your payload - the 16 MiB string alone tipped it over by 33 bytes.
Tips and gotchas
Section titled “Tips and gotchas”- ObjectIds are generated client-side, by the driver, not the server (the Node run above created ids with no server call). Your application knows the new
_idthe instant it builds the document, before the insert reaches the database. - Date is not the same as the BSON Timestamp type. The BSON reference says the Timestamp type is “for internal MongoDB use” and that application code should use the Date type.
- Use Decimal128 for money. Proven above:
$sumof doubles0.1and0.2is0.30000000000000004; as Decimal128 it is exactly0.3. - Null is not absent.
{ field: null }matches both, but$existsand$type: "null"tell them apart (proven above).
Reading AI-generated code
Section titled “Reading AI-generated code”Ask an assistant for “an invoices collection with totals” and the totals are usually plain JavaScript numbers:
🐛 Spot the bug
// AI comment: "store totals as numbers, sum them on the server"await db.collection("invoices").insertMany([{ total: 0.1 }, { total: 0.2 }]);const [{ sum }] = await db.collection("invoices").aggregate([{ $group: { _id: null, sum: { $sum: "$total" } } }]).toArray();if (sum === 0.3) markLedgerBalanced(); // never runsShow the answer
The fields are stored as BSON double, so the server’s $sum returns 0.30000000000000004 and the equality check is false - real output on mongo 8.3.11 (the shell run above printed the same digits). The ledger will “never balance” and nobody sees why. The fix is to store money as Decimal128 (or as integer cents in an int64) and compare exactly; this fence ran on the same server:
await db.collection("invoices").insertMany([ { total: Decimal128.fromString("0.1") }, { total: Decimal128.fromString("0.2") },]);const [{ sum }] = await db.collection("invoices") .aggregate([{ $group: { _id: null, sum: { $sum: "$total" } } }]) .toArray();console.log(sum, sum.toString() === "0.3");if (sum.toString() !== "0.3") throw new Error("expected exact 0.3");new Decimal128('0.3') trueMental Model
Section titled “Mental Model”Every field is a (name, type, value) triple, not just a name and a value. JSON hides the type behind the syntax; BSON writes it down. Most bugs in this lesson are the same bug: the value looked right (0.1, 1.0, a 24-hex string) but the type the driver chose was not the one you meant, and queries, sorts and $sum all honour the type, not the look.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| BSON (extra types like Date, ObjectId, Decimal128, Binary) | Stores values as their real type; queries and sorts behave correctly for that type, such as sorting by a real date or computing money exactly with Decimal128 | Not as instantly portable as plain JSON - external systems that only accept JSON text must convert these types first, which can lose precision if done carelessly |
| Plain JSON | Instantly portable across systems, easy to read, tooling supports it everywhere | Only a handful of basic types (string, number, boolean, null, array, object) - you must encode Date and high-precision numbers as strings yourself |
| Double for money | Native JavaScript number, fastest to write | 0.1 + 0.2 -> 0.30000000000000004; totals drift |
| Decimal128 for money | Exact base-10 arithmetic, 34 digits | 16 bytes per value vs 8, and you must construct it explicitly in every driver |
ObjectId _id | 12 bytes, generated client-side, sorts in creation order per process | Not human-readable; carries a creation time you may not want to expose |
Common Misconceptions
Section titled “Common Misconceptions”- “BSON and JSON are the same thing.” - you write and read JSON-like values, but the server stores typed BSON.
Long('4815162342')andDecimal128('12.50')printed above are not JSON. - “A number is a number.” -
1and1.0are bothintfrom Node,2**31becomes adouble, andpromoteLongshands alongback as a JavaScript number that loses precision above 2^53. - “An ObjectId is just a string of hex.” - stored as a string it is not found by an ObjectId query (
[]above) and vice versa. - “
{ field: null }finds documents where the field is null.” - it also finds documents where the field is missing. - “Use Double for money because it looks like a decimal.” - see the SpotTheBug above.
- “There is no size limit.” - 16 MiB per document, counted on the encoded bytes.
Real-world example
MongoDB’s own BSON reference recommends
decimal128for “high-precision applications like finance and banking” and calls the Timestamp type “for internal MongoDB use” (BSON types, fetched 2026-09-30).
Python/Go/Rust are not used in this lesson; the fences ran in mongosh and the Node driver.