MongoDB Projection: Selecting Fields with find() and $project

6 min readMongoDB

By default, MongoDB returns entire documents. For wide documents (or ones with large embedded arrays), that wastes network bandwidth and driver deserialization time on fields you never read. Projection fixes this: you tell MongoDB which fields to return.

There are two places projection happens: the second argument to find(), and the $project (or $set/$unset) stages in an aggregation pipeline. They share concepts but have different capabilities, and the find-side array operators ($, $elemMatch, $slice) have rules that trip people up regularly. This guide covers all of it.

Inclusion and exclusion in find()

A projection document maps field names to 1 (include) or 0 (exclude):

// Return only name and email (plus _id, which is included by default)
db.users.find({ status: "active" }, { name: 1, email: 1 })
 
// Return everything EXCEPT the audit trail
db.users.find({ status: "active" }, { auditLog: 0 })

The rule that catches everyone: you cannot mix inclusion and exclusion in the same projection, with one exception. _id is always returned unless you explicitly exclude it, and excluding it is allowed alongside inclusions:

// Valid: inclusion projection + _id suppression
db.users.find({}, { name: 1, email: 1, _id: 0 })
 
// Invalid: mixing inclusion and exclusion -> error
db.users.find({}, { name: 1, auditLog: 0 })
// MongoServerError: Cannot do exclusion on field auditLog in inclusion projection

The reasoning is sound once you see it: an inclusion projection defines the complete output shape, so excluding a field inside it is contradictory.

Nested fields

Use dot notation. Projecting a nested field preserves the embedding structure:

db.orders.find({}, { "customer.name": 1, "customer.address.city": 1 })
// Returns: { _id: ..., customer: { name: "...", address: { city: "..." } } }

Projecting a parent field ({ customer: 1 }) returns the whole embedded document.

Array projection operators in find()

Three operators control which elements of an array come back. These work only in find/findOne/findAndModify projections, not in $project.

$slice: first N, last N, or a page

// First 5 comments
db.posts.find({}, { comments: { $slice: 5 } })
 
// Last 5 comments
db.posts.find({}, { comments: { $slice: -5 } })
 
// Skip 20, return 10 (page 3 of comments)
db.posts.find({}, { comments: { $slice: [20, 10] } })

One behavioral quirk: a projection containing only $slice does not act as an inclusion projection. All other fields are still returned. If you want just the sliced array, add explicit inclusions.

The positional $ operator

$ returns the first array element that matched the query condition. The array field must appear in the query for $ to know what "matched" means:

// Return only the first grade >= 90, not the whole grades array
db.students.find(
  { grades: { $gte: 90 } },
  { "grades.$": 1 }
)

Rules (enforced since MongoDB 4.4): only one positional $ per projection, it must be the last component of the field path, and you cannot combine it with a $slice expression in the same projection.

$elemMatch (projection)

Like $, but the condition lives in the projection itself, so it can differ from the query condition:

// Find students with any grade, but project only the first failing one
db.students.find(
  { semester: "2026-1" },
  { name: 1, grades: { $elemMatch: { $lt: 60 } } }
)

If no element matches, the field is simply omitted from that document. Both $ and $elemMatch return at most one element. There is no find-side operator that returns "all matching elements". For that you need aggregation $filter; see our arrays guide.

Aggregation expressions in find() projections (4.4+)

Since MongoDB 4.4, find projections accept aggregation expressions, which means computed fields without switching to an aggregation pipeline:

db.orders.find({}, {
  customer: 1,
  total: 1,
  isLarge: { $gt: ["$total", 1000] },
  itemCount: { $size: "$items" }
})

This blurs the old line between find and aggregate. For read paths that need light computation, it saves a pipeline.

$project in aggregation pipelines

$project is the pipeline equivalent, with full expression support: renaming, computation, nested reshaping, and conditional removal via $$REMOVE:

db.orders.aggregate([
  { $project: {
      orderId: "$_id",        // rename
      _id: 0,
      customer: 1,
      total: { $sum: "$items.price" },          // compute
      coupon: { $cond: [ { $eq: ["$coupon", ""] }, "$$REMOVE", "$coupon" ] }
  } }
])

Since 4.2, $set (add/overwrite a few fields) and $unset (drop a few fields) are usually cleaner than $project when you do not want to restate the whole output shape. Reach for $project when you are deliberately defining the final shape; reach for $set/$unset for incremental tweaks mid-pipeline.

Pipeline placement matters: a $project early in the pipeline can reduce the working set carried through later stages, but the query planner already does dependency analysis and only carries fields later stages need, so manual early projection mostly helps when intermediate documents would otherwise blow the 100MB per-stage memory limit.

Covered queries: projection as a performance tool

If every field in the query and the projection lives in one index, MongoDB answers from the index alone and never touches the documents. This is a covered query, and it is the single biggest read optimization projection enables:

db.users.createIndex({ email: 1, status: 1 })
 
// Covered: query + projection both within the index, _id excluded
db.users.find(
  { email: "a@example.com" },
  { email: 1, status: 1, _id: 0 }
)

The classic mistake is forgetting _id: 0. _id is returned by default, it is (usually) not in your compound index, so MongoDB must fetch the document and the query is no longer covered. In explain() output, a covered query shows IXSCAN with no FETCH stage and totalDocsExamined: 0. More on reading explain output in our indexes guide.

Common mistakes

  • Expecting projection to filter documents. Projection shapes fields, not results. A document with no matching array element still comes back (minus the field). Filtering is the query's job.
  • Mixing 1s and 0s. Only _id: 0 may coexist with inclusions.
  • Using $ without the array in the query. The positional operator errors or behaves unpredictably when the query has no condition on that array.
  • Assuming $slice-only projections hide other fields. They do not; add explicit inclusions.
  • Projecting in the driver instead of the database. Fetching full documents and picking fields in application code still pays the full network and BSON-decoding cost. Push projection to the server.

Quick reference

GoalTool
Return some fieldsfind(query, { a: 1, b: 1 })
Hide some fieldsfind(query, { secret: 0 })
First matching array element (query condition)"arr.$": 1
First matching array element (own condition)arr: { $elemMatch: {...} }
First/last/page of N elementsarr: { $slice: ... }
All matching array elementsaggregation $filter
Computed fieldsexpressions in find (4.4+) or $project/$set
Read without touching documentscovered query: index + projection + _id: 0

Mako connects to MongoDB with AI-powered autocomplete that knows your collection shapes, which makes writing projections against unfamiliar schemas considerably less guess-driven. Try it free at mako.ai.

Mako — open source

Skip the terminal. Use Mako.

Connect your database, write queries with AI assistance, and import/export data in clicks. Free to start.