Ch. 12 · MongoDB

MongoDB ObjectId Explained: What's Inside and Custom _id

What the 12 bytes of an ObjectId contain, why its timestamp is not a reliable clock, and when to choose your own _id in MongoDB.

~6 min readadvancedupdated Oct 7, 2026

Every MongoDB document has an _id, it’s unique within the collection, it’s immutable, and it gets an index automatically. If you don’t supply one, the driver (not the server, in nearly every case) generates an ObjectId. Interviewers ask about ObjectIds because candidates tend to treat them as magic opaque strings, and the details leak into real bugs: lookups that never match, “sort by creation time” that’s subtly wrong, and API ids that reveal more than intended.

Examples use mongosh syntax for MongoDB 7 and 8. No server was available while writing, so outputs are illustrative of documented behaviour rather than pasted from a terminal.

What’s inside the 12 bytes

An ObjectId is 12 bytes, usually shown as 24 hex characters:

65f1a2b3  9c4e7d1a2f  00a1b2
|-------| |---------| |----|
 4 bytes    5 bytes   3 bytes
 seconds    random    counter
Text
  • 4 bytes: seconds since the Unix epoch, big-endian. That’s why ObjectIds created later usually sort after earlier ones.
  • 5 bytes: a random value generated once per process (per client process, since the driver makes the id).
  • 3 bytes: a counter that starts at a random value and increments per id within that process.

Older documentation describes the middle bytes as machine id plus process id. That changed years ago, and any recent driver uses the random value. If an interviewer asks, describe the current layout and mention the old one existed; it shows you’ve read beyond a single blog post.

You can pull the timestamp out:

const id = ObjectId("65f1a2b39c4e7d1a2f00a1b2")
id.getTimestamp()
// ISODate("2024-03-13T12:57:23.000Z")
JavaScript

The 4-byte seconds field 0x65f1a2b3 is 1710334643, which is that date. Note the .000: there are no milliseconds in there.

Is ObjectId order the same as insertion order?

Not exactly, and this is the follow-up most people miss. Sorting by _id gives you approximately creation order, with three caveats:

  1. Second precision. All ids created in the same second share their first 4 bytes. Within that second, order comes from the random bytes and counter. Ids from two different app servers in the same second interleave by their random 5 bytes, not by which was created first.
  2. Client clocks. The timestamp comes from whatever machine created the id. A server with a clock running 40 seconds behind produces ids that sort before ids made earlier on a correct clock.
  3. Created is not committed. A driver can create an id, then wait on a slow network, and insert after documents created later.

So sort({ _id: 1 }) is fine for “roughly oldest first”, and it’s a sensible tie-breaker for pagination because it’s unique. It’s not fine for anything that needs a correct event sequence, such as an audit log or financial ledger. For those, store an explicit timestamp from a controlled source (or a sequence number), and still add _id as a tie-breaker.

The string-versus-ObjectId bug

This is the most common real-world ObjectId bug, and it’s worth explaining in an interview because it looks so innocent:

db.users.insertOne({ _id: ObjectId("65f1a2b39c4e7d1a2f00a1b2"), name: "Ana" })

db.users.findOne({ _id: "65f1a2b39c4e7d1a2f00a1b2" })
// null

db.users.findOne({ _id: ObjectId("65f1a2b39c4e7d1a2f00a1b2") })
// { _id: ObjectId("65f1a2b39c4e7d1a2f00a1b2"), name: "Ana" }
JavaScript

BSON is typed. A string and an ObjectId are different types, so equality never matches across them. The id arrives at your API as a string in a URL, someone forgets to convert it, and the query returns nothing with no error. Worse, in a reference field you can end up with a mix: some orders store userId as ObjectId and others as a string, and a $lookup silently joins only half of them.

The fixes are boring and effective: convert at the API boundary (and reject strings that aren’t 24 hex characters with a 400, rather than letting new ObjectId(input) throw a 500), and add a schema validator that pins the type of reference fields with bsonType: "objectId".

Choosing your own _id

You don’t have to use an ObjectId. _id can be any BSON type except an array (and regular expressions are a bad idea). Reasons to pick your own:

  • A natural key exists and never changes. An ISO country code, an external order number from a payment provider, or a sku. Using it as _id gives you uniqueness and the lookup index for free, and saves a second unique index.
  • Idempotent inserts. If a message from a queue carries an id, inserting with that as _id means a redelivered message hits a duplicate key error (E11000) instead of creating a second document. You catch that error and treat it as “already processed”.
  • Compound identity. _id: { tenant: "acme", invoice: 1042 } works, though field order inside the embedded document matters for equality, so be careful to build it the same way everywhere.

Reasons to avoid it: _id is immutable, so a “natural” key that later turns out to change (an email address, a username) forces you to delete and reinsert the document. Emails change. Usernames get renamed. Keep those as unique secondary fields.

UUIDs are another option, stored as BSON binary subtype 4 (UUID("...") in mongosh). Random UUIDv4 values scatter inserts across the _id index, which hurts cache locality on very write-heavy collections; time-ordered UUIDv7 avoids that while staying globally unique. Storing a UUID as a 36-character string works but wastes space and invites the same type mismatch bug described above.

Sharding and exposure concerns

Two more points for senior roles.

First, ObjectIds increase over time, so they’re a poor ranged shard key: every new insert lands at the high end, on one chunk and one shard. A hashed shard key on _id spreads them evenly, at the cost of making range queries on _id scatter to every shard.

Second, ObjectIds leak information. Anyone with an id knows when the record was created, to the second. Sequential-looking ids also hint at volume if a counter is visible. Nobody can guess another user’s ObjectId just from the structure (the random bytes plus counter make enumeration impractical across processes), but ids are not secrets and shouldn’t be used as access tokens. Authorization checks must still run on every request.

Interview practice

A team stores events with default ObjectIds and builds an “activity feed” by sorting { _id: -1 }. Users report that events from two web servers sometimes appear out of order, and occasionally an event appears “before” something it was caused by. What’s happening, and what would you change?

Answer

The feed relies on ObjectId order meaning creation order, and that’s only approximate. Each web server’s driver generates ids from its own clock, with second precision. Events created in the same second on different servers sort by the random 5 bytes, which is effectively arbitrary. If one server’s clock is a few seconds slow, its ids sort earlier than events that happened before them on another server, which explains the cause-after-effect reports.

I’d add an explicit createdAt field. Ideally it’s set from a single trusted clock, for example with $$NOW in an update pipeline so the database server sets it, or from the upstream event source if it already has an ordering. Then sort on { createdAt: -1, _id: -1 }, with _id only as a unique tie-breaker for stable pagination, and index exactly that. If true causal ordering matters (one event caused another), a per-user sequence number incremented atomically is the stronger fix, because wall clocks can’t guarantee causality at all. I’d also mention that the feed’s pagination should use the (createdAt, _id) pair as a cursor rather than skip.

Further reading

More in MongoDB

read ✓MongoDB · mid

MongoDB Bulk Writes

Batch a nightly upsert job with bulkWrite, pick ordered or unordered mode, and read writeErrors so partial failures don't get lost or retried.

~6 min readread →
read ✓MongoDB · mid

MongoDB Capped Collections

Size a capped collection for a recent-errors feed, follow it with a tailable cursor, and see why TTL indexes suit time-based retention better.

~6 min readread →
read ✓MongoDB · hard

MongoDB Change Streams and Resume Tokens

Build an order-to-search sync on change streams, save resume tokens after processing, and recover when the oplog has rolled past you.

~6 min readread →
esc