$sort, $limit, and $skip in Pipelines

Once an aggregation pipeline has matched and shaped documents, you almost always need to order them and take just a slice of the results — the top 10 best-selling products, page 3 of a search result, the 5 most recent orders. That is the job of three simple but easy-to-misuse stages: $sort, $limit, and $skip. They sound trivial, but stage order, index usage, and memory limits make a real difference to correctness and performance.

Overview / How it works

An aggregation pipeline is an array of stages, and MongoDB processes documents through it one stage at a time, left to right. Each stage receives the stream of documents produced by the previous stage and passes a new stream to the next one. $sort, $limit, and $skip don’t change the shape of a document (unlike $project or $group) — they only change the order and the set of documents that flow onward.

$sort reorders the document stream according to one or more fields, ascending (1) or descending (-1). If the field used for sorting is indexed and the sort is the first stage in the pipeline (or comes right after a $match that can use the same index), MongoDB’s query planner can use the index to produce documents already in sorted order — no separate sort step needed. If there’s no usable index, MongoDB performs a blocking sort: it must gather every document that reaches the stage into memory before it can emit the first sorted document. That in-memory sort is capped at 100MB by default; exceed it and MongoDB throws an error unless you pass { allowDiskUse: true } as an option to aggregate(), which lets it spill to temporary files on disk (slower, but won’t fail).

$limit simply stops the stream after N documents have passed through. $skip discards the first N documents and passes the rest through untouched. Neither stage understands what came before it — a $limit placed before a $sort limits the unsorted stream, which is almost never what you want.

There’s an important optimization worth knowing about: when $limit immediately follows $sort, the MongoDB server recognizes the pattern and performs a top-k sort. Instead of sorting the entire input set, it only needs to track the top k documents (say, the top 5) as it scans, using far less memory than a full sort of millions of documents. This is one reason the classic “top N results” pipeline ($sort then $limit) is so efficient compared to sorting everything and discarding most of it yourself.

Syntax

db.collection.aggregate([
  { $sort: { field1: 1, field2: -1 } },
  { $skip: numberOfDocsToSkip },
  { $limit: numberOfDocsToReturn }
]);
Stage Argument Meaning
$sort { field: 1 } or { field: -1 } Sort ascending (1) or descending (-1). Multiple fields sort like a compound SQL ORDER BY, applied left to right for ties.
$skip a non-negative integer Discards that many documents from the front of the stream before passing the rest along.
$limit a positive integer Stops the stream after that many documents have passed through this stage.

All three stages can appear multiple times in a single pipeline (a second $sort after a $group is common), and there is no requirement to use all three together — but for pagination you’ll typically use all three in the order $sort, $skip, $limit.

Examples

Example 1: Basic descending sort. Suppose db.orders holds documents like { customerName: "Priya Shah", total: 249.99, status: "shipped", orderDate: ISODate(...) }. To see the highest-value orders first:

db.orders.aggregate([
  { $sort: { total: -1 } }
]);
[
  { _id: ObjectId("..1"), customerName: "Priya Shah", total: 899.50, status: "shipped" },
  { _id: ObjectId("..2"), customerName: "Alex Kim", total: 620.00, status: "pending" },
  { _id: ObjectId("..3"), customerName: "Maria Lopez", total: 249.99, status: "shipped" }
  // ...remaining documents, largest total first
]

Every document flows through, just reordered by total descending.

Example 2: Top-N with $sort + $limit. To get only the 5 highest-value orders (a common “leaderboard” query):

db.orders.aggregate([
  { $sort: { total: -1 } },
  { $limit: 5 }
]);
[
  { customerName: "Priya Shah", total: 899.50 },
  { customerName: "Devon Clarke", total: 875.25 },
  { customerName: "Alex Kim", total: 620.00 },
  { customerName: "Sam Osei", total: 588.10 },
  { customerName: "Maria Lopez", total: 570.00 }
]

Because $limit directly follows $sort, the server can apply the top-k optimization: it never needs to fully sort every order in the collection, only keep track of the current top 5 as it scans.

Example 3: Paginating with $match, $sort, $skip, and $limit. To show page 3 of shipped orders, 10 per page, most recent first:

const pageSize = 10;
const page = 3;

db.orders.aggregate([
  { $match: { status: "shipped" } },
  { $sort: { orderDate: -1 } },
  { $skip: (page - 1) * pageSize },
  { $limit: pageSize }
]);
[
  { customerName: "Jonah Reyes", orderDate: ISODate("2026-06-30T00:00:00Z"), status: "shipped" },
  { customerName: "Wei Zhang", orderDate: ISODate("2026-06-29T00:00:00Z"), status: "shipped" }
  // ...8 more documents
]

$match runs first so $sort only has to order the shipped orders, not the whole collection; $skip then discards the first 20 (pages 1 and 2), and $limit takes the next 10.

How it works step by step

For the pagination example above, MongoDB’s query planner first checks whether an index exists that satisfies both the $match filter on status and the $sort on orderDate — a compound index like { status: 1, orderDate: -1 } would let it satisfy both in one index scan (IXSCAN), emitting already-sorted documents with no blocking sort stage at all. Without that index, MongoDB scans the collection (COLLSCAN) or uses a less ideal index, gathers all matching documents, and sorts them in memory before $skip and $limit can run. You can confirm which path is taken by appending .explain("executionStats") to the aggregation and inspecting the stage names in the plan.

Whichever path is used, $skip still has to walk past every skipped document — it does not “jump” to an offset. Skipping 20 documents costs little; skipping 200,000 means MongoDB (or the underlying index scan) still has to advance through 200,000 entries every single time that page is requested, even though none of them are returned.

Common Mistakes

Mistake 1: Putting $limit before $sort.

// Wrong: limits an arbitrary, unsorted set of 5 documents, then sorts just those 5
db.orders.aggregate([
  { $limit: 5 },
  { $sort: { total: -1 } }
]);

This does not return the 5 highest-value orders — it grabs whatever 5 documents happen to come first (natural/insertion order or index order), and only sorts those. The fix is to always sort before limiting when you want the “top N”:

// Correct
db.orders.aggregate([
  { $sort: { total: -1 } },
  { $limit: 5 }
]);

Mistake 2: Using $skip for deep pagination on a large collection. Jumping straight to page 5,000 of results with { $skip: 49990 }, { $limit: 10 } forces MongoDB to walk past nearly 50,000 documents on every request, even with a supporting index — a real, growing cost as users page deeper. For large, frequently-paginated collections, prefer range-based (cursor) pagination: remember the sort key of the last document on the current page, and query for documents strictly after it.

// Range-based pagination instead of a large $skip
// lastSeenDate is the orderDate of the last document on the previous page
db.orders.aggregate([
  { $match: { status: "shipped", orderDate: { $lt: lastSeenDate } } },
  { $sort: { orderDate: -1 } },
  { $limit: 10 }
]);

This uses the index to jump straight to the right starting point instead of scanning past every earlier document.

Mistake 3: Sorting a large unindexed field and hitting the memory limit. If total has no index and the collection is large, { $sort: { total: -1 } } alone (without a following $limit) may throw an error like Sort exceeded memory limit of 104857600 bytes. Either add an index on the sort field, restructure the pipeline so a $match shrinks the set first, or explicitly allow disk use:

db.orders.aggregate(
  [ { $sort: { total: -1 } } ],
  { allowDiskUse: true }
);

Best Practices

  • Always put $sort before $limit when you want a “top N” result — never the reverse.
  • Put $match as early as possible so $sort and later stages work on the smallest possible document set.
  • Create an index that matches your sort field(s) (and any preceding equality filter) so MongoDB can avoid a blocking in-memory sort — check with .explain() for IXSCAN vs a sort stage.
  • For deep pagination on large collections, use range/cursor-based pagination (a $match on “after the last seen value”) instead of a large $skip.
  • Only reach for { allowDiskUse: true } as a fallback for large, necessary sorts — it’s slower than an in-memory or index-backed sort.
  • When sorting on a field that can tie (e.g. many orders with the same total), add a unique tiebreaker field (like _id) to the sort so pagination results stay stable across pages.

Practice Exercises

  • Given a db.products collection with a price field, write an aggregation pipeline that returns the 3 cheapest products.
  • Using db.orders, write a pipeline that returns page 2 (10 per page) of orders with status: "delivered", sorted by orderDate descending. Then rewrite it using range-based pagination assuming you know the orderDate of the last document on page 1.
  • Run .explain("executionStats") on a pipeline that sorts by an unindexed field, and identify in the output whether MongoDB used an index scan or a blocking sort stage.

Summary

  • $sort reorders the document stream; $limit stops it after N documents; $skip discards the first N documents.
  • Stage order matters: $sort must come before $limit to get a true “top N”, and $match should come before $sort to shrink the working set.
  • A $sort immediately followed by $limit gets a top-k optimization, avoiding a full sort of the entire input.
  • Without a supporting index, $sort performs a blocking in-memory sort capped at 100MB unless allowDiskUse: true is set.
  • $skip still has to walk past every skipped document, making it a poor fit for deep pagination — prefer range/cursor-based pagination for large collections.