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.

  • Databases
  • $mongodb-aggregation
  • 190 lines
  • 3 files
  • ~2,050 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- mongodb-aggregation

Installs to ~/.agents/skills/mongodb-aggregation/ for every project on this machine.

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

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.

~/.agents/skills/mongodb-aggregation/SKILL.md

--- name: mongodb-aggregation description: Writes and tunes MongoDB aggregation pipelines - stage order so $match and $sort use indexes, $project and $group with accumulators like $top and… license: MIT ---

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

  1. Read AGENTS.md; find the driver, server version (db.version()) and whether this is Atlas (enables $search and $vectorSearch, which must be the first stage).
  2. State the result shape first: one example output document.
  3. Write the pipeline in mongosh against sample data, stage by stage, checking the output after each stage (pipeline.slice(0, n)).
  4. Order stages for index use and data reduction (rules below).
  5. Run explain("executionStats"), fix the plan, then port the pipeline into code.
  6. Add a test with fixture documents covering nulls, empty arrays and ties.

Stage order rules

  • $match first, on indexed fields, then $sort (+$limit) while the index still applies. Once $group, $unwind, $project with computed fields or $lookup runs, later $match/$sort stages cannot use collection indexes.
  • $sort immediately followed by $limit becomes a top-k sort with bounded memory.
  • Drop unused fields early with $project or $unset; large documents through $group or $lookup dominate memory.
  • $match on fields produced by $lookup comes after the $lookup, but filter the joined collection inside the $lookup pipeline so fewer documents are joined.
  • $unwind multiplies documents; $group back by _id afterwards, or avoid unwinding by using array operators ($filter, $map, $reduce, $size).
  • $count or $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

NeedForm
Equality joinlocalField / foreignField (index foreignField on the joined collection)
Equality plus filter/projection on the joined sidelocalField/foreignField plus pipeline (MongoDB 5.0+)
Non-equality or multi-field conditionslet + pipeline with $match: { $expr: ... }
Called for every document on a hot read pathReshape 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/$bottomN in 5.2+). Otherwise $sort then $group with $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: $bucket with fixed boundaries and a default bucket, 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: $densify then $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; $merge upserts.
  • $out replaces 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), not COLLSCAN.
  • totalDocsExamined vs documents returned by the initial match: close means a selective index.
  • For $lookup, collectionScans and indexesUsed per lookup stage (recent versions report them).
  • $sort stages showing usedDisk: true or 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, allowDiskUseByDefault is on, so it spills to disk; spills are slow. Reduce input first; pass allowDiskUse: true explicitly on older servers.
  • Output documents (including $facet and $group with $push) are capped at 16 MB.
  • $addToSet/$push on large groups is the usual cause of both limits; aggregate counts or use $topN instead of collecting everything.
  • Long-running pipelines on primaries compete with production traffic; use readPreference: "secondaryPreferred" for reports where slight staleness is acceptable.

Gotchas

  • $unwind drops documents with missing or empty arrays unless preserveNullAndEmptyArrays: true.
  • $sum of a missing field is 0; $avg ignores missing values. Decide which the report needs.
  • $group key _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 $lookup between "abc..." and ObjectId("abc...") matches nothing.
  • Driver code must pass dates as Date objects, not ISO strings, or $match compares 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 find and 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.yamlgenerated from this page

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.md153 lines, 5.3 KB
# 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.

esc