skip to content

Projections, Cursors & Pagination

Controlling what comes back and how it streams, plus why skip-based paging degrades on large collections. Pagination design is one of the most frequently asked practical MongoDB questions.

part ofMongoDBoverview, primer and where to startread it →
on this pageshow

questions

5

In a MongoDB find() projection, can you mix included and excluded fields?

level: juniorimportance: must knowfreq 72%

answer

  1. A projection has exactly one mode
  2. Include-list or exclude-list, never both
  3. One field is allowed to break the rule
  4. _id comes back unless you say otherwise
  5. {title: 1, _id: 0} is the sanctioned mix

basics

~10 s

No. A find() projection is either an inclusion list or an exclusion list, and mixing the two raises an error. The single exception is _id, which may be excluded inside an otherwise inclusive projection.

solid answer

~40 s

A `find()` projection document maps field names to `1`/`true` (include) or `0`/`false` (exclude). You must pick one mode: `{title: 1, author: 1}` returns only those fields, `{internalNotes: 0}` returns everything else. Writing `{title: 1, author: 0}` fails with a projection error because the server cannot tell what to do with the fields you named neither way. The one legal mix is `_id`: it is returned by default, so `{title: 1, _id: 0}` is valid and common. In an exclusion projection, all unnamed fields — including `_id` — still come back. Dot notation projects sub-fields (`{"address.city": 1}`), and the same single-mode rule applies to them.

code

javascript · 11 lines
javascript
// inclusion: only the named fields (plus _id)
db.books.find({}, { title: 1, author: 1 })

// inclusion with _id turned off - the one legal mix
db.books.find({}, { title: 1, _id: 0 })

// exclusion: everything except the named fields
db.books.find({}, { draftNotes: 0 })

// error: cannot mix inclusion and exclusion
db.books.find({}, { title: 1, draftNotes: 0 })

go deeper

for a junior

Memorise the two forms and the one exception: an all-1s projection, an all-0s projection, and _id which you may set to 0 inside an inclusion projection. Be ready to write both on a whiteboard.

for a middle

Explain why the mixing rule exists — the two modes imply opposite defaults for unnamed fields — and know that any non-zero number means include, so -1 is not an exclusion.

for a senior

Show where projection actually saves cost: network and driver deserialization, not storage reads, unless the query is covered by an index. Mention _id: 0 as a prerequisite for most covered queries.

for a principal

Frame projection as part of an API contract: a stable minimal shape returned to clients limits accidental data exposure and keeps payloads predictable as documents grow new fields.

## What a projection is The second argument to `find()` (and the `projection` option on the find command) is a document that tells the server which fields of each matching document to send back. Reducing the returned fields cuts network bytes, driver deserialization cost, and application memory — it does **not** change which documents match, which is the filter's job. ## The two modes A projection operates in exactly one of two modes: - **Inclusion**: every named field maps to a truthy value (`1`, `true`, or any non-zero number). Only the named fields are returned. - **Exclusion**: every named field maps to `0` or `false`. Every field *except* the named ones is returned. ```javascript db.books.find({}, { title: 1, author: 1 }) // _id, title, author db.books.find({}, { draftNotes: 0 }) // everything but draftNotes ``` Mixing the modes — `{ title: 1, draftNotes: 0 }` — is rejected by the server with a projection error, because the two modes imply contradictory defaults for the fields you did not mention. Inclusion says "default is drop"; exclusion says "default is keep". There is no coherent answer for the unnamed fields, so MongoDB refuses rather than guessing. Note that the value only has to be truthy or falsy: `{ title: -1 }` is an **inclusion**, not an exclusion, because `-1` is non-zero. This surprises people who expect `-1` to mean "remove", by analogy with sort direction. ## The _id exception `_id` is returned by default in an inclusion projection even though you did not ask for it — it is the document's identity, and drivers and application code rely on it. Because it is the one field with an implicit default, it is also the one field you may override in the other direction: ```javascript db.books.find({}, { title: 1, _id: 0 }) // just title ``` This is legal and is the standard way to get a clean, minimal shape for an API response. The reverse — `{ draftNotes: 0, _id: 1 }` — is pointless (exclusion already returns `_id`) and is not the same kind of sanctioned exception; keep exclusion projections pure. ## Nested fields and dot notation Projections reach into embedded documents with dot notation: ```javascript db.users.find({}, { "address.city": 1, "address.zip": 1, _id: 0 }) ``` The result preserves the nesting: each document comes back as `{ address: { city: ..., zip: ... } }`, not with flattened `"address.city"` keys. The single-mode rule still binds — you cannot include `address.city` and exclude `address.street` in the same projection. If you want "all of address except one field", use an exclusion projection naming that one field: `{ "address.street": 0 }`. An equivalent nested-document form exists (`{ address: { city: 1 } }`), but dot notation is the idiom and behaves more predictably when arrays are involved. ## Arrays and the operator forms Besides plain `1`/`0`, a projection may use the array projection operators `$slice`, `$elemMatch`, and the positional `$` on an array field, which return a subset of that array's elements rather than the whole array. These do not count as inclusion or exclusion for the mode rule in the way plain values do, but the safest habit is to keep them inside an inclusion projection and let the plain fields set the mode. ## Projections do not remove work from the server A common misreading is that projecting fewer fields makes the query cheaper on the server. In general the server still has to read the whole document from storage, then strip fields before sending. The saving is in network and client-side cost. The exception is a **covered query**, where every field the query needs is present in an index, and the document is never fetched at all — that requires `_id: 0` in the projection unless `_id` is part of the index. ## Aggregation-style projections Recent MongoDB versions (4.4 and later) also accept aggregation expressions in a `find()` projection, so you can compute a value, for example `{ fullName: { $concat: ["$first", " ", "$last"] } }`. Adding such a computed field puts the projection in inclusion mode, since you are describing what to emit rather than what to strip. ## What to say in an interview State the rule (one mode per projection), name the `_id` exception, show `{ title: 1, _id: 0 }`, and add that projection saves network and client cost rather than server read cost unless the query is covered.

  • Why is {title: 1, _id: 0} allowed when mixing inclusion and exclusion normally fails?
    Because `_id` is the only field with an implicit default. An inclusion projection would return it even though you did not name it, so MongoDB lets you override that single default explicitly. Every other field's presence follows purely from the projection's mode, so naming one in the opposite direction would be contradictory.
  • Does projecting fewer fields reduce the work the server does to read the documents?
    Usually not. The server still reads the full document from storage and then strips fields before sending, so the saving is network bandwidth and client-side deserialization. The exception is a covered query, where every field needed is in the index and the document is never fetched — that normally requires excluding `_id` unless it is part of the index.
  • How do you project two fields out of an embedded address sub-document?
    Use dot notation: `{ "address.city": 1, "address.zip": 1, _id: 0 }`. The result keeps the nesting, coming back as `{ address: { city, zip } }` rather than with flattened keys. The one-mode rule still applies, so you cannot include one address sub-field and exclude another in the same projection.

saying these in an interview costs you the question

  • Claims any field can be freely mixed with 0 and 1
  • Thinks {field: -1} excludes the field
  • Assumes _id disappears when it is not listed
  • Says projection makes the server read less data
  • Expects dotted keys to come back flattened in results

context

open as a page

Why does MongoDB's skip(200000).limit(20) get slower the deeper the page number goes?

level: seniorimportance: must knowfreq 74%

basics

~20 s

skip() is not a seek: the server walks and discards every skipped entry before returning anything, so cost grows with the offset. Range (keyset) paging replaces it with a filter on the last seen sort key, which the index seeks to directly.

open as a page

How do the $slice and $elemMatch projection operators limit which array elements are returned?

level: middleimportance: should knowfreq 52%

basics

~20 s

$slice returns a positional window of an array: a count from the front, a negative count from the back, or a [skip, limit] pair. $elemMatch returns only the first array element matching a condition, dropping the field entirely when nothing matches.

open as a page

What does cursor.batchSize() change about how a MongoDB find() returns documents?

level: middleimportance: should knowfreq 44%

basics

~20 s

batchSize() sets how many documents the server puts in each network batch, not how many the query returns in total. It tunes round trips against per-batch memory; limit() is what caps the total number of documents.

open as a page

A batch job iterating a MongoDB cursor fails with CursorNotFound — what causes that and how do you avoid it?

level: seniorimportance: should knowfreq 38%

basics

~20 s

The server closed the cursor before the job asked for the next batch — usually the 10-minute idle-cursor timeout, an expired logical session, or a primary stepdown. Fix it by not holding a long-lived cursor: page with a resumable range query instead.

open as a page