Skip to content

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.

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 — true or false.
  • 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 _id field.
  • 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):

mongosh/bson-types.js
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.

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:

mongosh/bson-sizes.js
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.

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"]
A document is scalar fields plus arrays and embedded documents nested to any depth

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 writtenmongoshNode driver
1 or 1.0intint (JavaScript cannot tell 1.0 from 1)
Double(1)double- (no separate type; use a fractional value)
1.5doubledouble
2**31 (2 147 483 648) or 3000000000doubledouble
2**53doubledouble
Long("2147483648") (NumberLong)longLong.fromNumber(2**31) -> long
BigInt(4815162342) / 4815162342nlonglong
app/bson-node-numbers.mjs
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.

mongosh/bson-money.js
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.30000000000000004
0.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).

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:

mongosh/bson-objectid.js
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 characters
assert.strictEqual(id.getTimestamp().toISOString(), "2024-03-12T19:58:12.000Z");
ISODate('2024-03-12T19:58:12.000Z')
65f0b3d4 1710273492 2024-03-12T19:58:12.000Z
random: e4b0a1c2e4 counter: b0a1d1

And the “counter” and “random” claims, proven with three ids generated back to back by one Node process (driver 7.7.0):

app/bson-objectid-node.mjs
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 9815967
6abca5dd3c98eeca8695c7a0 ts 6abca5dd rand 3c98eeca86 counter 95c7a0 9815968
6abca5dd3c98eeca8695c7a1 ts 6abca5dd rand 3c98eeca86 counter 95c7a1 9815969
sorted by _id equals insertion order: true

Same 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:

mongosh/bson-string-id.js
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).

mongosh/bson-null-missing.js
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".

A single document is capped at 16 MiB (hello.maxBsonObjectSize = 16777216). We inserted a 16 MiB string from Node:

app/bson-16mib.mjs
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: 16777216
just under the limit: true

The 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.

  • 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 _id the 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: $sum of doubles 0.1 and 0.2 is 0.30000000000000004; as Decimal128 it is exactly 0.3.
  • Null is not absent. { field: null } matches both, but $exists and $type: "null" tell them apart (proven above).

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 runs
Show 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:

app/bson-money-fix.mjs
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') true

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.

ChoiceBenefitCost
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 Decimal128Not 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 JSONInstantly portable across systems, easy to read, tooling supports it everywhereOnly a handful of basic types (string, number, boolean, null, array, object) - you must encode Date and high-precision numbers as strings yourself
Double for moneyNative JavaScript number, fastest to write0.1 + 0.2 -> 0.30000000000000004; totals drift
Decimal128 for moneyExact base-10 arithmetic, 34 digits16 bytes per value vs 8, and you must construct it explicitly in every driver
ObjectId _id12 bytes, generated client-side, sorts in creation order per processNot human-readable; carries a creation time you may not want to expose
  • “BSON and JSON are the same thing.” - you write and read JSON-like values, but the server stores typed BSON. Long('4815162342') and Decimal128('12.50') printed above are not JSON.
  • “A number is a number.” - 1 and 1.0 are both int from Node, 2**31 becomes a double, and promoteLongs hands a long back 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 decimal128 for “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.

What does BSON stand for, and why does MongoDB use it?
What does the first part of an ObjectId encode?
Which BSON type should you use to store money exactly?
In Node, you insert { visits: 2 ** 31 }. What BSON type does the server report?
A collection has { _id: 1, nickname: null }, { _id: 2 } and { _id: 3, nickname: "Al" }. What does find({ nickname: null }) return?