Skip to content
ExponaBLOG
MONGODB, NOSQL & DATA MODELING · DAY 064 / 100

Working with ObjectId: a practical MongoDB & NoSQL guide

Understand MongoDB's default ids.

Expona Admin7 min read

Understanding MongoDB's ObjectId

Every document you create in MongoDB gets a unique identifier unless you explicitly provide one. That identifier is an ObjectId—a 12-byte value that becomes the default _id field. Working with ObjectId: a practical MongoDB & NoSQL guide means understanding what those bytes represent, how they behave, and when you might want something different. We'll explore the structure, sortability, validation challenges, and when to reach for alternatives like UUIDs.

The 12-byte structure with an embedded timestamp

An ObjectId isn't random noise. It's a deliberately constructed value where each byte or group of bytes serves a purpose. Understanding this structure helps you debug issues, reason about data, and sometimes extract metadata directly from an ID.

Here's the breakdown of those 12 bytes:

  • Bytes 0–3 (4 bytes): Unix timestamp in seconds. This tells you when the document was created (in UTC).
  • Bytes 4–8 (5 bytes): Random value. Usually derived from machine identifier, process ID, or randomness, ensuring uniqueness across distributed systems.
  • Bytes 9–11 (3 bytes): Counter. Incremented for each ObjectId generated within the same second on the same machine/process.

Let's see this in action. In a Node.js environment with the MongoDB driver, you can create an ObjectId and inspect it:

javascript
const { ObjectId } = require('mongodb');

const id = new ObjectId();
console.log(id.toString());           // e.g., "65a7c23f1a2b3c4d5e6f7g8h"
console.log(id.getTimestamp());       // Date object: when this ID was created
console.log(id.toHexString());        // Same as toString() for ObjectId

That timestamp is useful. If you ever need to know roughly when a document entered your database, you can extract it without querying metadata. This is a lightweight way to add temporal context to your data.

Roughly sortable by creation time

Because the first 4 bytes encode a Unix timestamp, ObjectIds are roughly sortable by creation order. This doesn't mean they're perfectly sequential—the random and counter bytes prevent that—but documents created earlier will have lower ObjectId values than documents created later.

In practice, this means you can often sort by _id ascending and get documents in roughly the order they were created, without maintaining a separate timestamp field or index.

javascript
const results = await collection
  .find({})
  .sort({ _id: 1 })
  .toArray();

// Results are roughly in creation order (oldest first)

This is convenient but imperfect. If you have high concurrency or multiple servers creating documents in the same second, the order becomes fuzzy. For applications where precise ordering matters—like audit logs or transaction sequences—don't rely on ObjectId sorting alone. Create an explicit createdAt timestamp field and index it.

Real-world scenario: you're building a feed feature. You might use ObjectId's natural sortability as a quick win for small datasets, but scale it up and you'll want an indexed createdAt field for predictable performance and correct ordering under load.

Validating format before querying

Here's where ObjectId bites many teams. User input is often a string (from a URL param, request body, or query string). Before you use it as an _id filter, you need to validate that it's a valid ObjectId string. Passing an invalid format can cause unexpected behavior or subtle bugs.

The MongoDB driver provides validation tools:

javascript
const { ObjectId } = require('mongodb');

function getUserById(userId) {
  // Validate before querying
  if (!ObjectId.isValid(userId)) {
    return null; // or throw an error
  }
  
  return collection.findOne({
    _id: new ObjectId(userId)
  });
}

// Usage
const user = await getUserById('65a7c23f1a2b3c4d5e6f7g8h');
const invalidUser = await getUserById('not-an-id'); // Returns null safely

If you skip this validation and pass a malformed string to the ObjectId constructor, it throws an error. In a web server, this can crash a request handler if not caught. More subtly, some drivers or languages silently coerce invalid input to a default value, leading to queries that return wrong results.

Always validate. Make it a habit in any handler or service method that accepts user input as an ID.

In TypeScript or a modern framework, encapsulate this validation in a utility or middleware:

javascript
// Validation helper
const toObjectId = (value) => {
  if (!ObjectId.isValid(value)) {
    throw new Error(`Invalid ObjectId: ${value}`);
  }
  return new ObjectId(value);
};

// In your route handler or controller
app.get('/users/:id', async (req, res) => {
  try {
    const user = await collection.findOne({
      _id: toObjectId(req.params.id)
    });
    res.json(user);
  } catch (err) {
    res.status(400).json({ error: 'Invalid ID format' });
  }
});

Use UUIDs if you need external IDs

ObjectId is optimized for MongoDB's distributed nature and is excellent as an internal database identifier. But in some scenarios, you'll want UUIDs (Universally Unique Identifiers) or other ID schemes instead.

When to prefer UUIDs:

  • You need IDs generated outside MongoDB before inserting documents. Example: your application generates a UUID in a microservice, then passes it to MongoDB.
  • You're migrating from a system that uses UUIDs or other ID schemes, and compatibility matters.
  • You want complete control over ID generation logic (custom formats, domain-driven IDs like usr_12345).
  • You're building an API where client-side ID generation is part of the design.
  • You need your IDs to work seamlessly in multiple databases or systems (polyglot persistence).

Here's how to work with UUIDs in MongoDB:

javascript
const { v4: uuidv4 } = require('uuid');

// Generate a UUID and insert
const doc = {
  _id: uuidv4(),
  name: 'Alice',
  email: 'alice@example.com'
};

await collection.insertOne(doc);

// Query by UUID
const found = await collection.findOne({
  _id: '550e8400-e29b-41d4-a716-446655440000'
});

One trade-off: UUIDs are typically stored as strings (36 characters with hyphens) or binary (16 bytes). As strings, they take up more space than ObjectId's 24-character hex representation. As binary, they're comparable to ObjectId size, but MongoDB's binary type adds overhead in queries. ObjectIds remain more storage-efficient for most use cases.

A middle ground is to store both: use ObjectId as your primary _id, and add an indexed externalId or `uuid` field for external references and API contracts.

javascript
const doc = {
  _id: new ObjectId(),     // MongoDB's native ID
  externalId: uuidv4(),    // Exposed in your API
  name: 'Bob',
  email: 'bob@example.com'
};

await collection.insertOne(doc);

// Create a unique index on externalId for fast lookups
await collection.createIndex({ externalId: 1 }, { unique: true });

// Query by external ID
const found = await collection.findOne({
  externalId: '550e8400-e29b-41d4-a716-446655440000'
});

Common mistakes

  • Forgetting to validate ObjectId format. Always check with ObjectId.isValid() before using user input as an ID. Validation errors in production are silent but destructive.
  • Relying on ObjectId's sortability for critical ordering. Under concurrent writes, ObjectId order becomes unreliable. Use an indexed createdAt field for any feature where order matters.
  • Mixing string and ObjectId types. Be consistent. If _id is an ObjectId, always convert incoming strings to ObjectId before querying. Querying with a string when the field is ObjectId yields no results (silently).
  • Assuming all MongoDB drivers treat ObjectId identically. Language and framework differences exist. Test your ID handling thoroughly, especially when switching drivers or languages.
  • Not indexing external ID fields. If you use a secondary ID (UUID, user handle, etc.) for lookups, create a unique index on it. Otherwise, queries degrade and duplicates can slip in.

Key takeaways

  • ObjectId is a 12-byte value: 4 bytes timestamp, 5 bytes random/machine ID, 3 bytes counter. This structure ensures uniqueness across distributed systems.
  • ObjectIds are roughly sortable by creation time because the timestamp is the first bytes. For precise ordering, use an indexed createdAt field.
  • Always validate ObjectId format before using user input in queries. Use ObjectId.isValid() and handle errors gracefully.
  • Use UUIDs or custom IDs when you need IDs generated externally or when you require compatibility with other systems.
  • Consider a dual-ID strategy: ObjectId for MongoDB's native _id, plus an indexed external ID field for APIs and cross-system references.
  • ObjectId remains the best default for most MongoDB workloads. Change only when you have a specific reason.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles