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 ObjectId created 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

  1. The client (mongosh or a driver) builds the document to insert. If _id is missing, the client library generates an ObjectId locally — the server never has to invent it, which is why insertedId is known immediately, before the round trip completes.
  2. The document, now including _id, is sent to the server as part of an insert command.
  3. The server enforces the unique index on _id. If a document with that exact _id already exists in the collection, the insert fails with a duplicate key error (error code 11000) rather than overwriting anything.
  4. 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 _id index is always present and always used for equality lookups on _id.
  5. When comparing ObjectId values, MongoDB compares the full 12 bytes; two ObjectIds 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 ObjectId unless 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 explicit createdAt date field for that.
  • Use .equals(), not ===, when comparing two ObjectId values 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.orders collection without specifying _id, then run find() and note that the ObjectId values 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 in ObjectId() in mongosh, and call .getTimestamp() on it. What date do you get, and what would happen if you tried db.orders.findOne({ _id: "66b1f2a1c8d4e5f6a7b8c9d0" }) (without wrapping) against a real inserted document?
  • Design a db.products collection that uses a custom string _id based on SKU instead of an ObjectId. Insert two products, then attempt to insert a third with a duplicate SKU as _id and 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-byte ObjectId: a 4-byte timestamp, a 5-byte per-process random value, and a 3-byte incrementing counter.
  • ObjectId values are roughly time-sortable and expose their creation time via .getTimestamp().
  • You can supply any unique BSON-comparable value as _id instead, such as a string SKU, but it becomes immutable once inserted.
  • Comparing a stored ObjectId to a plain string always fails to match — always convert with new ObjectId(idString) first.