Skip to content

Projection, sort, and pagination

A filter decides which documents come back. Three more controls decide what those documents look like on the way to your app: projection trims the fields, sort fixes the order, and limit with skip carves the result into pages. Together they keep you from dragging whole documents and whole collections across the network when you wanted a name and a page of ten. This lesson covers all four, and ends with the one pagination trick that quietly gets slow.

We are back to the simple library members, each with a name, joined year, and fines.

A projection is a second document passed to find that lists which fields to keep. Set a field to 1 to include it; everything you do not list is dropped — except _id, which is returned unless you explicitly exclude it with 0. Here we want names and join years only, with no _id:

db.members.find(
{ fines: 0 },
{ name: 1, joined: 1, _id: 0 }
)

Only the requested fields arrive, and _id is gone:

[
{ "name": "Margaret", "joined": 2023 },
{ "name": "Ada", "joined": 2024 }
]

You may include fields or exclude them, but not mix the two in one projection — the only exception is excluding _id alongside an include list, as above.

sort orders the result by one or more fields: 1 is ascending, -1 is descending. Ties are broken by later keys you list. Here we list everyone from most fines to fewest:

db.members.find().sort({ fines: -1 })

The highest balances come first:

[
{ "name": "Linus", "joined": 2023, "fines": 5 },
{ "name": "Grace", "joined": 2021, "fines": 3 },
{ "name": "Margaret", "joined": 2023, "fines": 0 }
]

limit caps how many documents you receive; skip discards that many from the front first. Combined with a stable sort, they produce pages. For a second page of two, sorted by name, you skip the first two and take two:

db.members.find()
.sort({ name: 1 })
.skip(2)
.limit(2)

You get the third and fourth members in name order — page two:

[
{ "name": "Grace", "joined": 2021, "fines": 3 },
{ "name": "Linus", "joined": 2023, "fines": 5 }
]

The server applies these stages in a fixed order, regardless of the order you chained the methods: it filters first, then sorts the matches, then skips, then limits. Knowing the order explains why a sort is needed for stable pages and why a big skip is wasteful.

flowchart LR
  C["Collection"] --> F["find filter"]
  F --> S["sort"]
  S --> SK["skip n"]
  SK --> L["limit k"]
  L --> R["Cursor of k documents"]
A read flows through find, then sort, then skip, then limit — in that order — before the cursor reaches you

find returns a cursor, not a list. The driver pulls documents in batches as you iterate, so you can stream a large result without loading it all into memory at once. Calling toArray (or its equivalent) simply drains the cursor for you:

const cursor = db.members.find().sort({ name: 1 })
while (cursor.hasNext()) {
printjson(cursor.next())
}

skip(n) does not jump to the nth document — the server must still walk past the first n matches before it can return anything. On page two that is trivial, but on page 5,000 the database churns through hundreds of thousands of documents only to throw them away. The fix is range-based pagination: instead of skipping, remember the last value you saw and ask for documents after it. To continue past the last member named “Linus”, you would filter forward rather than skip:

db.members.find({ name: { $gt: "Linus" } })
.sort({ name: 1 })
.limit(2)

This always reads only the page you want, no matter how deep you are. We will return to range-based pagination and the indexes that make it fast in a later module — for now, just know that skip is fine for shallow pages and a problem for deep ones.

In Compass: the Documents tab has dedicated controls for all of this — a Project box for the field list, a Sort box for the order, and skip/limit inputs beside the filter bar, so you can build the whole query without writing the method chain by hand.

  • Always sort before you page. Without an explicit sort, the order of results is not guaranteed, so skip/limit can return overlapping or missing documents between pages. A stable sort on a unique-enough field is what makes pagination correct.
  • _id is included unless you exclude it. Add _id: 0 to a projection when you do not want it; this is the one case where you may mix exclusion with an include list.
  • Do not mix include and exclude otherwise. A projection is either a list of fields to keep or a list to drop, not both.
  • Large skip costs grow with the page number. For deep pages, switch to range-based pagination on an indexed field, which reads only the documents you actually return.
In a projection, what does { name: 1, _id: 0 } return?
What does sort({ fines: -1 }) do?
Why should you always sort before using skip and limit?
Why does a large skip(n) get slow?