sort, limit, and skip

Once a query has decided which documents match, you often still need to control how many come back and in what order. That’s the job of three cursor methods: sort(), limit(), and skip(). They don’t change which documents match a filter — they reshape the stream of results after matching, which makes them essential for building top-N lists, dashboards, and paginated APIs.

Overview: Cursor Methods, Not Query Operators

find() returns a cursor, a pointer to a result set that mongosh iterates lazily. sort(), skip(), and limit() are methods you chain onto that cursor to modify how it’s consumed. A key detail: you can write them in any order in the chain (find().sort().skip().limit() or find().limit().sort().skip()) and MongoDB applies them in the same logical order internally regardless: first sort, then skip, then limit. Writing them in that order in your code is just a convention for readability.

sort() takes a document mapping field names to a direction: 1 for ascending, -1 for descending. When a field holds mixed BSON types across documents (some numbers, some strings, some nulls), MongoDB sorts using a fixed BSON type-comparison order (roughly: MinKey, null, numbers, strings, objects, arrays, binary data, ObjectId, boolean, date, timestamp, regex, MaxKey) rather than throwing an error — another reminder that a collection’s schema flexibility means you should validate types you rely on for sorting.

Under the hood, a sort can be satisfied two ways: MongoDB can walk an index that’s already in the requested order (fast, streaming, no memory limit), or it can pull matching documents into memory and sort them there (a blocking SORT stage). In-memory sorts are capped — by default a sort that needs more than 100MB of memory throws an error unless you explicitly opt in to spilling to disk. This is why pairing sort() with the right index matters far more than it looks like it should.

Syntax

db.collection.find(<query>, <projection>)
  .sort(<sort spec>)
  .skip(<number>)
  .limit(<number>);
Method Argument Effect
sort() Document like { field: 1 } or { field: -1 }. Multiple fields create a compound sort, applied left to right. Orders the result set. 1 = ascending, -1 = descending.
limit() A non-negative integer. Caps the number of documents returned. limit(0) means “no limit.” A negative limit behaves like the same positive value but forces the cursor to return everything in a single batch and then close.
skip() A non-negative integer. Discards that many documents from the front of the (already sorted) result set before returning the rest.

Examples

Example 1: Simple ascending and descending sort

db.products.find(
  { category: "electronics" },
  { name: 1, price: 1, _id: 0 }
).sort({ price: 1 });
[
  { name: 'USB-C Cable', price: 8.99 },
  { name: 'Wireless Mouse', price: 19.99 },
  { name: 'Bluetooth Speaker', price: 45.5 },
  { name: 'Noise-Cancelling Headphones', price: 129.99 }
]

The filter narrows to the electronics category, then sort({ price: 1 }) orders the matches from cheapest to most expensive. Flip it to { price: -1 } and the same four documents come back most-expensive first.

Example 2: Top-N with sort + limit

db.products.find(
  {},
  { name: 1, rating: 1, _id: 0 }
).sort({ rating: -1 }).limit(5);
[
  { name: 'Noise-Cancelling Headphones', rating: 4.8 },
  { name: 'Standing Desk', rating: 4.7 },
  { name: 'Mechanical Keyboard', rating: 4.6 },
  { name: 'Ergonomic Chair', rating: 4.6 },
  { name: 'Bluetooth Speaker', rating: 4.5 }
]

This is the classic “top 5” pattern: sort by the ranking field, then limit() the stream. MongoDB doesn’t need to sort the whole collection in memory to do this efficiently if there’s a supporting index — it can stop reading as soon as it has 5 matches.

Example 3: Pagination with skip + limit

const pageSize = 10;
const pageNumber = 3;

db.products.find(
  {},
  { name: 1, createdAt: 1, _id: 1 }
)
  .sort({ createdAt: -1, _id: -1 })
  .skip((pageNumber - 1) * pageSize)
  .limit(pageSize);
[
  { _id: ObjectId('66b1f2a1...'), name: 'Desk Lamp', createdAt: ISODate('2026-05-11T09:00:00Z') },
  { _id: ObjectId('66b1f299...'), name: 'Webcam', createdAt: ISODate('2026-05-10T14:20:00Z') },
  // ... 8 more documents
]

To show “page 3” of 10 results per page, skip the first 20 documents (pages 1 and 2) and take the next 10. Note the compound sort { createdAt: -1, _id: -1 }: _id is included as a tiebreaker so that documents with an identical createdAt value still have a fully deterministic order across pages (more on why this matters below).

How It Works Step by Step

When you run find(filter).sort(spec).skip(n).limit(m), the query planner does the following:

1. It evaluates the filter and checks whether an index exists whose key order matches the sort spec (and ideally the filter’s equality fields too, per the Equality-Sort-Range compound index rule). 2. If such an index exists, MongoDB performs an IXSCAN: it walks the index in the exact order the sort requires, so results come out pre-sorted with no extra work. 3. If no such index exists, MongoDB performs a COLLSCAN or an unordered IXSCAN, pulls the matching documents into memory, and runs a blocking SORT stage before anything is returned — this is where the 100MB memory ceiling can bite. 4. Once the result stream is ordered, skip(n) discards the first n documents from that stream one by one (this still costs work even when an index provides the order — MongoDB must walk past each skipped document). 5. Finally limit(m) stops the cursor after m documents, letting MongoDB avoid fetching or sorting anything beyond that.

You can see which path a query takes with explain():

db.products.createIndex({ category: 1, price: 1 });

db.products.find({ category: "electronics" })
  .sort({ price: 1 })
  .explain("executionStats");
{
  queryPlanner: {
    winningPlan: {
      stage: 'FETCH',
      inputStage: {
        stage: 'IXSCAN',
        keyPattern: { category: 1, price: 1 },
        indexName: 'category_1_price_1',
        // no SORT stage present: the index already returns sorted order
      }
    }
  }
}

Because the compound index { category: 1, price: 1 } puts the equality field first and the sort field second, MongoDB reads it directly in price order — no separate SORT stage appears in the plan. If you sorted by a field not covered by any usable index, you’d see an explicit SORT stage in the plan instead, a signal that MongoDB is sorting in memory.

Common Mistakes

Mistake 1: Sorting a large, unindexed field

Sorting on a field with no supporting index forces an in-memory sort that can blow past MongoDB’s default memory ceiling.

// Wrong: no index on `description`, and the collection is large
db.products.find().sort({ description: 1 });
MongoServerError: Sort exceeded memory limit of 104857600 bytes,
but did not opt in to external sorting. Aborting operation.
Pass allowDiskUse:true to opt in.

Fix it by adding an index that supports the sort, or, if an index isn’t practical for a rarely-run query, explicitly allow MongoDB to spill to temporary files on disk:

// Fix option A: index the sort field
db.products.createIndex({ description: 1 });
db.products.find().sort({ description: 1 });

// Fix option B: opt in to an external (disk-backed) sort
db.products.find().sort({ description: 1 }).allowDiskUse();

Mistake 2: Using skip() for deep pagination

skip() doesn’t jump directly to an offset — even with an index providing sorted order, MongoDB still has to walk past and discard every skipped document. On page 10,000 of a feed, that’s tens of thousands of documents thrown away on every request, and it gets linearly slower the deeper you page.

// Wrong: slow and gets worse as pageNumber grows
db.products.find().sort({ createdAt: -1 }).skip(100000).limit(20);

For deep pagination, use range-based (“keyset”) pagination instead: remember the sort key of the last document on the previous page and query for documents strictly beyond it.

// Fix: range query using the last seen createdAt from the previous page
db.products.find({ createdAt: { $lt: lastSeenCreatedAt } })
  .sort({ createdAt: -1 })
  .limit(20);

Mistake 3: Sorting without a tiebreaker field

If the sort field has duplicate values, MongoDB doesn’t guarantee a stable order for those ties across separate queries. Combined with skip()/limit() pagination, this can cause the same document to appear on two pages, or a document to be skipped entirely, as writes happen between page requests.

// Wrong: many products share a rating of 4.6, so page boundaries can shift
db.products.find().sort({ rating: -1 }).skip(10).limit(10);
// Fix: add a unique field (like _id) as a secondary sort key
db.products.find().sort({ rating: -1, _id: 1 }).skip(10).limit(10);

Best Practices

  • Always pair a frequent sort() with a supporting index; check with explain("executionStats") for an IXSCAN and the absence of a separate SORT stage.
  • For compound indexes that support both filtering and sorting, follow the ESR rule: Equality fields first, then Sort fields, then Range fields.
  • Add a unique tiebreaker field (usually _id) to any sort spec used for pagination, so document order is fully deterministic.
  • Prefer range/keyset pagination ($gt/$lt on the last seen sort key) over skip() for large offsets or infinite-scroll UIs.
  • Use limit() aggressively for “top N” style queries — it lets MongoDB stop scanning early when an index provides the order.
  • Avoid unindexed sorts on large collections in production; if one is unavoidable, use allowDiskUse() deliberately rather than letting it fail at the memory ceiling.

Practice Exercises

1. In a db.orders collection with fields status, total, and placedAt, write a query that returns the 3 highest-value "shipped" orders, showing only total and placedAt.

2. Using the same collection, write a paginated query for “page 2” (10 per page) of all orders sorted by placedAt descending, with a tiebreaker on _id to keep the pages stable.

3. Run explain("executionStats") on a sort you’d expect to be slow (sorting by an unindexed field on a large collection) and identify whether the plan shows a SORT stage or an IXSCAN. If you see a SORT stage, what index would remove it?

Summary

  • sort(), skip(), and limit() are cursor methods that reorder and trim an already-matched result set; they don’t affect which documents match.
  • MongoDB always applies sort, then skip, then limit internally, regardless of the order you chain them in your code.
  • A sort backed by a matching index streams in order with no memory limit; an unindexed sort blocks in memory and can hit the default 100MB cap unless you use allowDiskUse().
  • skip() still walks past every skipped document, so it gets slower with larger offsets — use range-based pagination for deep paging.
  • Always include a unique tiebreaker field in a sort used for pagination to guarantee a stable, deterministic order.