UUID Primary Keys in MongoDB: Do You Even Need Them?
MongoDB requires every document to have a unique _id field that serves as the primary key. By default the server (or driver) generates a 12-byte ObjectId. Many teams replace it with a UUID, usually for portability, client-side generation, or to avoid MongoDB-specific types. The question is whether that substitution is worth the cost.
This post examines the storage-engine mechanics, quantifies the performance differences, and provides clear guidance on when a UUID is justified and how to use one correctly if you must.
How _id and Indexes Work in WiredTiger
MongoDB’s default storage engine, WiredTiger, stores collection data in a B-tree (or columnar for some workloads) and maintains a separate unique B-tree index on _id. The index maps the _id value to an internal RecordId that points to the document.
Because the _id index is mandatory and unique, every insert performs an index insertion. The shape of the key therefore determines:
- How often leaf pages split
- The average fill factor of those pages
- How much of the index fits in the WiredTiger cache
- The amount of write amplification and journal traffic
ObjectId Anatomy
An ObjectId is 12 bytes:
- 4 bytes – Unix timestamp (seconds)
- 5 bytes – random value (machine + process entropy in modern drivers)
- 3 bytes – incrementing counter
The leading timestamp makes successive ObjectIds roughly monotonic. Inserts therefore tend to hit the right-hand side of the B-tree, producing high fill factors and good cache locality.
UUID Characteristics
A standard UUID is 16 bytes. When stored as a BSON Binary subtype 4 it occupies 16 bytes of key data plus a small type tag. Stored as a string it balloons to 36 bytes of UTF-8 plus overhead—almost never acceptable.
Random UUIDv4 values are uniformly distributed. Each insert lands at a random leaf, causing frequent page splits, lower fill factors, and a larger working set that is harder to keep in cache.
Measured Impact
Independent benchmarks (MongoDB 6.x, modern drivers) consistently show the following pattern:
| Scenario | ObjectId | Binary UUIDv4 | Relative Cost of UUID |
|---|---|---|---|
| 1 M batched inserts (empty collection) | Baseline | 50–55 % slower | Significant |
| 10 M inserts into collection already holding 10 M docs | Baseline | ~2× slower | Severe |
| Index size at 20 M documents | ~192 MiB | ~750 MiB | ~4× larger |
Point lookup by _id (warm cache) |
~150 µs | ~165–170 µs | Modest |
| Bulk insert throughput at scale | Highest | 2–3.5× lower | High |
Time-ordered alternatives (ULID, UUIDv7) close most of the gap and produce index sizes closer to ObjectId.
Secondary indexes are not inflated by the size of _id in WiredTiger (they store RecordIds, not the primary key value). The penalty is confined to the mandatory _id index itself, the document size, and any application-level references that also store the identifier.
Sharding Considerations
ObjectId’s monotonicity is a double-edged sword under sharding:
- Range sharding on
_idconcentrates recent inserts onto the chunk that owns the highest key values → write hotspot. - Hashed sharding on
_iddistributes writes evenly but destroys the locality benefits of the ordered index.
A pure random UUID distributes writes naturally but still pays the random-index penalty on every shard. A time-ordered UUID or a carefully chosen compound shard key is usually superior.
When a UUID Makes Sense
Use a UUID (preferably time-ordered) only when one or more of the following is true:
- Identifiers must be generated by clients, edge devices, or multiple independent services before the document reaches MongoDB.
- The same identifier must be shared across multiple databases or message buses that do not understand ObjectId.
- You need an opaque, non-guessable public identifier and do not want to expose ObjectId timestamps.
- You are building a polyglot or multi-tenant system where MongoDB is only one of several stores.
In all other cases ObjectId remains the better default: smaller, faster, natively supported, and already globally unique for practical purposes.
Correct Ways to Store a UUID
Never store a UUID as a string.
// Bad – 36-byte string
{ _id: "550e8400-e29b-41d4-a716-446655440000" }
// Good – BSON Binary subtype 4 (16 bytes)
{ _id: UUID("550e8400-e29b-41d4-a716-446655440000") }
In the Node.js driver:
const { Binary } = require('mongodb');
const { v7: uuidv7 } = require('uuid');
const id = uuidv7();
const binaryId = new Binary(Buffer.from(id.replace(/-/g, ''), 'hex'), 4);
await collection.insertOne({ _id: binaryId, ...doc });
In C# with the official driver:
using MongoDB.Bson;
using MongoDB.Driver;
var id = Guid.NewGuid(); // or a UUIDv7 library
var doc = new BsonDocument {
{ "_id", new BsonBinaryData(id, GuidRepresentation.Standard) },
// ...
};
await collection.InsertOneAsync(doc);
Practical Alternatives
| Approach | Size | Index Locality | Client Generation | Cross-system Portability | Recommendation |
|---|---|---|---|---|---|
| Default ObjectId | 12 B | Excellent | Driver/server | MongoDB-centric | Default choice |
| Binary UUIDv7 / ULID | 16 B | Excellent | Yes | High | Best UUID option |
| Binary UUIDv4 | 16 B | Poor | Yes | High | Avoid for large collections |
| String UUID | 36 B | Poor | Yes | High | Never |
Hybrid (ObjectId _id + public UUID) |
12 B + 16 B | Excellent | Yes | High | Often ideal |
| Natural key (email, external ID) | Varies | Depends | Yes | High | When truly immutable |
The hybrid pattern is frequently the cleanest compromise:
{
_id: ObjectId(), // internal, optimal index
publicId: Binary(...), // UUIDv7 exposed to clients and other systems
// ...
}
You keep MongoDB’s best index behavior while still offering a portable identifier.
Recommendations
- Prefer the default
ObjectIdunless you have a concrete requirement that it cannot satisfy. - If you need a UUID, use a time-ordered variant (UUIDv7 or ULID) and store it as BSON Binary subtype 4.
- Never store UUIDs as strings.
- For public APIs consider the hybrid pattern: ObjectId as
_id, UUIDv7 as a separate unique field. - When sharding, avoid a pure monotonic shard key; use hashed sharding or a high-cardinality compound key that includes a random or hashed component.
- Measure index size (
db.collection.stats().indexSizes) and insert latency at your expected scale before committing to a custom_idstrategy.
Conclusion
You rarely need a UUID as the MongoDB _id. The native ObjectId is smaller, produces denser indexes, inserts faster at scale, and already provides distributed uniqueness. Random UUIDv4 values re-introduce the classic random-key fragmentation problems that B-trees suffer from, while offering little advantage inside a pure MongoDB workload.
When portability or client-side generation is mandatory, reach for a time-ordered UUID stored in binary form, or adopt the hybrid ObjectId-plus-public-UUID pattern. In the majority of applications the simplest answer remains the best: let MongoDB generate the ObjectId and move on.
Tools and Versions
Need some UUIDs for testing? Try our tools for generating
UUIDv4
UUIDv7
ULID
NanoID
and
decoding
validating.
For a detailed comparison of UUID versions, visit our Complete Guide to UUID Versions.