Extended JSON, and where $date and $oid quietly change your data
JSON has no date type and no 64-bit integer. Extended JSON has both, in two modes, and relaxed mode loses less than people think and more than they check.
BSON has types JSON does not. Dates, 64-bit integers, decimals, binary data, ObjectIds. So exporting a MongoDB document to JSON is not a formatting change, it is a conversion, and some of it does not come back.
Extended JSON is the format that solves this, and most of the trouble people have with it comes from not knowing there are two versions of it with different guarantees.
The two modes
Take one document:
{
_id: ObjectId('66f0a1c2e4b0a1c2e4b0a1c2'),
createdAt: ISODate('2026-09-07T10:30:00Z'),
views: NumberLong('9007199254740993'),
price: NumberDecimal('19.99')
}
Canonical Extended JSON keeps every type, explicitly:
{
"_id": { "$oid": "66f0a1c2e4b0a1c2e4b0a1c2" },
"createdAt": { "$date": { "$numberLong": "1788777000000" } },
"views": { "$numberLong": "9007199254740993" },
"price": { "$numberDecimal": "19.99" }
}
Relaxed Extended JSON prefers readability, and unwraps the types it can represent as plain JSON numbers:
{
"_id": { "$oid": "66f0a1c2e4b0a1c2e4b0a1c2" },
"createdAt": { "$date": "2026-09-07T10:30:00Z" },
"views": 9007199254740993,
"price": { "$numberDecimal": "19.99" }
}
Two things to read carefully there.
views is now a JSON number, and JSON numbers are IEEE 754 doubles. 9007199254740993 cannot be
represented exactly as a double: it comes back as 9007199254740992. The value changed, nothing
errored, and the difference is one.
price is still wrapped, and that is the part people get wrong about relaxed mode. It unwraps
doubles, 32-bit and 64-bit integers, and in-range dates. It does not unwrap Decimal128, precisely
because there is no JSON number that can hold it without loss. If you chose NumberDecimal for
money, relaxed mode does not quietly undo that.
So relaxed mode is lossy for integers, not for everything. The rule worth carrying is narrower than “relaxed loses types”: it loses precision on 64-bit integers, and it turns dates into strings that no longer say they are dates.
Plain JSON is lossier still
Strip the wrappers entirely and you get something that looks clean and has thrown away the types:
{
"_id": "66f0a1c2e4b0a1c2e4b0a1c2",
"createdAt": "2026-09-07T10:30:00Z",
"views": 9007199254740992,
"price": 19.99
}
price has lost its decimal type here, which relaxed mode did not do. This is the mode where the
money argument actually applies: as a double, 19.99 is not exactly 19.99, and a few thousand of
them added up no longer match the ledger.
And _id is now a string. Feed that back into MongoDB and you get a document whose _id is the
24-character string rather than an ObjectId, which will not match anything, will not join to
anything, and will look correct in every listing you print.
This is the single most common version of the problem: a document exported for a fixture, edited by
hand, imported back, and the _id is now a string. Everything downstream misses and nothing
complains.
createdAt has the same fate. It is a string that reads like a date, sorts like a string, and fails
any date operator you point at it.
Which one to use
The rule is short:
- Moving data between MongoDB systems: canonical. Backups, fixtures, anything that will be imported again. Correctness beats readability, and you are not the reader.
- Sending data to something that is not MongoDB: relaxed, and check the number columns. An API response consumed by a browser has to be plain JSON anyway, so decide deliberately what the precision loss means there.
- Reading a document to understand it: relaxed. That is what it is for.
The failure mode to watch for is using relaxed as an interchange format because it looked tidier in the terminal.
Two things that follow from this
An _id in a query has to be an ObjectId, not a string. This is the same problem arriving from
the other direction:
db.orders.findOne({ _id: '66f0a1c2e4b0a1c2e4b0a1c2' }); // matches nothing
db.orders.findOne({ _id: ObjectId('66f0a1c2e4b0a1c2e4b0a1c2') }); // matches
The first returns null rather than erroring, because a string _id is perfectly legal, it just is
not this document’s _id.
Dates are stored as UTC milliseconds and have no time zone. $date carries an instant, and the
offset you see in relaxed mode is a rendering. Grouping by day with $dateTrunc and no timezone
argument buckets by UTC boundaries, which is not where your users’ days start.
Checking a document before you trust it
Paste it into the Extended JSON converter. It converts between canonical, relaxed and plain, and it flags where the plain conversion is lossy rather than doing it silently, which is the part that matters. It runs in the browser and nothing you paste is uploaded.
If you only want to know whether an _id is what you think it is, the
ObjectId decoder takes one apart into its embedded timestamp, random value and
counter, which also answers “when was this document created” for a collection with no createdAt.