Skip to main content

Inside a MongoDB ObjectId: 12 Bytes, One Hidden Clock

A practical breakdown of the MongoDB ObjectId — its 12-byte layout, how to decode the embedded creation time, and why _id values sort roughly by time.

Published By Li Lei
#mongodb #objectid #database #developer-tools

Inside a MongoDB ObjectId: 12 Bytes, One Hidden Clock

Every document you insert into MongoDB without specifying an _id gets a 12-byte ObjectId, printed as a 24-character hex string like 65f1a2b3c4d5e6f708192a3b. Most developers treat it as an opaque blob — a random-looking key they copy, paste, and never think about. That is a missed opportunity. The leading 4 bytes of every ObjectId are a plain Unix timestamp, which means your primary key already knows when each document was created. No createdAt column required.

This post walks through the byte layout, decodes a real ObjectId by hand, and explains why ObjectIds sort roughly by insertion time. If you want to skip the arithmetic, the MongoDB ObjectId generator and decoder does all of it in your browser.

The 12-byte layout

An ObjectId is exactly 12 bytes — 96 bits — and the layout has been stable for years. Reading left to right:

  • Bytes 0–3 (4 bytes): a Unix timestamp in whole seconds, stored big-endian. This is the concrete point worth memorizing: the first four bytes are a 32-bit second count, the same number Date.now() / 1000 would give you, floored.
  • Bytes 4–8 (5 bytes): a random value generated once per process when the driver starts up. It separates ids minted by different machines, containers, or worker processes.
  • Bytes 9–11 (3 bytes): a counter that increments by one for each new id within a process. It starts at a random offset and wraps around after 0xFFFFFF.

So in 65f1a2b3c4d5e6f708192a3b, the slice 65f1a2b3 is the timestamp, c4d5e6f708 is the random field, and 192a3b is the counter. Three jobs, three byte ranges, one fixed-width key.

Older drivers (pre-3.4) used a slightly different split — 3 bytes of machine id plus 2 bytes of process id where the 5 random bytes now sit. Modern drivers collapsed both into a single random field, which is simpler and reduces collision risk across short-lived containers. Either way, the timestamp bytes never moved.

Decoding the timestamp by hand

The timestamp lives in the first 8 hex characters, and pulling it out is genuinely a one-liner you can do on paper.

Take 65f1a2b3c4d5e6f708192a3b. Grab the first 8 characters: 65f1a2b3. Read that as a big-endian hexadecimal integer:

0x65f1a2b3 = 1710334643

That 1710334643 is a Unix timestamp in seconds. Convert it to a date:

1710334643 → 2024-03-13 12:57:23 UTC

So the document carrying that _id was inserted at 12:57:23 UTC on March 13, 2024. In JavaScript the round trip is just:

const id = "65f1a2b3c4d5e6f708192a3b";
const seconds = parseInt(id.slice(0, 8), 16);  // 1710334643
const created = new Date(seconds * 1000);       // 2024-03-13T12:57:23.000Z

One caveat that trips people up: the resolution is one second, not one millisecond. The timestamp field only holds whole seconds, so two documents inserted 100 ms apart can share the same ObjectId second. If you need sub-second ordering, you read the counter, not the clock — or you store a real millisecond timestamp alongside the key. If you want a key format that bakes a millisecond timestamp into the id itself, look at ULID or UUIDv7, both of which trade ObjectId's second resolution for millisecond ordering.

Why ObjectIds sort roughly by time

Because the timestamp occupies the most significant bytes, ObjectIds compare like big numbers with the clock at the front. Sort a collection by _id ascending and you get documents in roughly the order they were inserted — for free, no secondary index.

"Roughly" is doing real work in that sentence. Within a single process, the per-second counter guarantees strict ascending order even when many ids land in the same second. Across processes or machines, two ids created in the same second can interleave however their random fields fall, because the random bytes sit between the timestamp and the counter. So _id ordering is reliable down to one-second granularity globally, and exact within one process. For most dashboards, "most recent documents first" via sort({ _id: -1 }) is good enough and costs nothing.

This is also why the ObjectId is genuinely useful where a random UUID is not. A v4 UUID is 128 bits of pure randomness with no readable time and no natural sort order; inserting v4 UUIDs as primary keys scatters writes across a B-tree index and tells you nothing about when a row appeared. An ObjectId gives you both a creation timestamp and a near-monotonic insertion order baked into the same 12 bytes.

Querying a time range with ObjectId bounds

The sortable-by-time property pays off in a neat trick: you can query a time window using only _id, without a createdAt field or a separate index.

To pull every document created during a given second, build the smallest possible ObjectId for that second (timestamp bytes set, random and counter bytes all zero) and the largest (random and counter bytes all FF), then query between them:

// All documents from 2024-03-13 12:57:23 UTC onward
db.events.find({
  _id: {
    $gte: ObjectId("65f1a2b30000000000000000"),
    $lte: ObjectId("65f1a2b3ffffffffffffffff")
  }
})

The low bound 65f1a2b30000000000000000 zeroes the trailing 8 bytes; the high bound maxes them out. Because _id is already indexed, this range scan is fast and needs no extra createdAt index. Widen the bounds to cover a whole day or week by computing the start-of-window and end-of-window seconds. The common mistake here is hand-building a bound with leftover random bytes in the tail — 65f1a2b3c4d5e6f708192a3b as a $gte would silently skip earlier documents in that same second. Always zero the low bound and saturate the high bound. If you just need to convert a Unix second to a human date while building those windows, the Unix timestamp converter handles that direction.

How I actually use this

I reach for ObjectId decoding most often when I am staring at a production document that has no audit fields. Last month I was chasing a duplicate-record bug and the only thing I had was two _id values from a support ticket. Pasting both into the decoder told me they were inserted 0.4 seconds apart — same timestamp second, adjacent counters — which immediately told me a retry had fired twice against the same request, not that two separate users had hit the endpoint. No log diving, no schema archaeology. The clock was sitting in the key the whole time, and reading it took ten seconds. That is the quiet superpower of ObjectId: the metadata you wish you had logged is already there, you just have to know which four bytes to look at.

Wrapping up

The MongoDB ObjectId is not a random blob. It is a 12-byte structure: a 4-byte second-resolution timestamp, 5 random bytes, and a 3-byte counter. Those first four bytes mean every _id carries its own creation time, sorts roughly by insertion order, and supports cheap time-range queries against an index you already have. Once you can read the leading 8 hex characters as a Unix timestamp, an ObjectId stops being opaque and starts being one of the more useful default keys a database hands you.

Try decoding a few of your own ids with the MongoDB ObjectId generator and decoder — paste a real one and watch the embedded second fall out.


Made by Toolora · Updated 2026-06-13