MongoDB ObjectId → timestamp

Every ObjectId carries its creation time in its first four bytes. Paste one to extract it — or generate one from a date for _id range queries.

What is inside an ObjectId?

A BSON ObjectId is twelve bytes, written as twenty-four hexadecimal characters. Since MongoDB 3.4 those bytes are laid out as:

Bytes Hex chars Meaning
0–3 1–8 Big-endian Unix timestamp, in seconds
4–8 9–18 Random value, generated once per process
9–11 19–24 Counter, incremented per document and seeded randomly

That is why extracting a creation date needs no driver and no database round-trip: parseInt(id.slice(0, 8), 16) is the whole algorithm. Older MongoDB releases split the middle bytes into a machine identifier and a process id; the timestamp bytes have never moved, so extraction works on documents of any vintage. It is also why the decoder above is client-side: the hex you paste is parsed in this browser, with no upload and no server round trip, which is worth knowing when the _id came out of a production collection.

Second precision, and what that costs you

The timestamp is seconds, not milliseconds. Two documents written in the same second carry the same four leading bytes, and their relative order then depends on the counter — which is per-process. So ObjectIds are roughly sortable by time, and exactly sortable only across different seconds. If you need true insertion order at sub-second resolution, store an explicit createdAt field.

The range-query recipe

Because the timestamp bytes lead, an ObjectId with zeroed random and counter bytes sorts exactly at the start of its second. That makes it a perfect boundary value for querying by creation date without adding an index on a separate field:

// Everything created on or after 2026-08-15 12:00:00 UTC
db.events.find({ _id: { $gte: ObjectId("6a8054c00000000000000000") } })

// A half-open window: [start, end)
db.events.find({
  _id: {
    $gte: ObjectId("6a8054c00000000000000000"),
    $lt:  ObjectId("6a8062d00000000000000000")
  }
})

The caveat: use these values only as $gte/$lt boundaries. A zeroed ObjectId is not a document that exists, so equality matches against it will never hit anything, and $lte on a boundary silently excludes every document written during that second.

Frequently asked questions

How do I get the creation date out of a MongoDB ObjectId?

Read the first four bytes. A BSON ObjectId is twelve bytes written as twenty-four hex characters, and bytes 0-3 — hex characters 1 through 8 — are a big-endian Unix timestamp in seconds, so parseInt(id.slice(0, 8), 16) is the whole algorithm: no driver and no database round-trip. Older MongoDB releases split the middle bytes differently, but the timestamp bytes have never moved, so this works on documents of any vintage.

Are MongoDB ObjectIds sortable by time?

Across seconds, yes. Within a single second, no guarantee — the trailing bytes are a per-process counter, so two application servers inserting concurrently can produce ids whose byte order does not match their true insertion order. If you need true insertion order at sub-second resolution, store an explicit createdAt field.

How do I query MongoDB documents by creation date using _id?

Because the timestamp bytes lead, an ObjectId with zeroed random and counter bytes sorts exactly at the start of its second, which makes it a boundary value — the recipe above. Use such values only as $gte/$lt boundaries: a zeroed ObjectId is not a document that exists, so equality matches never hit.

Can I tell which timezone a document was created in?

No. The stored value is a UTC instant with no zone attached. The local rendering above is your browser's zone, not the writer's.

What about UUIDv7?

UUIDv7 embeds a 48-bit millisecond timestamp in its leading bytes for the same reason — sortable identifiers with a readable creation time. If you are choosing an id format today and want millisecond ordering, it is the closer fit; ObjectId's advantage is that MongoDB already generates one for free on every insert. Discord and Twitter snowflake IDs use the same trick again, packing milliseconds since a custom 2015 epoch into their top 42 bits.

Why does my ObjectId decode to 1970 or 2106?

Those are the edges of what four bytes can express. A value that far out usually means the string is not an ObjectId at all — a truncated hash, or an id from another system. The tool reports what the bytes say rather than correcting them. If a stray 1970 is your actual symptom, the epoch-zero debugger covers the usual causes.