
MongoDB Projection: Returning Only the Fields You Need
A user profile document might hold a name, an email, a password hash, notification settings, a list of saved addresses, a login history, and a few hundred kilobytes of preferences. Your navigation bar needs exactly two of those fields: the display name and the avatar URL. If you call find() without thinking about it, you get all of it, shipped over the network, decoded by the driver, and held in memory, on every page load.
Projection is how you tell MongoDB which fields to return. It's the equivalent of listing columns in a SQL SELECT instead of writing SELECT *. It's also one of the easiest performance wins available, and a basic safety measure: fields you never fetch can't leak into an API response by accident.
This guide covers inclusion and exclusion projections, the rules for combining them, projecting nested fields and array elements, computed fields, how projection looks in Node.js, Mongoose, and Python, covered queries, and the mistakes that make projections silently fail.
Sample Data
The examples use a users collection:
use app
db.users.insertMany([
{
_id: 1,
name: "Ada Lovelace",
email: "ada@example.com",
passwordHash: "$argon2id$v=19$m=65536,t=3,p=4$...",
avatarUrl: "/avatars/1.png",
plan: "pro",
profile: { bio: "First programmer.", city: "London", timezone: "Europe/London" },
addresses: [
{ label: "home", city: "London", primary: true },
{ label: "work", city: "Cambridge", primary: false }
],
logins: [
{ at: ISODate("2026-09-20T08:00:00Z"), ip: "203.0.113.4" },
{ at: ISODate("2026-09-25T09:30:00Z"), ip: "203.0.113.4" },
{ at: ISODate("2026-09-27T18:45:00Z"), ip: "198.51.100.7" }
]
},
{
_id: 2,
name: "Grace Hopper",
email: "grace@example.com",
passwordHash: "$argon2id$v=19$m=65536,t=3,p=4$...",
avatarUrl: "/avatars/2.png",
plan: "free",
profile: { bio: "Found a moth.", city: "Arlington", timezone: "America/New_York" },
addresses: [{ label: "home", city: "Arlington", primary: true }],
logins: [{ at: ISODate("2026-09-26T14:10:00Z"), ip: "192.0.2.10" }]
}
])
Inclusion Projections
The projection is the second argument to find() and findOne(). Set a field to 1 (or true) to include it:
db.users.find({}, { name: 1, avatarUrl: 1 });
[
{ _id: 1, name: 'Ada Lovelace', avatarUrl: '/avatars/1.png' },
{ _id: 2, name: 'Grace Hopper', avatarUrl: '/avatars/2.png' }
]
With an inclusion projection, only the listed fields come back, plus _id, which is included by default. To drop it, set it to 0 explicitly:
db.users.find({ plan: "pro" }, { name: 1, avatarUrl: 1, _id: 0 });
[ { name: 'Ada Lovelace', avatarUrl: '/avatars/1.png' } ]
Inclusion is an allowlist. If someone adds a sensitive field to the schema next month, it won't appear in results that use an inclusion projection. That makes inclusion the safer default for anything that feeds an API response.
Exclusion Projections
Set fields to 0 to return everything except those fields:
db.users.findOne({ _id: 1 }, { passwordHash: 0, logins: 0 });
Exclusion is a denylist. It's convenient when you want nearly the whole document minus one or two heavy or private fields, but it's riskier: any field added later will be returned automatically.
The Mixing Rule
You can't mix inclusion and exclusion in the same projection, with one exception: _id.
// Error: Cannot do exclusion on field passwordHash in inclusion projection
db.users.find({}, { name: 1, passwordHash: 0 });
// Fine: _id is the one field you can exclude from an inclusion projection
db.users.find({}, { name: 1, _id: 0 });
The logic is straightforward once you see it. In an inclusion projection, everything not listed is already excluded, so excluding passwordHash is meaningless. MongoDB rejects it rather than guess what you meant.
Projecting Nested Fields
Use dot notation to reach into embedded documents:
db.users.find({}, { name: 1, "profile.city": 1, _id: 0 });
[
{ name: 'Ada Lovelace', profile: { city: 'London' } },
{ name: 'Grace Hopper', profile: { city: 'Arlington' } }
]
The result keeps the nesting: you get a profile object containing only city, not a flat city field. Recent MongoDB versions also accept the nested-document form, which is equivalent:
db.users.find({}, { name: 1, profile: { city: 1 }, _id: 0 });
Dot notation works across arrays too. Projecting "addresses.city" returns each element of the array with only its city field:
db.users.find({ _id: 1 }, { "addresses.city": 1, _id: 0 });
[ { addresses: [ { city: 'London' }, { city: 'Cambridge' } ] } ]
Path Collisions
You can't project a field and one of its sub-fields at the same time:
// Error: Path collision at profile.city
db.users.find({}, { profile: 1, "profile.city": 1 });
Pick one: the whole sub-document, or specific fields inside it.
Projecting Array Elements
Arrays often grow much larger than the rest of the document. MongoDB has three projection tools to return only part of an array.
$slice
$slice returns a subset of array elements by position:
// Last login only
db.users.find({ _id: 1 }, { name: 1, logins: { $slice: -1 } });
[
{
_id: 1,
name: 'Ada Lovelace',
logins: [ { at: ISODate('2026-09-27T18:45:00Z'), ip: '198.51.100.7' } ]
}
]
A positive number takes from the start, a negative number from the end, and a two-element array [skip, limit] gives you pagination within an array:
db.posts.find({ _id: postId }, { comments: { $slice: [20, 10] } }); // comments 21-30
One quirk: in find() projections, $slice on its own behaves like an exclusion projection for the rest of the document. { logins: { $slice: -1 } } returns every other field too. Combine it with inclusions, as in the example above, if you want only specific fields.
$elemMatch in Projection
The projection form of $elemMatch returns the first array element that matches a condition:
db.users.find({}, { name: 1, addresses: { $elemMatch: { primary: true } } });
[
{ _id: 1, name: 'Ada Lovelace', addresses: [ { label: 'home', city: 'London', primary: true } ] },
{ _id: 2, name: 'Grace Hopper', addresses: [ { label: 'home', city: 'Arlington', primary: true } ] }
]
The condition is independent of the query filter, so you can use it even when you're not filtering on the array. Documents with no matching element are still returned, just without the addresses field.
The Positional $ Operator
The positional $ projection returns the first element that matched the query filter:
db.users.find({ "addresses.city": "Cambridge" }, { name: 1, "addresses.$": 1 });
[
{ _id: 1, name: 'Ada Lovelace', addresses: [ { label: 'work', city: 'Cambridge', primary: false } ] }
]
The array must appear in the filter for $ to know which element you mean. If the filter has conditions on several arrays, the result is undefined, so keep it to one.
All three operators return at most the first match ($elemMatch and $) or a positional range ($slice). If you need every matching element, use an aggregation with $filter, shown below.
Computed Fields in Projections
Since MongoDB 4.4, find() projections accept aggregation expressions, so you can rename, compute, or reshape fields without switching to an aggregation pipeline:
db.users.find(
{},
{
_id: 0,
displayName: "$name",
city: "$profile.city",
loginCount: { $size: "$logins" },
isPaid: { $ne: ["$plan", "free"] },
},
);
[
{ displayName: 'Ada Lovelace', city: 'London', loginCount: 3, isPaid: true },
{ displayName: 'Grace Hopper', city: 'Arlington', loginCount: 1, isPaid: false }
]
This is great for shaping API responses at the database. Keep it to simple expressions; for heavy transformations, an aggregation pipeline is clearer.
Projection in Aggregation Pipelines
In aggregations, the same ideas live in a few stages:
$projectworks like afind()projection, with full expression support.$unsetremoves fields (a readable shorthand for an exclusion$project).$set(alias$addFields) adds or overwrites fields and keeps everything else.
To return every matching array element, rather than only the first, use $filter:
db.users.aggregate([
{ $match: { _id: 1 } },
{
$project: {
_id: 0,
name: 1,
recentLogins: {
$filter: {
input: "$logins",
as: "l",
cond: { $gte: ["$$l.at", ISODate("2026-09-24T00:00:00Z")] },
},
},
},
},
]);
[
{
name: 'Ada Lovelace',
recentLogins: [
{ at: ISODate('2026-09-25T09:30:00Z'), ip: '203.0.113.4' },
{ at: ISODate('2026-09-27T18:45:00Z'), ip: '198.51.100.7' }
]
}
]
A useful habit in pipelines: project or unset large fields early, right after $match, so later stages move less data through memory. The optimizer handles some of this automatically, but being explicit costs nothing.
Projection in Drivers and ODMs
Node.js Driver
The Node.js driver takes the projection as an option, or via .project() on the cursor:
const users = db.collection("users");
const nav = await users.findOne(
{ _id: userId },
{ projection: { name: 1, avatarUrl: 1, _id: 0 } },
);
const list = await users
.find({ plan: "pro" })
.project({ name: 1, email: 1 })
.toArray();
A common bug is passing the projection as the second argument directly, the way the shell does: users.findOne(filter, { name: 1 }). The driver treats that object as options, finds no projection key, and returns the whole document without any error.
Mongoose
Mongoose uses .select(), which accepts an object or a space-separated string. A leading minus means exclude:
const nav = await User.findById(userId).select("name avatarUrl -_id").lean();
const safe = await User.find({ plan: "pro" }).select("-passwordHash").lean();
Mongoose also lets you mark a field as hidden by default in the schema, which is the best way to protect secrets like password hashes:
const userSchema = new Schema({
email: String,
passwordHash: { type: String, select: false },
});
// Opt back in only where you need it, e.g. during login
const user = await User.findOne({ email }).select("+passwordHash");
PyMongo
In PyMongo, the projection is the second argument, just like the shell. You can pass a dict or a list of field names to include:
nav = db.users.find_one({"_id": 1}, {"name": 1, "avatarUrl": 1, "_id": 0})
pro_users = db.users.find({"plan": "pro"}, ["name", "email"])
Performance: What Projection Does and Doesn't Save
Projection reduces the data sent over the network and the work your driver does to decode it. For large documents or large result sets, that's a significant saving in latency and application memory.
What projection doesn't do, in most cases, is reduce the work the server does to read the document. WiredTiger stores whole documents, so the server typically loads the entire document into its cache and then strips fields before sending. If a document is 2 MB and you project one field, the server still reads 2 MB.
The exception is a covered query, where the index alone can answer the query without touching documents at all. For that, every field in the filter and the projection must be in the index, and _id must be excluded unless it's part of the index:
db.users.createIndex({ email: 1, name: 1 });
db.users
.find({ email: "ada@example.com" }, { name: 1, _id: 0 })
.explain("executionStats");
In the explain output, look for totalDocsExamined: 0 and a plan with IXSCAN and PROJECTION_COVERED and no FETCH stage. That means MongoDB answered from the index alone. Covered queries are particularly valuable for high-frequency lookups like authentication checks and autocomplete. More on reading explain output in Using Explain to Analyze and Debug Slow MongoDB Queries.
If you consistently need a small slice of a very large document, projection is a band-aid. Consider splitting the rarely used, heavy parts (like the full login history) into a separate collection.
Common Mistakes
Forgetting that _id is included by default. Inclusion projections always return _id unless you set _id: 0. That's harmless for most use cases, but it breaks covered queries and can leak internal ids into public responses.
Mixing inclusion and exclusion. { name: 1, passwordHash: 0 } is an error. Choose one style per projection; for API responses, prefer inclusion.
Passing the projection in the wrong place in the Node.js driver. It belongs in { projection: {...} } or .project(). The shell-style second argument silently returns full documents.
Relying on exclusion to hide secrets. A denylist protects only the fields you remembered. Use inclusion projections for responses, and select: false in Mongoose for sensitive fields.
Expecting $elemMatch or $ to return all matches. They return the first matching element only. Use $filter in an aggregation for all matches.
Assuming projection makes the server read less. It saves network and client work, but the server still reads whole documents unless the query is covered by an index.
Conclusion
Projection lets you ask MongoDB for exactly the fields you need. Inclusion projections ({ field: 1 }) act as an allowlist and are the safest choice for API responses. Exclusion projections ({ field: 0 }) trim a few fields from otherwise complete documents. Dot notation reaches into nested documents, $slice, $elemMatch, and $ trim arrays, and aggregation expressions let you compute and rename fields on the way out. When the index contains everything the query needs, projection can even make the query covered and skip documents entirely.
For a next step, find the busiest read endpoint in your application and check whether it fetches whole documents. Add an inclusion projection with just the fields the response uses, and compare the response size before and after.


