Data Types in MongoDB
Every document you store in MongoDB is not plain JSON — it’s BSON (Binary JSON), a binary-encoded format that extends JSON with a richer set of data types. That extra richness is why MongoDB can natively store a precise date, a large integer, or a raw binary blob without resorting to string encoding tricks the way plain JSON has to. Understanding BSON’s type system is essential: it explains why 1 and "1" never match the same query, why dates sort correctly, and why comparing an _id to a string silently returns nothing.
Overview: How BSON Types Work
JSON only has six types: string, number, boolean, null, array, and object. That’s fine for data interchange, but it’s not enough for a database that needs to distinguish a 32-bit counter from a 64-bit counter from a floating-point price, or store an exact timestamp instead of a string that merely looks like one. BSON solves this by tagging every value in the binary-encoded document with a one-byte type code before the value itself. When mongosh (or your driver) reads a document back, it reconstructs each field as its proper JavaScript type — a real Date object, a real ObjectId instance, and so on — not just a string that happens to look right.
This matters for three reasons. First, storage and comparison: BSON types have a well-defined sort order (MongoDB documentation calls this the “BSON comparison order”), so mixed-type fields still sort predictably — MinKey, then Null, then Numbers, then String, Object, Array, BinData, ObjectId, Boolean, Date, Timestamp, Regex, MaxKey. Second, precision: a JSON number is always a double under the hood in most languages, which is unsafe for money; BSON gives you 32-bit integers, 64-bit integers, doubles, and an exact Decimal128 type. Third, querying: operators like $type let you filter documents by their exact BSON type, which is invaluable once a collection has been written to by more than one code path and field types have drifted.
Because MongoDB collections are schemaless by default, nothing stops two documents in the same collection from storing the same field name with two different types — one document might have price: 19.99 (a double) and another price: "19.99" (a string) if the application code was inconsistent. This flexibility is a genuine feature for evolving applications, but it is not a license for carelessness: production applications should still enforce a consistent shape, either in application code or with schema validation rules, precisely so that type drift like this doesn’t happen by accident.
The Core BSON Types
| Type | mongosh example | Notes |
|---|---|---|
| String | "Wireless Mouse" |
Always UTF-8. No separate “char” type. |
32-bit Integer (int) |
NumberInt(42) |
mongosh usually stores small whole numbers written as literals as doubles unless you wrap them. |
64-bit Integer (long) |
NumberLong("9007199254740993") |
Use for integers beyond JavaScript’s safe integer range (2^53). |
| Double | 19.99 |
The default numeric type for decimal literals typed in mongosh. |
| Decimal128 | NumberDecimal("19.99") |
Exact base-10 decimal — use for currency, never Double. |
| Boolean | true / false |
|
| Date | new Date() |
Stored as milliseconds since the Unix epoch, UTC. |
| Null | null |
Distinct from a missing field — see Common Mistakes. |
| ObjectId | ObjectId() |
12-byte identifier; default type for _id. |
| Array | ["red", "blue"] |
Can hold mixed types, including nested arrays and documents. |
| Embedded Document (Object) | { street: "5th Ave", city: "NYC" } |
A document nested as a field value. |
| Binary Data | BinData(0, "...") |
Raw bytes — images, hashes, encrypted blobs. |
| Regular Expression | /^abc/i |
Usable directly inside queries for pattern matching. |
| Timestamp | Timestamp() |
Internal type used by MongoDB’s replication oplog — not for application dates. |
Syntax: Checking Types with $type
The $type query operator matches documents where a field is a specific BSON type. It accepts either the numeric type code or the string alias.
db.collection.find({ field: { $type: "alias-or-number" } });
- field — the field whose BSON type you want to check.
- alias-or-number — a string alias (
"string","int","long","double","decimal","bool","date","objectId","array","object","null","regex","binData") or its numeric code (e.g.2for string,16for int32,18for int64,1for double). - You can also pass an array of aliases, e.g.
{ $type: ["double", "int", "long"] }, to match any of several numeric types at once.
Examples
Example 1: Inserting a document with mixed BSON types
db.products.insertOne({
name: "Wireless Mouse",
price: NumberDecimal("24.99"),
inStock: true,
quantity: NumberInt(150),
tags: ["electronics", "accessories"],
dimensions: { widthCm: 6, heightCm: 11 },
releasedAt: new Date("2025-03-01"),
discountCode: null
});
{
acknowledged: true,
insertedId: ObjectId("66f1a2b3c4d5e6f7a8b9c0d1")
}
Notice how many distinct BSON types appear in one document: a Decimal128 price (exact, safe for money), a boolean, a 32-bit integer quantity, a string array, an embedded document for dimensions, a real Date, and an explicit null. MongoDB stores each with its own type tag — nothing here is silently converted to a string.
Example 2: Filtering by BSON type
db.products.find({ price: { $type: "decimal" } }, { name: 1, price: 1, _id: 0 });
[ { name: "Wireless Mouse", price: NumberDecimal("24.99") } ]
This query only returns documents where price was stored as a Decimal128. If a buggy import script had inserted some products with price as a string, this query would exclude them — which is exactly how you’d audit a collection for type drift after a migration.
Example 3: Why floating point is dangerous for money
db.orders.insertOne({ item: "Widget", unitPrice: 0.1, quantity: 3 });
const order = db.orders.findOne({ item: "Widget" });
print(order.unitPrice * order.quantity);
0.30000000000000004
This is a classic IEEE-754 double-precision rounding error, and it exists in every language, not just MongoDB — but because BSON gives you a real fix (Decimal128), there’s no excuse to let it leak into a database that will be summed and reconciled for years. Rewriting unitPrice as NumberDecimal("0.1") avoids the error entirely, because Decimal128 represents decimal fractions exactly instead of approximating them in binary.
How It Works Step by Step
When you run insertOne(), the mongosh driver serializes your JavaScript object into BSON before sending it over the wire. Each field is written as: a type byte, the field name (a null-terminated C string), and then the value encoded according to that type’s binary layout (a double is 8 bytes, a boolean is 1 byte, a string is length-prefixed UTF-8, and so on). The server stores that binary document as-is on disk (subject to its own compression). When you later run find(), the server deserializes each matching document back into BSON for transmission, and your driver deserializes it a second time into native JavaScript objects — which is why a stored Date comes back as an actual JavaScript Date instance you can call .getFullYear() on, and a stored ObjectId comes back as an ObjectId instance with real methods like .getTimestamp(), not just a hex string.
Type-sensitive operators like $type, comparison operators ($gt, $lt), and sorts all operate directly on these type tags and binary values — a query for { price: { $gt: 10 } } only considers documents where price is a BSON numeric type (int, long, double, or decimal); a string "15" is a different BSON type and, per BSON’s comparison ordering, strings sort entirely after numbers, so it will never satisfy a numeric $gt.
Common Mistakes
Mistake 1: Comparing ObjectId to a plain string
A URL param or JSON request body always arrives as a string, even when it represents an _id. Querying with the raw string silently returns nothing, because a BSON string and a BSON ObjectId are different types and never compare equal.
// Wrong: idFromUrl is the string "66f1a2b3c4d5e6f7a8b9c0d1", not an ObjectId
const idFromUrl = "66f1a2b3c4d5e6f7a8b9c0d1";
db.products.findOne({ _id: idFromUrl }); // returns null
// Correct: convert to ObjectId first
const { ObjectId } = require("mongodb");
db.products.findOne({ _id: new ObjectId(idFromUrl) });
Mistake 2: Storing dates as strings
Writing releasedAt: "2025-03-01" instead of a real Date feels harmless until you try to query a range or sort chronologically — string comparison happens to work for ISO-formatted dates in a lucky ASCII-ordering coincidence, but it breaks the moment any document uses a different date format, and you lose access to date-specific aggregation operators like $year or $dateDiff.
// Wrong: string date, brittle and not a real Date type
db.products.insertOne({ name: "Keyboard", releasedAt: "2025-03-01" });
// Correct: real BSON Date
db.products.insertOne({ name: "Keyboard", releasedAt: new Date("2025-03-01") });
Mistake 3: Confusing null with a missing field
A field explicitly set to null and a field that was never written to a document are different things internally, but a plain equality query treats them the same way by default, which surprises many beginners. { discountCode: null } matches both documents where discountCode is literally null and documents where the field doesn’t exist at all. If you need to distinguish them, use $exists: { discountCode: { $eq: null, $exists: true } } matches only documents that explicitly store null.
Best Practices
- Use
Decimal128(NumberDecimal(...)) for any monetary value — neverDouble, which cannot represent most decimal fractions exactly. - Always store dates as BSON
Date, never as strings, so range queries, sorts, and date aggregation operators work correctly. - Convert string IDs to
ObjectIdbefore querying by_id, especially when the ID came from a URL, form input, or JSON API body. - Use schema validation (
$jsonSchema) on collections where type consistency matters, so the schema-flexible nature of MongoDB doesn’t turn into silent type drift. - Run
db.collection.find({ field: { $type: "..." } })periodically on collections that have been touched by multiple scripts or app versions, to catch type drift early. - Distinguish
nullfrom “field doesn’t exist” with$existswhen the difference matters to your application logic. - Use
NumberLongfor IDs or counters that may exceed JavaScript’s safe integer range (2^53 – 1), since plain numeric literals in mongosh default to doubles.
Practice Exercises
- Insert three documents into a new
db.inventorycollection where thequantityfield is, respectively, aDouble, aNumberInt, and a string like"10". Then write a query using$typethat returns only the documents wherequantityis stored as a string, to see how type drift shows up. - Insert a document with an
_idyou note down, then write a query that attempts to find it by passing the ID as a plain string. Confirm it returns nothing, then fix the query usingnew ObjectId(...). - Insert two orders with the same numeric
totalvalue, one as aDouble(19.99) and one as aDecimal128(NumberDecimal("19.99")). Query for{ total: 19.99 }and observe which document(s) match — then explain why in terms of BSON type comparison.
Summary
- BSON extends JSON’s six types with many more, including
ObjectId,Date,Decimal128, 32/64-bit integers, and binary data — each tagged with its own type byte in the stored document. - The
$typeoperator lets you query documents by their exact BSON type, which is essential for auditing type consistency in a schema-flexible database. - Use
Decimal128for money and realDateobjects for timestamps — never approximate either withDoubleor strings. ObjectIdand string are different BSON types and never compare equal; always convert string IDs withnew ObjectId(...)before querying.- An explicit
nulland a missing field are different internally but match the same way in a plain equality query — use$existswhen the distinction matters. - Schema flexibility is a feature, not an excuse for carelessness — enforce type discipline in application code or with
$jsonSchemavalidation.
