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:
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.
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:
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.
// 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:
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:
// 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:
// 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:
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.
// 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
$matchearly and$limitaggressively. Test explain plans withdb.collection.aggregate([...], { explain: true }). - Creating views for trivial transformations: A view with only
$projectto 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
findon 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
$mergeinstead.
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
$mergeon a schedule instead of computing on every read. - Test view performance with
explain: trueand ensure your aggregation stages are efficient—push$matchearly, use indexes, avoid$lookupon large collections.






Comments
Loading comments…