Clustered collections
A clustered collection stores documents in _id order inside the _id index itself. Range reads on _id get faster, there is no separate _id index to maintain, and TTL can delete by _id without another index. Learn when that helps and when it hurts.
- Create a clustered collection and recognise it in collection info, indexes and explain
- Explain the storage difference from a normal collection
- Use expireAfterSeconds on a clustered collection
- Judge when large or random _id values make clustering a bad fit
conceptThe concept
In a normal collection, documents live in a WiredTiger table keyed by an internal RecordId, and the _id index is a separate table that maps _id to RecordId. Every insert writes both, and a range query on _id walks the index and then fetches each document.
A clustered collection (5.3+) uses _id as the table's key: documents are stored in _id order and there is no separate _id index. It is created with db.createCollection(name, { clusteredIndex: { key: { _id: 1 }, unique: true } }); the key must be { _id: 1 } and the option cannot be added to an existing collection.
- Range queries on
_idbecome aCLUSTERED_IXSCANover the data itself, with no FETCH stage. - Inserts write one table instead of two, and storage drops by the size of the
_idindex. expireAfterSecondson the collection deletes documents whose_idis a date older than the cutoff, with no separate TTL index.- Secondary indexes store the
_idvalue instead of a small RecordId, so a large_idmakes every secondary index bigger.
Time series collections already use clustered storage for their buckets; clustered collections expose the same idea for ordinary documents.
whyWhy it matters
Append-heavy collections that are read by time ranges, such as audit trails, event logs and ledgers, pay for an _id index they barely need and for random fetches on every range read. Clustering removes both costs with one option at creation time, which is why it is worth knowing before the collection exists.
Use caseReal-world scenario
A payments service writes an audit record for every state change, with an ObjectId _id (time-ordered), and reads "everything between 10:00 and 10:05" during investigations. As a clustered collection, inserts write one table, the range read scans contiguous data, and expireAfterSeconds is not used because audit data must be kept; retention is handled by archiving.
Try itExamples
Run and edit these in the interactive lesson.
db.lab_audit.drop()
db.createCollection("lab_audit", { clusteredIndex: { key: { _id: 1 }, unique: true } })
db.lab_audit.insertMany([{ action: "login" }, { action: "update" }, { action: "logout" }])
db.getCollectionInfos({ name: "lab_audit" })[0].options
db.lab_audit.getIndexes()db.lab_bad.drop()
db.createCollection("lab_bad", { clusteredIndex: { key: { createdAt: 1 }, unique: true } })db.lab_c.drop()
db.lab_n.drop()
db.createCollection("lab_c", { clusteredIndex: { key: { _id: 1 }, unique: true } })
const docs = []
for (let i = 1; i <= 200; i++) docs.push({ _id: i, v: i * 2 })
Object.keys(db.lab_c.insertMany(docs).insertedIds).length
Object.keys(db.lab_n.insertMany(docs).insertedIds).length
const stagesOf = p => { const s = []; for (let n = p; n; n = n.inputStage) s.push(n.stage); return s.join(" > ") }
const clustered = stagesOf(db.lab_c.find({ _id: { $gte: 50, $lt: 60 } }).explain().queryPlanner.winningPlan)
const normal = stagesOf(db.lab_n.find({ _id: { $gte: 50, $lt: 60 } }).explain().queryPlanner.winningPlan)
const result = { clustered: clustered, normal: normal }
resultdb.lab_sessions.drop()
db.createCollection("lab_sessions", { clusteredIndex: { key: { _id: 1 }, unique: true }, expireAfterSeconds: 86400 })
db.lab_sessions.insertOne({ _id: new Date(), user: "asha" })
db.getCollectionInfos({ name: "lab_sessions" })[0].optionsinternalsUnder the hood
The clustered key is the WiredTiger table key, so documents with neighbouring _id values sit in neighbouring pages. Monotonically increasing keys (ObjectId, timestamps) append at the right edge and keep pages full. Random keys such as UUIDv4 insert across the whole table, splitting pages and spreading the working set, which is the same problem a random key causes in any B-tree. Keep _id small: it is copied into every secondary index entry.
Avoid / preferDo this, not that
db.createCollection("events", { clusteredIndex: { key: { _id: 1 }, unique: true } })
db.events.insertOne({ _id: UUID(), ... }) // random v4 keydb.createCollection("events", { clusteredIndex: { key: { _id: 1 }, unique: true } })
db.events.insertOne({ ... }) // default ObjectId, increasing over timeA random key turns every insert into a write to a random page. An increasing key appends at the end of the table, which is what clustering is good at.
db.audit.createIndex({ at: 1 }, { expireAfterSeconds: 2592000 }) // on a normal collectiondb.createCollection("audit", { clusteredIndex: { key: { _id: 1 }, unique: true }, expireAfterSeconds: 2592000 })
// _id holds the event timeWhen the expiry time can be the _id, a clustered collection deletes old ranges without maintaining a second index.
PitfallsCommon mistakes
- Clustering on a random UUIDv4 _id, so every insert lands on a different page.
- Using a large _id (long strings, embedded documents) with many secondary indexes; every index entry carries the full _id.
- Planning to convert an existing collection. clusteredIndex can only be set at creation; migrate with $out or $merge into a new collection.
- Expecting clustering to speed up queries on other fields. It only changes how _id is stored.
LimitsWhen not to use it
Skip clustering when the _id is random or large, when most reads use other fields, or for small collections where the _id index costs nothing noticeable. Use a time series collection instead for high-volume measurements with a time field and a source identifier.
SecuritySecurity notes
Clustering does not change access control, but a date or sequential _id reveals when a record was created and how many exist. Do not expose such ids in public URLs if that information is sensitive; use a separate random public identifier.
ChallengeTry it yourself
Create a clustered collection lab_clicks, insert documents with integer _id 1 to 50, and return the winning plan's stage for { _id: { $gte: 10, $lt: 20 } }.
Show one solution
db.lab_clicks.drop()
db.createCollection("lab_clicks", { clusteredIndex: { key: { _id: 1 }, unique: true } })
const rows = []
for (let i = 1; i <= 50; i++) rows.push({ _id: i, page: "/p/" + i })
Object.keys(db.lab_clicks.insertMany(rows).insertedIds).length
db.lab_clicks.find({ _id: { $gte: 10, $lt: 20 } }).explain().queryPlanner.winningPlan.stageInterviewQuestions you should be able to answer
What is a clustered collection and when would you use one?
A collection whose documents are stored in the order of _id inside the _id B-tree, with no separate _id index (5.3+). Use it for append-heavy data with an increasing _id that is read by _id ranges or point lookups: audit logs, events, ledgers. Benefits are fewer writes per insert, less storage, efficient range scans and TTL by _id without an index. Avoid it for random or large _id values and when most queries use other fields, because secondary indexes then carry the larger key.
How does a clustered collection differ from a time series collection?
Both use clustered storage, but a time series collection groups measurements into internal bucket documents, compresses them, and rewrites queries on the user-facing view; it is optimised for many small measurements with a timeField and metaField. A clustered collection stores ordinary documents one per key with no bucketing, supports normal updates and unique _id semantics, and suits records that are read individually or by _id range.
QuizCheck your understanding
5 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.