MongoDB Aggregation skill for Codex
Pipelines that hit indexes and stay under limits. Codex builds MongoDB aggregation pipelines stage by stage against sample data, puts $match and $sort where indexes still apply, joins after limiting instead of before, and avoids $unwind when array operators can do the work. Slow pipelines get an explain executionStats reading, heavy reports move to $merge rollups, and fixture tests cover missing fields, empty arrays and timezone edges.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- mongodb-aggregation Installs to ~/.agents/skills/mongodb-aggregation/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install mongodb-aggregation The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project mongodb-aggregation Installs to .agents/skills/mongodb-aggregation/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/mongodb-aggregation.zipunzip -o mongodb-aggregation.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) mongodb-aggregation PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $mongodb-aggregation, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Writes and tunes MongoDB aggregation pipelines - stage order so $match and $sort use indexes, $project and $group with accumulators like $top and $push, $lookup with let and pipeline, $unwind, $facet, $setWindowFields, $bucket, $unionWith, and writing results with $merge for materialized views - and reads explain executionStats to fix slow pipelines, memory limits and $lookup cost. Use when building reports or analytics in MongoDB, a pipeline is slow or hits the 100 MB stage limit, converting SQL GROUP BY or JOIN queries to MongoDB, or maintaining pre-aggregated collections.
Use it when
- Building reports or analytics queries in MongoDB
- A pipeline is slow, spills to disk or exceeds 16 MB output
- Translating SQL GROUP BY, JOIN or window queries to MongoDB
- Maintaining pre-aggregated collections with $merge
Use something else when
- Deciding how documents and collections should be shaped: use mongodb-schema-design
- Heavy analytical workloads over billions of events: use clickhouse-analytics
How to check the result
Fixture tests with nulls, empty arrays and ties produce exact expected output, totals match an independent sample computation, and explain shows IXSCAN on the first stage with fewer documents examined.
The mistake it prevents
Running $lookup before $match and $limit, so the join executes for every document in the collection instead of the final page.
Done well means: the pipeline returns the right numbers on edge-case data (missing fields,
empty groups, duplicates after $unwind), its first stages use an index, each stage passes
on only the fields later stages need, and its runtime is known from explain, not guessed.
Workflow
- Read
AGENTS.md; find the driver, server version (db.version()) and whether this is Atlas (enables$searchand$vectorSearch, which must be the first stage). - State the result shape first: one example output document.
- Write the pipeline in
mongoshagainst sample data, stage by stage, checking the output after each stage (pipeline.slice(0, n)). - Order stages for index use and data reduction (rules below).
- Run
explain("executionStats"), fix the plan, then port the pipeline into code. - Add a test with fixture documents covering nulls, empty arrays and ties.
Stage order rules
$matchfirst, on indexed fields, then$sort(+$limit) while the index still applies. Once$group,$unwind,$projectwith computed fields or$lookupruns, later$match/$sortstages cannot use collection indexes.$sortimmediately followed by$limitbecomes a top-k sort with bounded memory.- Drop unused fields early with
$projector$unset; large documents through$groupor$lookupdominate memory. $matchon fields produced by$lookupcomes after the$lookup, but filter the joined collection inside the$lookuppipeline so fewer documents are joined.$unwindmultiplies documents;$groupback by_idafterwards, or avoid unwinding by using array operators ($filter,$map,$reduce,$size).$countor$group: { _id: null, n: { $sum: 1 } }for totals; do not fetch all documents to count in application code.
Core example: revenue per product, last 30 days
db.orders.aggregate([
{ $match: { status: "paid", paidAt: { $gte: ISODate("2026-09-28") } } }, // index { status: 1, paidAt: 1 }
{ $project: { items: 1, paidAt: 1 } },
{ $unwind: "$items" },
{ $group: {
_id: "$items.productId",
revenueCents: { $sum: { $multiply: ["$items.qty", "$items.unitCents"] } },
orders: { $addToSet: "$_id" },
lastSale: { $max: "$paidAt" }
}},
{ $project: { revenueCents: 1, lastSale: 1, orderCount: { $size: "$orders" } } },
{ $sort: { revenueCents: -1 } },
{ $limit: 20 },
{ $lookup: {
from: "products",
localField: "_id",
foreignField: "_id",
pipeline: [{ $project: { name: 1, sku: 1 } }],
as: "product"
}},
{ $set: { product: { $first: "$product" } } }
]);
Note the $lookup after $limit: join 20 documents, not 50 000.
$lookup decisions
| Need | Form |
|---|---|
| Equality join | localField / foreignField (index foreignField on the joined collection) |
| Equality plus filter/projection on the joined side | localField/foreignField plus pipeline (MongoDB 5.0+) |
| Non-equality or multi-field conditions | let + pipeline with $match: { $expr: ... } |
| Called for every document on a hot read path | Reshape the schema (extended reference) instead |
{ $lookup: {
from: "payments",
let: { orderId: "$_id", since: "$createdAt" },
pipeline: [
{ $match: { $expr: { $and: [
{ $eq: ["$orderId", "$$orderId"] },
{ $gte: ["$at", "$$since"] }
] } } },
{ $sort: { at: -1 } },
{ $limit: 1 }
],
as: "lastPayment"
}}
$expr equality matches can use an index on the joined collection; range comparisons
inside $expr often cannot. Check the explain output for each $lookup.
Grouping, windows and buckets
- Top document per group:
$group: { _id: "$customerId", latest: { $top: { sortBy: { createdAt: -1 }, output: "$$ROOT" } } }($top/$topN/$bottomNin 5.2+). Otherwise$sortthen$groupwith$first. - Running totals, ranks, moving averages:
$setWindowFields(5.0+).
{ $setWindowFields: {
partitionBy: "$storeId",
sortBy: { day: 1 },
output: {
runningRevenue: { $sum: "$revenue", window: { documents: ["unbounded", "current"] } },
avg7d: { $avg: "$revenue", window: { range: [-6, 0], unit: "day" } }
}
}}
- Histograms:
$bucketwith fixedboundariesand adefaultbucket, or$bucketAuto. - Several aggregations over the same input (counts per facet on a search page):
$facet. Each sub-pipeline output must fit in one 16 MB document; cap each with$limit. - Date grouping:
$dateTrunc: { date: "$at", unit: "day", timezone: "Europe/Berlin" }. Always pass the timezone the business reports in. - Fill gaps in time series output:
$densifythen$fill.
Materialized results with $merge
db.orders.aggregate([
{ $match: { paidAt: { $gte: startOfDay, $lt: endOfDay } } },
{ $group: { _id: { day: startOfDay, productId: "$productId" }, revenue: { $sum: "$totalCents" } } },
{ $merge: { into: "daily_revenue", on: "_id", whenMatched: "replace", whenNotMatched: "insert" } }
]);
- Recompute only the changed window (a day, an hour) on a schedule;
$mergeupserts. $outreplaces the whole target collection; use it only for full rebuilds, and confirm with the user before pointing it at an existing collection, since its contents are lost.- Reports on large collections should read the merged collection, not aggregate raw data per request.
Reading explain
db.orders.explain("executionStats").aggregate(pipeline)
Check:
- The first stage(s) show
IXSCAN(in$cursor/queryPlanner.winningPlan), notCOLLSCAN. totalDocsExaminedvs documents returned by the initial match: close means a selective index.- For
$lookup,collectionScansandindexesUsedper lookup stage (recent versions report them). $sortstages showingusedDisk: trueor memory use near the limit.
Memory and limits
- Each blocking stage (
$group,$sort,$bucketAuto,$setWindowFields) is limited to 100 MB of RAM. Since 6.0,allowDiskUseByDefaultis on, so it spills to disk; spills are slow. Reduce input first; passallowDiskUse: trueexplicitly on older servers. - Output documents (including
$facetand$groupwith$push) are capped at 16 MB. $addToSet/$pushon large groups is the usual cause of both limits; aggregate counts or use$topNinstead of collecting everything.- Long-running pipelines on primaries compete with production traffic; use
readPreference: "secondaryPreferred"for reports where slight staleness is acceptable.
Gotchas
$unwinddrops documents with missing or empty arrays unlesspreserveNullAndEmptyArrays: true.$sumof a missing field is 0;$avgignores missing values. Decide which the report needs.$groupkey_id: "$field"groups missing and null together.- Comparisons across BSON types follow type order (numbers < strings < objects); mixed types in a field produce surprising sorts and matches. Validate types at write time.
- ObjectId vs string ids: a
$lookupbetween"abc..."andObjectId("abc...")matches nothing. - Driver code must pass dates as
Dateobjects, not ISO strings, or$matchcompares strings.
For more stage recipes (pagination with totals, deduplication, pivoting, change-stream friendly pipelines, translating SQL clauses), read references/stage-cookbook.md when the pattern above does not cover the task.
Verification
- Fixture test: documents with missing fields, empty arrays, duplicates and ties; assert exact output.
- Compare totals with an independent computation on a sample (a simple
findand sum in a script) before trusting a new report. explain("executionStats")before and after; record execution time and docs examined.
Output
**Pipeline:** <purpose>, collection <name>
**Result shape:** <example document>
**Indexes used / added:** <index> for stage 1
**Explain:** docs examined <n>, returned <n>, time <ms>, disk use <yes/no>
**Edge cases tested:** missing fields, empty arrays, ties, timezone boundaries What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "MongoDB Aggregation"
short_description: "Pipelines that hit indexes and stay under limits"
default_prompt: "$mongodb-aggregation Write a MongoDB aggregation for monthly revenue per region with a running total" references/stage-cookbook.md
# Aggregation stage cookbook
Recipes for patterns that come up after the basics. Each one names the stages, the index
that makes it fast, and the trap to test for.
## SQL to aggregation map
| SQL | Aggregation |
|---|---|
| `WHERE` | `$match` (before grouping) |
| `SELECT a, b` | `$project` / `$set` / `$unset` |
| `GROUP BY` + aggregates | `$group` |
| `HAVING` | `$match` after `$group` |
| `ORDER BY ... LIMIT` | `$sort` + `$limit` |
| `JOIN` | `$lookup` (+ `$unwind` or `$first` for to-one) |
| `UNION ALL` | `$unionWith` |
| `COUNT(DISTINCT x)` | `$group` by x then `$count`, or `$addToSet` + `$size` for small sets |
| `CASE WHEN` | `$switch` / `$cond` |
| Window functions | `$setWindowFields` |
| `INSERT ... SELECT` / upsert | `$merge` |
## Page of results plus total count
```javascript
[
{ $match: filter },
{ $sort: { createdAt: -1, _id: -1 } },
{ $facet: {
items: [{ $skip: page * size }, { $limit: size }, { $project: { title: 1, createdAt: 1 } }],
total: [{ $count: "n" }]
}},
{ $project: { items: 1, total: { $ifNull: [{ $first: "$total.n" }, 0] } } }
]
```
The `total` branch still scans every match. For large result sets, show "1000+" using a
capped count (`$limit: 1001` before `$count`) or use `countDocuments` with a cached value.
Deep `$skip` is linear; use range pagination on `{ createdAt, _id }` for infinite scroll.
## Find duplicates before adding a unique index
```javascript
[
{ $group: { _id: { email: { $toLower: "$email" } }, ids: { $push: "$_id" }, n: { $sum: 1 } } },
{ $match: { n: { $gt: 1 } } },
{ $sort: { n: -1 } }
]
```
Run with `allowDiskUse` on big collections. Resolving duplicates (deleting or merging
documents) is a data change: show the list to the user and get confirmation first.
## Latest document per key
```javascript
[
{ $match: { deviceId: { $in: ids } } },
{ $sort: { deviceId: 1, at: -1 } }, // index { deviceId: 1, at: -1 }
{ $group: { _id: "$deviceId", latest: { $first: "$$ROOT" } } }
]
```
With the matching index, MongoDB can use a DISTINCT_SCAN-style plan for `$sort` + `$group`
with `$first`. Check explain; `$top` does not always get that optimization.
## Pivot rows to columns
```javascript
[
{ $group: { _id: { month: "$month", region: "$region" }, total: { $sum: "$amount" } } },
{ $group: { _id: "$_id.month", pairs: { $push: { k: "$_id.region", v: "$total" } } } },
{ $project: { month: "$_id", _id: 0, byRegion: { $arrayToObject: "$pairs" } } }
]
```
Keys passed to `$arrayToObject` must be strings; convert with `$toString`.
## Conditional counts in one pass
```javascript
{ $group: {
_id: "$storeId",
paid: { $sum: { $cond: [{ $eq: ["$status", "paid"] }, 1, 0] } },
refunded: { $sum: { $cond: [{ $eq: ["$status", "refunded"] }, 1, 0] } },
refundRate: { $avg: { $cond: [{ $eq: ["$status", "refunded"] }, 1, 0] } }
}}
```
## Array manipulation without $unwind
```javascript
{ $set: {
paidItems: { $filter: { input: "$items", as: "i", cond: { $eq: ["$$i.status", "paid"] } } },
itemTotal: { $sum: { $map: { input: "$items", as: "i", in: { $multiply: ["$$i.qty", "$$i.unitCents"] } } } },
skus: { $reduce: { input: "$items", initialValue: [], in: { $setUnion: ["$$value", ["$$this.sku"]] } } }
}}
```
Per-document array math avoids multiplying documents and a regrouping stage.
## Combine collections
```javascript
db.orders_2025.aggregate([
{ $match: { customerId } },
{ $unionWith: { coll: "orders_2026", pipeline: [{ $match: { customerId } }] } },
{ $sort: { createdAt: -1 } },
{ $limit: 50 }
])
```
Put the `$match` inside the `$unionWith` pipeline so the second collection uses its index.
## Incremental rollups with $merge
Keep a high-water mark (last processed `_id` or timestamp) in a small `jobs` collection:
```javascript
const since = db.jobs.findOne({ _id: "daily_revenue" }).watermark;
db.orders.aggregate([
{ $match: { updatedAt: { $gt: since } } },
{ $group: { _id: { $dateTrunc: { date: "$paidAt", unit: "day", timezone: "UTC" } } } },
// recompute each touched day fully, then $merge with whenMatched: "replace"
]);
```
Recompute affected buckets fully rather than adding deltas, so late or updated documents
cannot double count. Use `whenMatched: "merge"` only when the pipeline produces partial fields.
## Time series gap filling
```javascript
[
{ $match: { sensorId, at: { $gte: start, $lt: end } } },
{ $group: { _id: { $dateTrunc: { date: "$at", unit: "hour" } }, avg: { $avg: "$value" } } },
{ $project: { _id: 0, hour: "$_id", avg: 1 } },
{ $densify: { field: "hour", range: { step: 1, unit: "hour", bounds: [start, end] } } },
{ $fill: { sortBy: { hour: 1 }, output: { avg: { method: "linear" } } } }
]
```
## Pipelines for change streams and views
- Change stream pipelines accept only a subset of stages (`$match`, `$project`, `$set`,
`$unset`, `$replaceRoot`, `$redact` and similar); no `$group` or `$lookup`.
- Standard views (`db.createView`) run their pipeline on every read and cannot be indexed;
for heavy reports use a `$merge` target collection instead (an on-demand materialized view).
## Atlas Search and Vector Search
`$search`, `$searchMeta` and `$vectorSearch` must be the first stage and run on Atlas (or
self-managed deployments with the search process). Filter inside the search operator
(`filter` clause) rather than in a later `$match` so pagination and limits stay accurate.
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.