MongoDB – Documents and BSON Types

October 15, 20246 min readUpdated 8/24/2026

MongoDB stores documents, and a document is a set of field-value pairs where the values are typed. That type system is BSON, and it is not quite JSON. Knowing where the two differ explains a whole family of bugs that otherwise look like the database losing your data.

BSON is JSON with types that survive

JSON has six types: string, number, boolean, null, object, array. BSON has around twenty. The extra ones exist because JSON’s are not enough to round-trip a real record:

  • ObjectId — a 12-byte identifier, the default for _id.
  • Date — a real instant, not a formatted string.
  • Int32 / Int64 / Double / Decimal128 — JSON has one “number”; BSON distinguishes them, and Decimal128 is the one to use for money.
  • Binary — bytes, without base64 in the middle.
  • Timestamp — an internal type for the oplog. Not the one you want for your own dates.

You can see the difference in the shell, which prints BSON types explicitly:

db.samples.insertOne({ a: 1, b: 1.5, c: NumberLong("9007199254740993"), d: new Date() })
db.samples.findOne()
// { _id: ObjectId('...'), a: 1, b: 1.5, c: Long('9007199254740993'), d: ISODate('2025-10-15T...') }

a came back as an integer, b as a double, c as a 64-bit long that a JavaScript number could not have held precisely, and d as a date you can compare and sort. Store that date as the string "2025-10-15" instead and every one of those operations becomes a string comparison — which happens to work for ISO dates and breaks the moment anyone writes "15/10/2025".

Money is the type that catches people

Doubles cannot represent most decimal fractions exactly, and that is a property of binary floating point rather than anything MongoDB does:

db.money.insertOne({ price: 0.1 + 0.2 })
db.money.findOne().price          // 0.30000000000000004

db.money.insertOne({ price: NumberDecimal("0.1") })
db.money.aggregate([{ $group: { _id: null, t: { $sum: "$price" } } }])
// Decimal128("0.1") — exact

Use Decimal128 for currency. Or do what many teams do and store integer minor units — cents as an Int64 — which sidesteps the question entirely.

_id, and why it is an ObjectId

Every document has an _id. It is unique within the collection, immutable, and indexed automatically — you cannot drop that index. If you do not supply one, the driver generates an ObjectId.

An ObjectId is twelve bytes: a 4-byte timestamp, a 5-byte random value per process, and a 3-byte counter. Two useful consequences follow. It can be generated on the client without asking the server, so inserts need no round trip to allocate a key. And because the timestamp leads, ObjectIds sort roughly by creation time:

const id = db.reels.findOne()._id
id.getTimestamp()                 // ISODate — when the document was created

db.reels.find().sort({ _id: -1 }).limit(5)   // roughly newest-first, no extra index

“Roughly” is doing work in that sentence. The ordering is only as good as the clocks on the machines that generated the ids, so it is fine for a debugging glance and wrong for anything a user sees. ReelCMS sorts its feed on an explicit publishedAt field for exactly that reason — and because the date a reel was created is not the date it went live.

Reaching into a document

Nested fields are addressed with dots, and the whole path goes in quotes because a dotted name is not a valid JavaScript identifier:

db.reels.find({ "video.durationSeconds": { $lt: 20 } })
db.reels.find({ "creator.username": "pitchside" })

Arrays behave in a way that surprises people the first time, and then turns out to be exactly what you wanted. A query against an array field matches if any element matches — there is no special syntax for it:

db.reels.find({ tags: "motorsport" })     // matches ["motorsport", "overtake"]
db.reels.find({ tags: ["motorsport"] })   // matches ONLY the exact array ["motorsport"]

The first is what you almost always mean. The second is an equality test against the whole array, order included. Dot notation reaches array elements by position too, which is occasionally useful and usually a sign the field should have been an object:

db.reels.find({ "tags.0": "motorsport" })   // first tag only

Two rules on field names are worth knowing before you design anything. A field name cannot contain a dot or start with a dollar sign in older server versions — modern MongoDB permits both, but drivers and aggregation expressions still treat them as special, so a key like "user.email" is a problem you are choosing to have. And field names are stored in every single document, so short names genuinely save space at scale. That is a real trade, not a micro-optimisation: at a billion documents the difference between createdAt and ca is gigabytes. At a million it is noise, and readable names win.

The trap: a string that looks like an ObjectId

This is the single most expensive type mistake in MongoDB, and it never raises an error.

An ObjectId and its 24-character hex string print almost identically. They are different BSON types, and MongoDB compares types strictly — so a query, or worse a join, that mixes them matches nothing at all:

const reel = db.reels.findOne()

db.view_events.countDocuments({ "metadata.reelId": reel._id })             // 1649 for this reel
db.view_events.countDocuments({ "metadata.reelId": reel._id.toString() })  // 0 — for every reel

Same field, same value, different BSON type. No error, no warning, just zero. In an aggregation the same mismatch is worse: a $lookup whose local and foreign fields differ in type joins nothing, the $unwind after it drops every row, and your report comes back as an empty array.

Which is why Java has to say so explicitly

In Java you naturally declare an id as a String, because that is what your API and your URLs use. Spring Data stores a plain String field as a BSON string — while the _id it points at is an ObjectId. That is the mismatch above, arriving through the mapping layer.

The fix is to tell the mapper what to store, keeping the Java type convenient and the BSON type correct. This is from ReelCMS’s view-event metadata, the two fields its reports join on:

public static class ViewMetadata {

    @Field(targetType = FieldType.OBJECT_ID)
    private String reelId;

    @Field(targetType = FieldType.OBJECT_ID)
    private String creatorId;

    private String country;
    private String device;
}

Without those two annotations every aggregation in that application returns an empty array, and nothing anywhere explains why. With them, the fields stay String in Java and land as ObjectId in BSON, and the joins match.

Checking types when a query returns nothing

$type queries by BSON type, which makes it the fastest way to confirm a hunch:

db.view_events.countDocuments({ "metadata.reelId": { $type: "string" } })    // 0
db.view_events.countDocuments({ "metadata.reelId": { $type: "objectId" } })  // 30187

db.reels.aggregate([{ $group: { _id: { $type: "$stats.views" }, n: { $sum: 1 } } }])
// one row per type present — anything but a single row is drift

That last one is worth keeping. Run it against any field you rely on and a second row in the output means two different types are living in the collection, which is a bug you would otherwise find through a wrong total.

The short version

  • BSON is JSON plus the types JSON lacks — dates, several number widths, binary, ObjectId.
  • Store dates as dates and money as Decimal128 or integer minor units.
  • _id is unique, immutable and always indexed; ObjectIds are client-generated and sort roughly by time.
  • An ObjectId is not its hex string. Mixing them matches nothing, silently.
  • In Spring Data, an id field declared String needs @Field(targetType = FieldType.OBJECT_ID) or every join on it fails.
  • Group by $type to find type drift before it becomes a wrong number.