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. 2 for string, 16 for int32, 18 for int64, 1 for 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 — never Double, 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 ObjectId before 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 null from “field doesn’t exist” with $exists when the difference matters to your application logic.
  • Use NumberLong for 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.inventory collection where the quantity field is, respectively, a Double, a NumberInt, and a string like "10". Then write a query using $type that returns only the documents where quantity is stored as a string, to see how type drift shows up.
  • Insert a document with an _id you 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 using new ObjectId(...).
  • Insert two orders with the same numeric total value, one as a Double (19.99) and one as a Decimal128 (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 $type operator lets you query documents by their exact BSON type, which is essential for auditing type consistency in a schema-flexible database.
  • Use Decimal128 for money and real Date objects for timestamps — never approximate either with Double or strings.
  • ObjectId and string are different BSON types and never compare equal; always convert string IDs with new ObjectId(...) before querying.
  • An explicit null and a missing field are different internally but match the same way in a plain equality query — use $exists when the distinction matters.
  • Schema flexibility is a feature, not an excuse for carelessness — enforce type discipline in application code or with $jsonSchema validation.