Type something to search...
Using Views and On-Demand Materialized Views in MongoDB

Using Views and On-Demand Materialized Views in MongoDB

Sooner or later, the same aggregation pipeline starts showing up all over your codebase. The dashboard needs "active customers with their order totals." The admin panel needs it too. So does the nightly export, the reporting script, and the analyst who only has read access and keeps asking you to paste the pipeline into Slack. Every copy is a chance for someone to forget a $match stage or compute a total slightly differently.

MongoDB gives you two tools for this. A standard view is a named, read-only pipeline that behaves like a collection: you query it with find() or aggregate(), and MongoDB runs the pipeline against the source data on every read. An on-demand materialized view is an ordinary collection that you fill with the output of a pipeline using $merge or $out, then refresh whenever you choose. One trades freshness for speed, the other speed for freshness.

This guide covers how to create, query, modify, and secure standard views, how to build and incrementally refresh materialized views, and how to decide which one a given problem calls for.

Standard Views: A Saved Pipeline With a Name

A view is defined by three things: its name, the collection (or other view) it reads from, and an aggregation pipeline. Nothing is stored except that definition. Say you have an orders collection:

db.orders.insertMany([
  {
    customerId: 1,
    status: "paid",
    total: 120.5,
    createdAt: ISODate("2026-09-01"),
  },
  {
    customerId: 1,
    status: "paid",
    total: 80.0,
    createdAt: ISODate("2026-09-10"),
  },
  {
    customerId: 2,
    status: "refunded",
    total: 45.0,
    createdAt: ISODate("2026-09-11"),
  },
  {
    customerId: 3,
    status: "paid",
    total: 310.0,
    createdAt: ISODate("2026-09-12"),
  },
]);

You can create a view of paid orders with just the fields that matter:

db.createView("paidOrders", "orders", [
  { $match: { status: "paid" } },
  { $project: { _id: 0, customerId: 1, total: 1, createdAt: 1 } },
]);

Now paidOrders shows up alongside your collections and you query it the same way:

db.paidOrders.find({ total: { $gt: 100 } });
[
  {
    customerId: 1,
    total: 120.5,
    createdAt: ISODate("2026-09-01T00:00:00.000Z"),
  },
  { customerId: 3, total: 310, createdAt: ISODate("2026-09-12T00:00:00.000Z") },
];

Behind the scenes, MongoDB appends your query to the view's pipeline. The find() above becomes roughly [ {$match: {status: "paid"}}, {$project: ...}, {$match: {total: {$gt: 100}}} ], and the optimizer is free to reorder and merge stages before running it. Because the view is computed at read time, it always reflects the current state of orders. Insert a new paid order and it appears in the view immediately.

You can also create a view with the create command, which is what drivers use under the hood:

db.runCommand({
  create: "paidOrders",
  viewOn: "orders",
  pipeline: [{ $match: { status: "paid" } }],
});

Views Built on Joins and Groups

Views get more interesting when the pipeline does real work. Here's a customer summary that joins and aggregates:

db.createView("customerSummary", "orders", [
  { $match: { status: "paid" } },
  {
    $group: {
      _id: "$customerId",
      orderCount: { $sum: 1 },
      lifetimeValue: { $sum: "$total" },
      lastOrderAt: { $max: "$createdAt" },
    },
  },
  {
    $lookup: {
      from: "customers",
      localField: "_id",
      foreignField: "_id",
      as: "customer",
    },
  },
  { $unwind: "$customer" },
  {
    $project: {
      name: "$customer.name",
      email: "$customer.email",
      orderCount: 1,
      lifetimeValue: 1,
      lastOrderAt: 1,
    },
  },
]);

Anyone querying db.customerSummary.find({ lifetimeValue: { $gte: 500 } }) gets the right answer without knowing how it's computed. If you're new to the join stage, using $lookup to join collections walks through it in detail.

The catch is that this whole pipeline runs every single time. The $match on lifetimeValue can't use an index because lifetimeValue doesn't exist until after $group. On a collection with millions of orders, that's a full aggregation per request. Keep that in mind; it's exactly the problem materialized views solve.

Views With a Default Collation

Views can carry a collation, which is handy for case-insensitive lookups:

db.createView(
  "customersByName",
  "customers",
  [{ $project: { name: 1, email: 1 } }],
  { collation: { locale: "en", strength: 2 } },
);

db.customersByName.find({ name: "ada lovelace" }); // matches "Ada Lovelace"

A view's collation is fixed. Queries against it can't specify a different collation, and if you build a view on another view, both must use the same collation.

What You Can and Can't Do With Views

Views are read-only. Any write operation (insertOne, updateMany, deleteOne, and so on) against a view fails. Beyond that, a few restrictions come up often:

OperationSupported on views?
find(), findOne(), aggregate()Yes
countDocuments(), distinct()Yes
Creating indexes on the viewNo (indexes live on the source collection)
$out or $merge inside the view definitionNo
$text queriesNo
Positional or $elemMatch, $slice projection in findNo
Renaming the viewNo (drop and recreate)
Views of viewsYes, as long as there are no cycles and collations match

The index rule deserves emphasis. A view uses the indexes of its source collection, but only for stages the optimizer can push down to the start of the pipeline. A $match on status in the view definition, or a filter on an original field in your query, can use an index on orders. A filter on a computed field cannot.

To see what's actually happening, run explain() on a view query just like a collection:

db.paidOrders.find({ customerId: 1 }).explain("executionStats");

If you see COLLSCAN in the winning plan for a filter on an original field, you're missing an index on the source collection. Using explain() to analyze slow queries covers how to read that output.

Modifying and Dropping Views

You can't rename a view, but you can change its definition in place with collMod:

db.runCommand({
  collMod: "paidOrders",
  viewOn: "orders",
  pipeline: [
    { $match: { status: { $in: ["paid", "shipped"] } } },
    { $project: { _id: 0, customerId: 1, total: 1, createdAt: 1 } },
  ],
});

Dropping a view removes only the definition, never the source data:

db.paidOrders.drop();

To list views and their pipelines, filter listCollections by type:

db.getCollectionInfos({ type: "view" }).map((v) => ({
  name: v.name,
  viewOn: v.options.viewOn,
  pipeline: v.options.pipeline,
}));

View definitions are stored in the database's system.views collection. Don't edit that collection directly; use createView, collMod, and drop.

Using Views to Restrict Access

One of the most practical uses of views has nothing to do with performance: they let you expose a safe slice of a collection. Suppose customers contains payment tokens and internal notes that your support team shouldn't see. Create a view that projects only the safe fields, then grant access to the view without granting access to the underlying collection:

db.createView("customersPublic", "customers", [
  { $project: { name: 1, email: 1, plan: 1, createdAt: 1 } },
]);

db.getSiblingDB("admin").createRole({
  role: "supportReadOnly",
  privileges: [
    {
      resource: { db: "shop", collection: "customersPublic" },
      actions: ["find"],
    },
  ],
  roles: [],
});

A user with supportReadOnly can query customersPublic but gets an authorization error on customers. This is also a clean way to give BI tools or analysts a stable, documented interface that won't break when you rename internal fields: update the view's pipeline to map the new name to the old one, and downstream consumers never notice.

Using Views From Application Code

Drivers treat views as regular collections. In Node.js:

import { MongoClient } from "mongodb";

const client = new MongoClient(process.env.MONGODB_URI);
const db = client.db("shop");

// Create once, for example in a migration script
await db.createCollection("paidOrders", {
  viewOn: "orders",
  pipeline: [{ $match: { status: "paid" } }],
});

// Query like any collection
const bigOrders = await db
  .collection("paidOrders")
  .find({ total: { $gte: 100 } })
  .sort({ createdAt: -1 })
  .limit(20)
  .toArray();

In Python with PyMongo:

from pymongo import MongoClient

db = MongoClient("mongodb://localhost:27017")["shop"]

db.create_collection(
    "paidOrders",
    viewOn="orders",
    pipeline=[{"$match": {"status": "paid"}}],
)

for doc in db["paidOrders"].find({"customerId": 1}):
    print(doc)

Treat view creation like a schema change: put it in a migration script so it's versioned and reproducible across environments, rather than creating views by hand in production.

On-Demand Materialized Views

A materialized view flips the model. Instead of computing results on every read, you run the pipeline once and write the results into a real collection. Reads are then as fast as reading any collection, and you can index the results however you like. The trade-off is staleness: the data is only as fresh as your last refresh.

MongoDB calls these "on-demand" because nothing refreshes them automatically. You decide when to rerun the pipeline.

Building One With $merge

The $merge stage writes pipeline output into a target collection, updating documents that already exist and inserting new ones. Here's the customer summary as a materialized view:

db.orders.aggregate([
  { $match: { status: "paid" } },
  {
    $group: {
      _id: "$customerId",
      orderCount: { $sum: 1 },
      lifetimeValue: { $sum: "$total" },
      lastOrderAt: { $max: "$createdAt" },
    },
  },
  { $set: { refreshedAt: "$$NOW" } },
  {
    $merge: {
      into: "customerStats",
      on: "_id",
      whenMatched: "replace",
      whenNotMatched: "insert",
    },
  },
]);

The aggregation returns nothing to the client; all output goes into customerStats. Now add indexes that fit the way you read the data:

db.customerStats.createIndex({ lifetimeValue: -1 });
db.customerStats.createIndex({ lastOrderAt: -1 });

db.customerStats
  .find({ lifetimeValue: { $gte: 500 } })
  .sort({ lifetimeValue: -1 });

That query is now an index scan over a small collection instead of a full aggregation over every order.

The whenMatched option controls what happens when a document with the same on key already exists:

  • replace swaps the old document for the new one.
  • merge combines fields, keeping fields in the existing document that the new one doesn't have.
  • keepExisting leaves the existing document alone.
  • fail stops the aggregation with an error.
  • A pipeline lets you compute the update yourself, using $$new to reference the incoming document.

The on field must be backed by a unique index on the target collection. When you merge on _id, that index already exists. If you merge on something else, like { on: ["region", "month"] }, create a unique compound index on those fields first or $merge will refuse to run.

Incremental Refreshes

Rebuilding the whole summary every time works, but it gets slower as data grows. If your source documents carry a timestamp, you can refresh only what changed. Here's a monthly sales rollup that recomputes just the current and previous month:

const since = new Date();
since.setUTCMonth(since.getUTCMonth() - 1, 1);
since.setUTCHours(0, 0, 0, 0);

db.orders.aggregate([
  { $match: { status: "paid", createdAt: { $gte: since } } },
  {
    $group: {
      _id: {
        month: { $dateTrunc: { date: "$createdAt", unit: "month" } },
      },
      revenue: { $sum: "$total" },
      orders: { $sum: 1 },
    },
  },
  {
    $merge: {
      into: "monthlySales",
      whenMatched: "replace",
      whenNotMatched: "insert",
    },
  },
]);

Older months stay untouched in monthlySales, and each refresh only scans a month or two of orders. This works because completed months don't change. If late-arriving or edited data can affect old buckets, widen the window or recompute the affected buckets specifically.

Adding to a running total is another option, using a pipeline in whenMatched:

db.orders.aggregate([
  {
    $match: { status: "paid", createdAt: { $gte: lastRunAt, $lt: thisRunAt } },
  },
  { $group: { _id: "$customerId", added: { $sum: "$total" } } },
  {
    $merge: {
      into: "customerTotals",
      whenMatched: [{ $set: { total: { $add: ["$total", "$$new.added"] } } }],
      whenNotMatched: "insert",
    },
  },
]);

This is efficient, but it's fragile. If a run fails halfway or runs twice over the same window, totals drift. Store lastRunAt durably and use a full rebuild occasionally to correct any drift. For most summaries, recomputing affected groups with replace is the safer default.

$out: Replace Everything at Once

When you want a full rebuild, $out replaces the target collection with the pipeline's output:

db.orders.aggregate([
  { $match: { status: "paid" } },
  { $group: { _id: "$customerId", lifetimeValue: { $sum: "$total" } } },
  { $out: "customerLtv" },
]);

$out writes into a temporary collection and swaps it in when the pipeline finishes, so readers never see a half-built result. Indexes that existed on the old target collection are preserved. The downside is that it's all or nothing: you can't update part of the collection.

Feature$merge$out
Updates existing resultsYes, per documentNo, replaces the whole collection
Incremental refreshYesNo
Readers see partial resultsPossibly, while it runsNo, atomic swap at the end
Output to a different databaseYesYes
Output to sharded collectionYesNo

Keeping Materialized Views Fresh

Since MongoDB won't refresh anything for you, you need a scheduler. Common options:

  • A cron job or worker in your own infrastructure that runs the pipeline on an interval.
  • An Atlas scheduled trigger, if you're on Atlas, which runs a function on a cron schedule. Atlas Triggers and Functions covers setting one up.
  • Event-driven refresh using change streams: when an order changes, recompute just that customer's summary.

Here's a small Node.js refresher you could run from cron:

import { MongoClient } from "mongodb";

const client = new MongoClient(process.env.MONGODB_URI);

async function refreshCustomerStats() {
  const db = client.db("shop");
  const started = Date.now();

  await db
    .collection("orders")
    .aggregate([
      { $match: { status: "paid" } },
      {
        $group: {
          _id: "$customerId",
          orderCount: { $sum: 1 },
          lifetimeValue: { $sum: "$total" },
        },
      },
      { $set: { refreshedAt: "$$NOW" } },
      { $merge: { into: "customerStats", whenMatched: "replace" } },
    ])
    .toArray(); // $merge returns no documents, but the cursor must be consumed

  console.log(`customerStats refreshed in ${Date.now() - started} ms`);
}

try {
  await refreshCustomerStats();
} finally {
  await client.close();
}

Note the .toArray(). In the Node.js driver, aggregate() returns a lazy cursor, and the pipeline doesn't execute until you iterate it. Forgetting this is a classic "why is my materialized view empty?" bug.

The refreshedAt field is worth including in every materialized view. It lets consumers see how stale the data is and lets your monitoring alert when a refresh job silently stops running.

Handling Deletions

$merge never removes documents from the target. If a customer's only order is refunded, they fall out of the pipeline's output, but their old document stays in customerStats. There are a few ways to handle that:

  1. Periodically run a full rebuild with $out.
  2. Use the refreshedAt stamp: after a full $merge pass, delete documents whose refreshedAt is older than the run's start time.
  3. Emit a tombstone from the pipeline (for example, a document with orderCount: 0) and filter those out when reading.

Option 2 is a simple, reliable pattern:

const runStart = new Date();
// ...run the $merge pipeline that sets refreshedAt: "$$NOW"...
await db
  .collection("customerStats")
  .deleteMany({ refreshedAt: { $lt: runStart } });

Choosing Between a View and a Materialized View

The decision usually comes down to three questions: how fresh does the data need to be, how expensive is the pipeline, and how often is it read?

SituationBetter choice
Results must reflect writes instantlyStandard view
Pipeline is a cheap $match and $project over indexed fieldsStandard view
Main goal is hiding fields or giving a stable interfaceStandard view
Pipeline groups or joins large amounts of dataMaterialized view
Data is read far more often than it changesMaterialized view
You need indexes on computed fieldsMaterialized view
Dashboards where "as of five minutes ago" is fineMaterialized view

The two also combine well. You can create a standard view on top of a materialized collection to hide internal fields like refreshedAt, or to give it a friendlier shape for a reporting tool.

Common Pitfalls

Expecting a view to be fast because it has a name. A standard view is not a cache. It runs the full pipeline on every read, so a heavy $group over millions of documents is exactly as slow through a view as it is inline.

Filtering on computed fields in a view. Filters on fields created by $group, $project, or $addFields can't use indexes. If your most common query filters on a computed value, materialize it.

Forgetting the unique index for $merge. Merging on anything other than _id requires a unique index on those fields in the target collection. Create it before the first run.

Not consuming the aggregation cursor. In most drivers, a pipeline ending in $merge or $out still needs to be iterated before it runs. Call .toArray() or loop over the result.

Letting stale rows accumulate. $merge only inserts and updates. Plan for removals with a periodic $out rebuild or a refreshedAt sweep.

Silent refresh failures. A cron job that stops running leaves a materialized view looking perfectly healthy but increasingly wrong. Store a refresh timestamp and alert on it.

Conclusion

Standard views and on-demand materialized views solve the same problem from opposite directions. A view gives a pipeline a name and always returns live data, which makes it ideal for access control, stable interfaces, and cheap filters. A materialized view stores the pipeline's output in a real collection you can index, trading a little freshness for dramatically faster reads on expensive aggregations.

Pick the heaviest aggregation your dashboard runs on every page load, move it into a $merge pipeline that writes to a summary collection with a refreshedAt field, and schedule it every few minutes. Then compare response times before and after. The difference is usually enough to convince the rest of your team.

Tags :
Share :

Related Posts

A Complete Guide to MongoDB Query Operators

A Complete Guide to MongoDB Query Operators

Your first MongoDB queries are usually simple equality filters: find the user with this email, find orders with this status. That covers a surprising

Continue Reading
Async MongoDB in Python with Motor and FastAPI

Async MongoDB in Python with Motor and FastAPI

FastAPI runs your endpoints on an event loop. That's what lets a single worker juggle hundreds of concurrent requests: while one request waits on the

Continue Reading
Atlas Online Archive: Tiering Cold Data to Cut Costs

Atlas Online Archive: Tiering Cold Data to Cut Costs

Look at almost any production database and you'll find the same shape. A small slice of recent data gets nearly all the reads and writes: this week's

Continue Reading