Skip to content
ExponaBLOG
MONGODB, NOSQL & DATA MODELING · DAY 093 / 100

Views in MongoDB: a MongoDB & NoSQL guide for developers

Read-only collections defined by pipelines.

Expona Admin7 min read

Introduction

Views in MongoDB are read-only collections defined by aggregation pipelines that transform underlying data on the fly. They're one of the most underutilized features in MongoDB, yet they solve real problems: controlling what data users see, computing derived fields without storing them, and simplifying complex queries into reusable interfaces. This guide walks you through implementing views in MongoDB: a MongoDB & NoSQL guide for developers, from creation to production patterns.

What Are MongoDB Views?

A view is essentially a named aggregation pipeline. When you query a view, MongoDB executes that pipeline against the underlying collection and returns the result. Views are read-only—you can't insert, update, or delete through them directly. They're stored in the database as metadata (the pipeline definition), not as physical data, so they take up negligible space.

Think of views as a bridge between raw data and application logic. Instead of embedding transformation logic in every query, you define it once and query it many times.

Create Views with db.createView

The syntax is straightforward:

javascript
db.createView(
  "viewName",
  "sourceCollection",
  [
    { $match: { ... } },
    { $project: { ... } },
    { $group: { ... } }
    // any aggregation stages
  ]
);

Let's say we run an e-commerce platform with an orders collection containing customer data, payment status, and delivery tracking. A finance team needs to see only completed orders with totals and customer counts—but they shouldn't see payment methods or personal addresses.

javascript
db.createView(
  "completed_orders_summary",
  "orders",
  [
    { $match: { status: "completed" } },
    { $project: {
        orderId: "$_id",
        customerId: 1,
        total: 1,
        completedAt: 1,
        _id: 0
    }},
    { $group: {
        _id: "$customerId",
        orderCount: { $sum: 1 },
        totalSpent: { $sum: "$total" }
    }}
  ]
);

Now the finance team queries it like a normal collection:

javascript
db.completed_orders_summary.find({ totalSpent: { $gt: 5000 } });

No separate logic, no risk of exposing sensitive fields. If the pipeline needs to change, you drop and recreate the view in one place.

Hide Fields from Some Users

Role-based access control is easier when views do the filtering. Create different views for different roles, each exposing only appropriate fields.

Imagine a SaaS platform with user accounts. Admins need everything; support agents need customer contact info and subscription status, but not billing details or internal notes; and customers see only their own non-sensitive profile data.

javascript
// View for support agents
db.createView(
  "users_for_support",
  "users",
  [
    { $project: {
        email: 1,
        name: 1,
        phone: 1,
        subscriptionTier: 1,
        createdAt: 1,
        lastLoginAt: 1,
        // Explicitly hide: paymentMethod, internalNotes, ssn
        internalNotes: 0,
        paymentMethod: 0,
        ssn: 0
    }}
  ]
);

// View for customers (only their own account)
db.createView(
  "user_public_profile",
  "users",
  [
    { $match: { _id: ??? } }, // In practice, filter by authenticated user
    { $project: {
        name: 1,
        email: 1,
        subscriptionTier: 1,
        avatar: 1
    }}
  ]
);

This pattern combines MongoDB views with your application's authentication layer. The view defines the schema; your code enforces who can access it.

Computed on Read

Views compute their results every time you query them. This is a feature when data changes frequently or when you want always-fresh results, but it's a drawback if computation is expensive.

Consider a ride-hailing platform with a trips collection. You want a leaderboard of top drivers by rating. A view could compute ratings on demand:

javascript
db.createView(
  "driver_leaderboard",
  "trips",
  [
    { $match: { driverId: { $exists: true } } },
    { $group: {
        _id: "$driverId",
        totalRides: { $sum: 1 },
        avgRating: { $avg: "$driverRating" },
        totalEarnings: { $sum: "$driverEarnings" }
    }},
    { $sort: { avgRating: -1 } },
    { $limit: 100 }
  ]
);

Each query recalculates the group and sort. If trips has millions of records, this could be slow. For frequently-accessed views or dashboards, consider pre-computation instead.

Use $merge for Precomputed Views

When on-demand computation is too expensive, use an aggregation pipeline with $merge to materialize results into a separate collection on a schedule. This gives you the best of both worlds: a fixed schema (like a view) and pre-calculated data (like denormalization).

Instead of:

javascript
// Expensive: recalculates every query
db.driver_leaderboard.find();

You run a scheduled job (via cron, a background worker, or MongoDB Triggers) that merges the aggregation result into a collection:

javascript
// Run this every hour via a background job
db.trips.aggregate([
  { $match: { driverId: { $exists: true } } },
  { $group: {
      _id: "$driverId",
      totalRides: { $sum: 1 },
      avgRating: { $avg: "$driverRating" },
      totalEarnings: { $sum: "$driverEarnings" }
  }},
  { $sort: { avgRating: -1 } },
  { $limit: 100 },
  { $merge: {
      into: "driver_leaderboard_materialized",
      whenMatched: "replace",
      whenNotMatched: "insert"
  }}
]);

Now queries are instant:

javascript
db.driver_leaderboard_materialized.find().toArray();

The tradeoff is staleness: results are at most 1 hour old. Choose this when your users tolerate delayed data and your query volume or data size makes live computation impractical.

Real-World Example: Multi-Tenant SaaS

Suppose you build a CRM for multiple customers. Each tenant has its own accounts collection with account details, owner, and metadata. You want support staff to see a clean, auditable summary without exposing internal flags or raw query patterns.

javascript
// Raw collection has lots of internal fields
db.accounts.insertOne({
  _id: ObjectId(),
  tenantId: "org_123",
  name: "Acme Corp",
  owner: "alice@acme.com",
  industry: "manufacturing",
  monthlyRecurringRevenue: 50000,
  churnRisk: true,           // internal flag
  dataQualityScore: 0.78,    // internal metric
  internalNotes: "..."       // sensitive
});

// View for support
db.createView(
  "accounts_for_support",
  "accounts",
  [
    { $match: { tenantId: "org_123" } },
    { $addFields: {
        accountHealth: {
          $cond: [
            { $lt: ["$monthlyRecurringRevenue", 10000] },
            "at-risk",
            "stable"
          ]
        }
    }},
    { $project: {
        name: 1,
        owner: 1,
        industry: 1,
        monthlyRecurringRevenue: 1,
        accountHealth: 1,
        lastUpdated: { $literal: new Date() }
    }}
  ]
);

Support queries it safely; the schema is readable; sensitive fields are hidden. If the business adds a new internal flag, the view remains stable.

Common Mistakes

  • Expecting write performance: Views are read-only. If you try to insert into a view, MongoDB throws an error. Plan for writes to go to the underlying collection.
  • Ignoring pipeline limits: Aggregation pipelines in views can be slow. Use $match early and $limit aggressively. Test explain plans with db.collection.aggregate([...], { explain: true }).
  • Creating views for trivial transformations: A view with only $project to rename fields adds minimal value over application logic. Use views for complex logic or security boundaries.
  • Forgetting to grant permissions: Just because a view exists doesn't mean users can query it. Ensure your MongoDB RBAC roles grant find on the view collection.
  • Treating views as free caching: Every query re-runs the pipeline. If you need sub-second results on large datasets, materialize with $merge instead.

Key Takeaways

  • Views are named aggregation pipelines stored as metadata—they compute results on read, so they're always fresh but potentially expensive.
  • Use views for role-based access control: create separate views for different user groups, each exposing only appropriate fields.
  • Define complex or frequently-used aggregations as views to centralize logic and reduce duplication across applications.
  • For expensive pipelines or high-throughput scenarios, materialize results with $merge on a schedule instead of computing on every read.
  • Test view performance with explain: true and ensure your aggregation stages are efficient—push $match early, use indexes, avoid $lookup on large collections.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles