Skip to content
Monghoul

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.

· ·updated 13 September 2026 ·checked against MongoDB all supported versions
Three squares in a row. The first has one outline around it, the second three, the third five with the outermost drawn as a broken dashed line and the innermost filled amber.
Each wrapper carries type information the one outside it can no longer see. The outermost is plain JSON, where the type is simply gone.

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.