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
Decimal128is 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") — exactUse 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 onlyTwo 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 reelSame 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 driftThat 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
Decimal128or integer minor units. _idis 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
Stringneeds@Field(targetType = FieldType.OBJECT_ID)or every join on it fails. - Group by
$typeto find type drift before it becomes a wrong number.