MongoDB Mastery
MongoDB Mastery lessons / Module 22: Time Series & Special Collections

Clustered collections

Level 3: Advanced18 min

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.

This lesson is interactive. Run every example against an in-browser MongoDB, see explain() plans drawn as trees, and take the quiz.
Open the interactive lesson
You will learn to
  • 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 _id become a CLUSTERED_IXSCAN over the data itself, with no FETCH stage.
  • Inserts write one table instead of two, and storage drops by the size of the _id index.
  • expireAfterSeconds on the collection deletes documents whose _id is a date older than the cutoff, with no separate TTL index.
  • Secondary indexes store the _id value instead of a small RecordId, so a large _id makes 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.

Create a clustered collection and inspect it
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()
getIndexes shows the _id index with clustered: true. ObjectId values grow over time, so new documents append at the end.
The key must be _id
db.lab_bad.drop()
db.createCollection("lab_bad", { clusteredIndex: { key: { createdAt: 1 }, unique: true } })
A range read on a clustered and a normal collection
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 }
result
The normal collection walks the _id index and fetches each document; the clustered collection scans the documents directly between minRecord and maxRecord.
TTL without a TTL index
db.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].options
Documents whose _id date is older than a day are removed by the TTL monitor. Use a date _id only if one document per instant is guaranteed, or a compound value would be needed instead.

internalsUnder 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

Avoid
db.createCollection("events", { clusteredIndex: { key: { _id: 1 }, unique: true } })
db.events.insertOne({ _id: UUID(), ... })   // random v4 key
Prefer
db.createCollection("events", { clusteredIndex: { key: { _id: 1 }, unique: true } })
db.events.insertOne({ ... })   // default ObjectId, increasing over time

A 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.

Avoid
db.audit.createIndex({ at: 1 }, { expireAfterSeconds: 2592000 })   // on a normal collection
Prefer
db.createCollection("audit", { clusteredIndex: { key: { _id: 1 }, unique: true }, expireAfterSeconds: 2592000 })
// _id holds the event time

When 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
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.stage

InterviewQuestions 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.