The _id Field and ObjectId
Every document in a MongoDB collection has a field called _id that uniquely identifies it, the same way a primary key uniquely identifies a row in a SQL table. Unlike most SQL primary keys, though, MongoDB doesn’t require you to supply one or configure an auto-increment sequence: if you leave _id out, MongoDB generates a special 12-byte value called an ObjectId for you. Understanding how ObjectId is built, how to query by it, and where it trips people up is essential to working with MongoDB correctly.
Overview / How it works
The _id field is mandatory and, within a collection, must be unique — MongoDB automatically creates a unique index on _id the moment a collection is created, and this index cannot be dropped. If you insert a document without an _id, the driver or mongosh generates one client-side using the ObjectId type before the document is sent to the server. If you supply your own _id (a string, a number, even a nested document), MongoDB uses that instead, as long as it’s unique in the collection.
An ObjectId is not a random string — it’s a structured 12-byte BSON value, conventionally displayed as a 24-character hexadecimal string. The 12 bytes break down as:
- 4 bytes: a Unix timestamp (seconds since the epoch) representing the
ObjectId‘s creation time. - 5 bytes: a random value generated once per process, making collisions across different machines and processes extremely unlikely.
- 3 bytes: an incrementing counter, initialized to a random value, that increments for every
ObjectIdcreated by that process.
Because the first 4 bytes are a timestamp, ObjectId values are roughly sortable by creation time, and you can extract that timestamp without storing a separate createdAt field (though many teams still add one explicitly for clarity and because it survives re-insertion or migration, when the original ObjectId might not).
This matters for BSON in general: BSON is the binary format MongoDB actually stores documents in, and it has richer types than JSON — ObjectId, Date, Decimal128, Int32/Int64, and binary data all exist in BSON with no equivalent in plain JSON. When mongosh prints a document, it renders these as JS-like constructors (ObjectId('...'), ISODate('...')) so you can see the real type, not just a string that looks like one.
Syntax
ObjectId() // generate a new ObjectId
ObjectId("<24-hex-string>") // wrap an existing hex string as an ObjectId
| Form | Description |
|---|---|
ObjectId() |
Creates a brand-new ObjectId using the current time, the process’s random value, and the next counter value. |
ObjectId("66b1f2a1c8d4e5f6a7b8c9d0") |
Wraps an existing 24-character hex string as a real ObjectId instance, rather than leaving it as a plain string. |
.getTimestamp() |
Instance method that returns the embedded creation time as a JS Date. |
.toString() / .toHexString() |
Returns the 24-character hex string representation. |
.equals(otherId) |
Compares two ObjectId values for equality (safer than ===, which compares object references). |
In the Node.js driver and Mongoose, the same type is available as ObjectId (imported from mongodb or mongoose.Types.ObjectId), and there you must use new ObjectId(idString) — unlike mongosh, plain Node.js has no special shell wrapper that lets you call it without new.
Examples
Example 1: Auto-generated _id on insert.
db.users.insertOne({ name: "Ava Chen", email: "ava@example.com" });
{
acknowledged: true,
insertedId: ObjectId('66b1f2a1c8d4e5f6a7b8c9d0')
}
No _id was supplied, so mongosh generated an ObjectId client-side and sent it along with the document. The insertedId returned is exactly the value now stored as _id on that document.
Example 2: Querying by _id.
db.users.findOne({ _id: ObjectId("66b1f2a1c8d4e5f6a7b8c9d0") });
{
_id: ObjectId('66b1f2a1c8d4e5f6a7b8c9d0'),
name: 'Ava Chen',
email: 'ava@example.com'
}
Because the unique index on _id always exists, this is an indexed point lookup — MongoDB doesn’t scan the collection, it goes straight to the matching document via the index, regardless of collection size.
Example 3: Extracting the creation timestamp.
const id = ObjectId("66b1f2a1c8d4e5f6a7b8c9d0");
id.getTimestamp();
ISODate('2024-08-05T14:22:57.000Z')
The timestamp is decoded straight from the first 4 bytes. This is handy for debugging or for a rough “created around this time” view without a dedicated date field, but note the resolution is only 1 second and it reflects when the id was generated, not necessarily when it was durably written.
Example 4: Supplying your own _id.
db.products.insertOne({ _id: "SKU-1042", name: "Wireless Mouse", price: 24.99 });
{ acknowledged: true, insertedId: 'SKU-1042' }
MongoDB happily accepts a string (or number, or any BSON-comparable type) as _id instead of generating an ObjectId, as long as it’s unique in the collection. This is useful when you already have a natural key, like a SKU or an external system’s id, and don’t want a second lookup field.
How it works step by step
- The client (mongosh or a driver) builds the document to insert. If
_idis missing, the client library generates anObjectIdlocally — the server never has to invent it, which is whyinsertedIdis known immediately, before the round trip completes. - The document, now including
_id, is sent to the server as part of an insert command. - The server enforces the unique index on
_id. If a document with that exact_idalready exists in the collection, the insert fails with a duplicate key error (error code 11000) rather than overwriting anything. - On a query like
findOne({ _id: ... }), the query planner doesn’t need to choose between a collection scan and an index scan the way it would for an arbitrary field — the_idindex is always present and always used for equality lookups on_id. - When comparing
ObjectIdvalues, MongoDB compares the full 12 bytes; twoObjectIds built from the same hex string are equal even though they’re different JS object instances, which is why you compare BSON values, not JS references.
Common Mistakes
Mistake 1: Comparing an ObjectId field to a plain string. A very common bug happens when an id arrives as a string, for example from a URL parameter or a JSON request body, and gets used directly in a query.
// req.params.id is the string "66b1f2a1c8d4e5f6a7b8c9d0"
db.users.findOne({ _id: req.params.id });
// Returns null -- no match, even though the document exists
This fails silently: no error, just an empty result, because the stored _id is BSON type ObjectId and the query value is BSON type string — they never compare equal. The fix is to convert the string into a real ObjectId before querying:
const { ObjectId } = require("mongodb");
db.users.findOne({ _id: new ObjectId(req.params.id) });
Mistake 2: Trying to change an existing document’s _id.
db.users.updateOne(
{ email: "ava@example.com" },
{ $set: { _id: ObjectId() } }
);
// MongoServerError: Performing an update on the path '_id' would modify
// the immutable field '_id'
_id is immutable once a document exists; MongoDB rejects any update that tries to change it. If you genuinely need a different id, you must delete the old document and insert a new one with the desired _id (and update anything that referenced the old id).
Best Practices
- Let MongoDB auto-generate
ObjectIdunless you have a real natural key (like a SKU or username) — it’s free uniqueness with no coordination needed across clients. - Always wrap string ids in
new ObjectId(idString)(Node.js driver) before querying by_id; validate the string is a 24-character hex string first so a malformed id throws a clear error instead of a confusing driver exception. - Don’t rely on
ObjectId‘s embedded timestamp as your only “created at” record if you ever plan to migrate, re-insert, or bulk-load documents with preserved history — store an explicitcreatedAtdate field for that. - Use
.equals(), not===, when comparing twoObjectIdvalues in application code, since===compares object identity, not the underlying bytes. - If you choose a custom
_id, make sure it’s genuinely unique and stable for the life of the document — remember it can never be changed later without a delete-and-reinsert.
Practice Exercises
- Insert three documents into a new
db.orderscollection without specifying_id, then runfind()and note that theObjectIdvalues sort in roughly the same order you inserted them — explain why, based on what’s encoded in the first 4 bytes. - Take the string
"66b1f2a1c8d4e5f6a7b8c9d0", wrap it inObjectId()in mongosh, and call.getTimestamp()on it. What date do you get, and what would happen if you trieddb.orders.findOne({ _id: "66b1f2a1c8d4e5f6a7b8c9d0" })(without wrapping) against a real inserted document? - Design a
db.productscollection that uses a custom string_idbased on SKU instead of anObjectId. Insert two products, then attempt to insert a third with a duplicate SKU as_idand observe the error MongoDB returns.
Summary
- Every document requires a unique
_id, backed by an always-present unique index that can’t be dropped. - If you don’t supply
_id, the client generates a 12-byteObjectId: a 4-byte timestamp, a 5-byte per-process random value, and a 3-byte incrementing counter. ObjectIdvalues are roughly time-sortable and expose their creation time via.getTimestamp().- You can supply any unique BSON-comparable value as
_idinstead, such as a string SKU, but it becomes immutable once inserted. - Comparing a stored
ObjectIdto a plain string always fails to match — always convert withnew ObjectId(idString)first.
