Filter concepts by levelShowing all levels.

MongoDB · Section 2

BSON and Data Types

Level
beginner
Read
26 min
Concepts
7

The full BSON type list, how BSON differs from JSON, what document size and serialization cost, the four number types and when precision actually matters, and the structure behind the default ObjectId.

What is true here

  1. BSON adds types JSON cannot express — date, objectId, binary, regex, and four distinct number types.
  2. BSON is binary and is what MongoDB actually stores and transmits; JSON-like text is a display rendering.
  3. double is binary floating point and can round; decimal128 is exact base-10 — use it for money.
  4. A BSON document is capped at 16 MB; per-field name overhead adds up at scale.
  5. ObjectId is 12 bytes — timestamp, random value, counter — unique without central coordination.

What you will be able to do

  • Name the BSON types that have no JSON equivalent, and say why each exists
  • Explain what BSON actually is versus the JSON-like text a shell displays
  • Choose the right number type for a value — including when decimal128 is required
  • Explain why a BSON date carries no timezone, and where timezone conversion belongs
  • Describe ObjectId's three parts and why that structure suits a distributed system

The type system

The full BSON type list, how it differs from JSON, and what document size and serialization actually cost.

BSON types

corebeginner

BSON has more types than JSON: alongside string, boolean, null, object and array, it has distinct number types (double, int32, int64, decimal128), a real date type, ObjectId, binary data, regex, and a couple of rarely used legacy types.

Think of it as

JSON has one number type; BSON has four, because a database has to store what a number actually is — a 32-bit counter, a 64-bit ID, an exact decimal for money — not just "a number" the way a text format can get away with.

json
{
  "text": "value",           // string
  "count": NumberInt(1),     // int32
  "big": NumberLong(1),      // int64
  "price": NumberDecimal("1.00"),  // decimal128
  "when": ISODate("2026-01-01")    // date
}

What we're doing: Show why storing money as a plain double instead of Decimal128 loses precision.

money-precision.txttext
// Stored as double (binary floating point):
0.1 + 0.2  ->  0.30000000000000004   // not exactly 0.3

// Stored as Decimal128 (exact base-10 decimal):
NumberDecimal("0.1") + NumberDecimal("0.2")  ->  NumberDecimal("0.3")   // exact
2
double is binary floating point — it cannot represent every base-10 fraction exactly, the same limitation every language's float type has.
5
decimal128 stores an exact base-10 value, so this addition has no rounding error at all.

Why this works: A double is fine for measurements and scores where a tiny rounding error is harmless. Money is exactly the case where it is not — small errors compound across many transactions, which is what decimal128 exists to prevent.

Storing every number as whatever the driver defaults to

Wrong

text
db.products.insertOne({ price: 19.99 })  // most drivers default this literal to a double

Better

text
db.products.insertOne({ price: NumberDecimal("19.99") })  // explicit decimal128 for money

What you see: Prices computed by summing many documents drift by fractions of a cent over time, and exact-equality comparisons on money fields intermittently fail.

Why: A driver's default numeric type is chosen for convenience, not correctness for every field — money specifically needs the exact type, decimal128, chosen deliberately rather than left to the default.

JSON's one number type vs. BSON's four

JSON

  • +One generic "number"
  • +No fixed width
  • +No exact decimal type

BSON

  • double, int32, int64, decimal128
  • Each has a fixed, known width
  • decimal128 is exact for money
  • JSON
    • One generic "number"
    • No fixed width
    • No exact decimal type
  • BSON
    • double, int32, int64, decimal128
    • Each has a fixed, known width
    • decimal128 is exact for money

The BSON type list

The BSON type list
TypeExampleJSON has it?
string"Dune"yes
booleantrueyes
nullnullyes
object{ "k": "v" }yes
array[1, 2, 3]yes
double3.14no — JSON has one generic number
int32NumberInt(42)no
int64NumberLong(42)no
decimal128NumberDecimal("9.99")no
dateISODate("2026-01-01")no
timestampinternal, replication useno
objectIdObjectId("65f1...")no
binaryBinData(0, "...")no
regex/^abc/no

Together

json
{
  "_id": ObjectId("65f1a2b3c4d5e6f7a8b9c0d1"),
  "price": NumberDecimal("19.99"),
  "views": NumberLong(4200000000),
  "createdAt": ISODate("2026-01-01T00:00:00Z"),
  "tags": ["sci-fi", "classic"]
}

Remember: BSON has more types than JSON: double/int32/int64/decimal128 instead of one number, plus date, objectId, binary, regex. Use decimal128 for money.

See also: bson vs json · integer types and precision · decimal128

BSON vs. JSON

corebeginner

JSON is human-readable text; BSON is the binary format MongoDB actually stores and transmits. BSON is a superset — richer types, but not readable in a text editor without a tool that decodes it.

Think of it as

JSON is the sentence you'd read aloud; BSON is the sentence written in a faster, denser shorthand a machine parses without re-tokenizing text. The shorthand also has words JSON's spoken form has no way to say — dates, exact decimals, binary bytes.

text
// mongosh and drivers render BSON as JSON-like text — this is a display convenience,
// not the on-disk or on-wire representation.

What we're doing: Show a value that displays fine as JSON-like text but loses information through a real JSON round-trip.

bson-round-trip.txttext
// Displayed (JSON-like text):
{ "createdAt": ISODate("2026-01-01T00:00:00Z") }

// Round-tripped through JSON.stringify/JSON.parse in application code:
{ "createdAt": "2026-01-01T00:00:00.000Z" }   // now a plain string, not a BSON date
2
mongosh displays this as an ISODate(...) call, but what is actually stored is a typed BSON date value.
5
JSON has no date type, so JSON.stringify serializes it as a string — the type information ("this is a date") is gone unless something restores it.

Why this works: The JSON-like text shown by a shell or logged by an application is a convenient rendering, not a faithful export — any code path that actually parses that text as plain JSON, rather than talking to MongoDB's driver, drops every BSON-only type back into whatever JSON can represent.

Logging a document as JSON.stringify(doc) and treating that as a full record

Wrong

text
console.log(JSON.stringify(doc))  // ObjectId and Date fields become plain strings, decimal128 may become a string or lose precision

Better

text
Use the driver's own EJSON (Extended JSON) serializer, which round-trips BSON types faithfully, or log the typed values directly.

What you see: A logged or exported document, when parsed back, no longer has real ObjectId/Date/Decimal128 values — just strings that happen to look like them.

Why: Plain JSON.stringify has no way to represent BSON-only types, so it silently downgrades them to their closest JSON equivalent — EJSON exists specifically to close that gap.

What you type vs. what is stored

JSON-like text

what you write/read

BSON

binary, what's stored

on disk / wire

transmitted this way

  1. JSON-like text — what you write/read
  2. BSON — binary, what's stored
  3. on disk / wire — transmitted this way

BSON vs. JSON

BSON vs. JSON
PropertyJSONBSON
Formattextbinary
Human-readableyes, directlyno — needs a decoder
Traversalmust scan/tokenize textlength-prefixed, can skip fields
Type richnessstring/number/boolean/null/object/arrayadds date, objectId, binary, decimal128, regex, ...
Where it's used hereshell/driver display, this app's code sampleswhat MongoDB actually stores and sends

Together

json
// What you type / read (JSON-like text, for humans):
{ "_id": ObjectId("65f1..."), "price": NumberDecimal("19.99") }

// What is actually stored and sent over the wire: binary BSON —
// not shown here because it is not meant to be read as text.

Remember: BSON is binary and what MongoDB actually stores/sends; JSON-like text is only how it is displayed. BSON has richer types JSON cannot express.

See also: bson types · document oriented

BSON size and serialization

standardintermediate

Every BSON document has a hard 16 MB size limit, and every field carries a small overhead — its name and a type byte are stored per field, not just per document. Both affect how a document should be shaped.

Think of it as

BSON's length-prefixed, field-tagged layout is what makes it fast to skip through, but that same tagging is not free — a document is a sum of per-field overhead plus the actual values, so a design with many small fields or deep repetition pays more of that overhead than one with fewer, larger fields.

text
// No syntax to run — a size/serialization constraint to design around, not an API call.

What counts toward document size

What counts toward document size
ContributorExampleNote
Field names"createdAt" repeated per documentshort field names measurably reduce total size at scale
Scalar values42, "text", trueproportional to content
Embedded documentsnested objectseach nested field adds its own name overhead
Arrayslarge arrays of documentsunbounded arrays are the most common way to hit 16 MB

Together

text
// 16 MB is roughly this much JSON-equivalent text:
16 * 1024 * 1024 bytes ≈ 16,000,000 characters

// A document with an ever-growing "comments" array is a common way
// to approach that limit — see schema design patterns, later.

Remember: A BSON document is capped at 16 MB; field names are stored per document, so short names and bounded arrays measurably matter at scale.

See also: bson vs json · bson types

Advertisement

Numbers, dates and identity

The four number types and when precision matters, how a BSON date represents time, and the structure behind ObjectId.

Integer types and numeric precision

standardintermediate

int32 holds roughly ±2.1 billion, int64 far more, and double trades exactness for range. MongoDB compares numbers by value across these types, but which type a driver picks for a literal is not always obvious.

Think of it as

Think of int32/int64/double/decimal128 as four containers of different sizes and precision — the same number can fit in more than one, but which container a value lands in depends on the driver, the literal syntax used, and any arithmetic already applied to it, not just the number itself.

json
{ "small": NumberInt(1), "big": NumberLong(1), "measured": 1.5, "exact": NumberDecimal("1.50") }

The four number types compared

The four number types compared
TypeRange/precisionBest for
int32±2.1 billion, exactsmall counters, flags
int64much larger, exactIDs, large counters, timestamps in millis
double~15-17 significant digits, binarymeasurements, scores, anything tolerant of rounding
decimal12834 significant decimal digits, exact base-10money, exact decimal arithmetic

Together

text
db.orders.find({ total: 42 })
// matches a document whose "total" is stored as int32, int64, OR double 42
// — MongoDB compares numbers by value, not by stored type

Remember: int32/int64 are exact but range-limited; double is binary and can round; decimal128 is exact base-10. Queries compare by value across types.

See also: bson types · decimal128

Decimal128

standardintermediate

Decimal128 stores an exact base-10 decimal, unlike double, which is binary and cannot represent most decimal fractions exactly. Use it for money and anywhere small rounding errors are unacceptable, even if they seem tiny.

Think of it as

A double stores a number the way a ruler marked in binary fractions would — most decimal values fall between two marks and get rounded to the nearest one. Decimal128 is a ruler marked directly in base-10, so an amount like 19.99 lands exactly on a mark instead of being approximated.

json
{ "price": NumberDecimal("19.99") }

When to reach for each numeric type

When to reach for each numeric type
Use caseTypeWhy
Item price, invoice totaldecimal128exact — no compounding rounding error across many transactions
Sensor reading, average scoredoublesmall rounding error is acceptable, range matters more
Row counter, user IDint32 / int64exact integers, no fractional part needed
Exact scientific/financial constantdecimal128precision matters more than storage cost

Together

text
// double: repeated addition drifts
0.1 + 0.2  ->  0.30000000000000004

// decimal128: exact
NumberDecimal("0.1") + NumberDecimal("0.2")  ->  NumberDecimal("0.3")

Remember: Use decimal128 (NumberDecimal) for money and exact decimals; double is fine where small rounding error is acceptable.

See also: bson types · integer types and precision

BSON Date

standardintermediate

A BSON date stores a single instant in time — milliseconds since the Unix epoch, UTC — with no timezone attached. Any timezone a user sees is applied when the application displays the value, not stored alongside it.

Think of it as

A BSON date is a timestamp on a wall clock set to UTC, nailed to the wall in one place — everyone reads the same instant, and converting it to "9am in Tokyo" or "6pm in New York" is a translation the viewer does afterward, not a different clock.

json
{ "createdAt": ISODate("2026-01-01T00:00:00Z") }

Where a timezone lives

Where a timezone lives
LayerHas a timezone?Responsibility
BSON date valueno — UTC instant onlystore the absolute moment
Application display codeyes, chosen at render timeconvert UTC to the viewer's local time
User input formyes, the user's local timeconvert local input back to UTC before storing

Together

text
// Stored (always UTC):
ISODate("2026-01-01T09:00:00Z")

// Displayed to a user in Tokyo (UTC+9): "2026-01-01 18:00"
// Displayed to a user in New York (UTC-5): "2026-01-01 04:00"
// Same stored value, two different presentations

Remember: BSON date is a UTC instant, no timezone stored. Convert to local time only at display/input, in application code.

See also: bson types · documents and collections

ObjectId structure

corebeginner

An ObjectId is a 12-byte value MongoDB generates as the default _id: a 4-byte timestamp, 5 bytes of random value, then a 3-byte counter — unique without coordination, and roughly sortable by creation time.

Think of it as

An ObjectId is like a timestamped, self-issued ticket number: the first part is "when," the rest is "random and incrementing enough that two tickets issued the same second never collide" — no central ticket office (auto-increment sequence) is needed.

text
ObjectId()                    // generates a new one
ObjectId("65f1...").getTimestamp()   // extracts its creation time

What we're doing: Show why ObjectId needs no central counter, unlike a relational auto-increment primary key.

objectid-uniqueness.txttext
// Two documents inserted from two different application servers,
// in the same second, with no coordination between them:
ObjectId("65f1a2b3c4d5e6f7a8b9c0d1")   // server A
ObjectId("65f1a2b3d1e2f3a4b5c6d7e8")   // server B
// Same leading timestamp bytes, but the random + counter bytes differ
2
A relational auto-increment primary key needs one authority handing out the next number — two independent servers cannot both do this safely without coordination.
3
ObjectId's random and counter bytes make collision astronomically unlikely without any server talking to any other.

Why this works: A distributed system with multiple writers benefits from an identifier scheme that does not need a single source of truth for "the next value" — ObjectId trades a strictly sequential counter for structure that stays unique under concurrent, uncoordinated generation.

Treating ObjectId's embedded timestamp as authoritative for "when this was written to the database"

Wrong

text
Assuming ObjectId("...").getTimestamp() always equals the moment the document was actually inserted.

Better

text
Storing an explicit createdAt: ISODate(...) field when the exact write time matters, and treating the ObjectId timestamp as approximate provenance only.

What you see: A record's "creation time," read from its ObjectId, is off by however long the document sat in an application queue or was generated ahead of the actual insert.

Why: The timestamp is generated when the ObjectId itself is created (often client-side, before the insert is even sent), which is not guaranteed to be the exact instant the write lands in the database.

ObjectId's three parts

12 bytes total

timestamp

4 bytes

random

5 bytes

counter

3 bytes

  • 12 bytes total
    • timestamp — 4 bytes
    • random — 5 bytes
    • counter — 3 bytes

ObjectId's 12 bytes

ObjectId's 12 bytes
BytesHoldsPurpose
0–3Unix timestamp (seconds)when it was generated — makes it roughly sortable
4–8random value, per processuniqueness without central coordination
9–11incrementing counteruniqueness within the same second, same process

Together

text
ObjectId("65f1a2b3c4d5e6f7a8b9c0d1")
//        └──4 bytes──┘└───5 bytes───┘└3 bytes┘
//         timestamp      random       counter

ObjectId("65f1a2b3...").getTimestamp()
// -> the creation instant encoded in the first 4 bytes

Remember: ObjectId is 12 bytes: timestamp + random + counter — unique without coordination. _id defaults to it but is not required to be one.

See also: documents and collections · bson date

Advertisement