Monitoring MongoDB Performance
Every production MongoDB deployment eventually hits a slow query, a runaway operation, or a server using more memory than expected. Monitoring is how you catch these problems before your users do, and diagnose them quickly once they happen. MongoDB ships with several built-in tools — command-line utilities, server introspection commands, and a query profiler — that together give you a complete picture of throughput, latency, memory usage, and index effectiveness.
Overview / How it works
MongoDB monitoring covers four broad areas: throughput (operations per second), latency (how long individual operations take), resource usage (memory, disk I/O, connections), and index effectiveness (whether queries use an index or fall back to a full collection scan).
The server tracks cumulative counters in db.serverStatus() — things like opcounters (inserts, queries, updates, deletes, commands since the process started), connections, and mem (resident and virtual memory). These are running totals, not instantaneous rates, which matters for how you read them (see Common Mistakes below).
Underneath every read and write sits the WiredTiger storage engine, which keeps a cache of frequently used data pages in memory. By default this cache is sized to 50% of (RAM minus 1 GB), with a 256 MB floor. As long as your working set — the data and indexes actively touched by queries — fits in that cache, reads are fast. Once it doesn’t, WiredTiger starts evicting pages and pulling from disk more often, and latency climbs even though nothing in your query changed. Watching mem.resident against total system RAM is an early warning sign of this pressure.
For any individual query, the query planner looks at the available indexes, generates candidate plans, and picks a winning plan based on which one does the least work (fewest documents examined) during a short trial period. You can see exactly which plan won, and how efficient it actually was, with explain(). If no usable index exists, the plan is a COLLSCAN — a full walk of every document in the collection. If an index is used, the plan is an IXSCAN followed by a FETCH to pull the full document for each matching index key.
For a live view of what’s running right now, db.currentOp() queries an in-memory table the server maintains of all active operations, including how long each has been running (secs_running). You can pair it with db.killOp(opid) to terminate a runaway operation — safe to do because a single document write is already atomic, so there’s no partial write to clean up.
Finally, the database profiler logs individual operations (with their exact timing and query shape) to a capped collection called system.profile, and the $indexStats aggregation stage reports per-index usage counters since the last restart — both are essential for finding slow queries and unused indexes after the fact, not just in the moment.
Syntax
The table below summarizes the main monitoring tools and when to reach for each one.
| Tool | What it shows | Typical use |
|---|---|---|
mongostat |
Live ops/sec, memory, connections, replication lag (terminal) | Quick health check from the command line |
mongotop |
Time spent reading/writing per collection (terminal) | Find which collection is hot right now |
db.serverStatus() |
Full server metrics snapshot: opcounters, connections, mem, wiredTiger cache | Scripted polling, dashboards |
db.currentOp() |
Currently running operations and their duration | Find and kill a long-running or blocked operation |
cursor.explain(verbosity) |
Winning query plan and execution stats for one query | Diagnose a slow query, confirm index usage |
db.setProfilingLevel() / system.profile |
Log of slow (or all) operations, persisted | Find slow queries after the fact |
$indexStats aggregation stage |
Per-index access counters since last restart | Find unused indexes worth dropping |
General form for the two most common commands:
db.<collection>.find(<query>).explain(<verbosity>);
// verbosity: "queryPlanner" (default), "executionStats", or "allPlansExecution"
db.setProfilingLevel(<level>, { slowms: <ms>, sampleRate: <0-1> });
// level: 0 = off, 1 = log slow ops only, 2 = log every op
Examples
Example 1: Diagnosing a slow query with explain()
Suppose db.orders has 500,000 documents and a query filtering on status feels slow. Run it with executionStats verbosity first:
db.orders.find({ status: "shipped" }).explain("executionStats");
Output:
{
queryPlanner: {
winningPlan: {
stage: 'COLLSCAN',
filter: { status: { '$eq': 'shipped' } },
direction: 'forward'
}
},
executionStats: {
executionSuccess: true,
nReturned: 4210,
executionTimeMillis: 138,
totalKeysExamined: 0,
totalDocsExamined: 500000
}
}
The COLLSCAN stage and totalDocsExamined: 500000 confirm every document was checked to find 4,210 matches — a huge waste. Add an index on the filtered field and re-run:
db.orders.createIndex({ status: 1 });
db.orders.find({ status: "shipped" }).explain("executionStats");
Output:
{
queryPlanner: {
winningPlan: {
stage: 'FETCH',
inputStage: {
stage: 'IXSCAN',
indexName: 'status_1',
direction: 'forward'
}
}
},
executionStats: {
executionSuccess: true,
nReturned: 4210,
executionTimeMillis: 6,
totalKeysExamined: 4210,
totalDocsExamined: 4210
}
}
Now the plan is IXSCAN → FETCH, totalDocsExamined dropped to match nReturned exactly, and execution time fell from 138ms to 6ms. The ratio of documents examined to documents returned is the single most useful number in an explain output — the closer to 1:1, the more efficient the query.
Example 2: Catching slow queries automatically with the profiler
Rather than guessing which queries are slow, turn on the profiler so MongoDB logs anything over a threshold for you:
db.setProfilingLevel(1, { slowms: 100 });
Output:
{ was: 0, slowms: 100, sampleRate: 1, ok: 1 }
After running your application for a while, query the (capped) system.profile collection for the slowest recent entries:
db.system.profile.find({ millis: { $gt: 100 } }).sort({ ts: -1 }).limit(5);
Output:
[
{
op: 'query',
ns: 'mydb.orders',
millis: 412,
planSummary: 'COLLSCAN',
command: { find: 'orders', filter: { customerEmail: 'a@example.com' } },
ts: ISODate('2026-08-04T10:22:11.000Z')
}
]
This single document tells you the collection, the exact filter used, the plan (COLLSCAN — a missing index), and how long it took — enough to reproduce and fix the problem without having to catch it live.
Example 3: Finding a runaway operation and an unused index
To see what’s currently running longer than expected:
db.currentOp({ active: true, secs_running: { $gt: 5 } });
Output:
{
inprog: [
{
opid: 445821,
active: true,
secs_running: 47,
ns: 'mydb.orders',
op: 'update',
command: { q: {}, u: { '$set': { archived: true } }, multi: true }
}
]
}
An unfiltered updateMany touching every document is running for 47 seconds and climbing — a good candidate to kill with db.killOp(445821) and rewrite with a proper filter or batching.
Separately, to find indexes nobody is using (they still cost write overhead and disk space):
db.orders.aggregate([{ $indexStats: {} }]);
Output:
[
{ name: 'status_1', accesses: { ops: 184203 } },
{ name: 'legacyFlag_1', accesses: { ops: 0 } }
]
legacyFlag_1 has zero accesses since the last restart — worth investigating as a drop candidate.
How it works step by step
When you run a query, the planner first checks the index catalog for candidates whose keys overlap the query’s filter fields. If more than one index looks usable, it runs a short competition between them (or uses a cached winning plan from a previous identical query shape) and picks the plan that examined the fewest documents to produce its results. The winning plan is then executed: an IXSCAN walks the index’s B-tree structure to find matching keys, then a FETCH stage retrieves the full BSON document for each match from the WiredTiger storage engine — pulling from cache if the page is resident, or from disk if not. A COLLSCAN skips the index step entirely and walks every document in the collection in storage order.
For writes, once a document is written, an entry is appended to the oplog (a capped collection replicated to secondaries). Secondaries apply oplog entries asynchronously, which is why rs.printSecondaryReplicationInfo() or db.serverStatus().repl matter for monitoring — a lagging secondary means reads with readPreference: "secondary" could return stale data.
Common Mistakes
Mistake 1: Reading opcounters as an instantaneous rate
db.serverStatus().opcounters is a cumulative counter since the process last started, not a per-second rate:
db.serverStatus().opcounters;
// { insert: 582034, query: 1923044, update: 88221, delete: 4021, getmore: 291, command: 902 }
// Wrong: "we're doing 1.9 million queries a second!"
To get an actual rate, sample the counter twice and divide by the elapsed time:
const before = db.serverStatus().opcounters;
sleep(10000);
const after = db.serverStatus().opcounters;
print((after.query - before.query) / 10, "queries/sec");
Mistake 2: Leaving the profiler at level 2 in production
db.setProfilingLevel(2); // logs EVERY operation, forever — wrong
Level 2 logs every single operation with no threshold, adding real overhead to every request and quickly cycling through the capped system.profile collection (default 1 MB, oldest entries silently dropped). Use level 1 with a threshold tied to your app’s latency SLA, and remember to turn it off when you’re done investigating:
db.setProfilingLevel(1, { slowms: 100 });
// ... investigate ...
db.setProfilingLevel(0);
Mistake 3: Assuming IXSCAN always means an efficient query
An index is used doesn’t automatically mean it’s selective. Indexing a low-cardinality boolean field is a common trap:
db.orders.createIndex({ active: 1 });
db.orders.find({ active: true }).explain("executionStats");
Output:
{
executionStats: {
nReturned: 249102,
totalKeysExamined: 249102,
totalDocsExamined: 249102,
executionTimeMillis: 210
}
}
The plan is an IXSCAN, but with only two possible values the index barely narrows anything — it still examines nearly half the collection. Always check totalDocsExamined relative to collection size, not just the stage name.
Best Practices
- Sample
serverStatus()metrics on an interval and graph the deltas — never treat a single snapshot as a live rate. - Run
explain("executionStats")on every new or suspect query before shipping it, and check thattotalDocsExaminedis close tonReturned. - In production, run the profiler at level 1 with a
slowmstied to your actual latency budget, not level 2. - Watch
mem.residentagainst total system RAM as an early signal of WiredTiger cache pressure. - Monitor replication lag on secondaries, not just primary uptime — a lagging secondary silently serves stale reads.
- Periodically review
$indexStatsoutput and drop indexes with near-zero access counts; every index adds write overhead. - For anything beyond a single developer poking at
mongostat, forward these metrics to MongoDB Atlas monitoring, Ops Manager, or a Prometheus exporter with Grafana, and set real alert thresholds instead of eyeballing dashboards.
Practice Exercises
- Find (or create) a collection with 100,000+ documents and a field with no index. Run
explain("executionStats")filtering on that field, note theCOLLSCANandtotalDocsExamined, then create an index and compare the newtotalKeysExaminedandexecutionTimeMillis. - Enable the profiler with
slowms: 50, run several queries including one intentionally unindexed one, then querysystem.profilefor entries withmillis > 50and identify the slow query’splanSummary. - Run
db.<collection>.aggregate([{ $indexStats: {} }])on a busy collection and list any index whoseaccesses.opsis at or near zero — a candidate for dropping.
Summary
mongostatandmongotopgive a quick terminal-level view of live throughput and hot collections.db.serverStatus()returns cumulative counters — sample twice and diff to get real rates.db.currentOp()plusdb.killOp()let you find and stop a runaway operation safely.explain("executionStats")is the primary tool for diagnosing a slow query: comparetotalDocsExaminedtonReturned, and check forCOLLSCANvsIXSCAN.- The database profiler logs slow operations to
system.profilefor after-the-fact analysis — use level 1 with a threshold, not level 2, in production. $indexStatsreveals which indexes are actually used, helping you find drop candidates.- Watch memory (WiredTiger cache pressure) and replication lag as leading indicators, not just query latency.
