← back

Sort, Limit
& Skip

FILE  15_sorting_limiting_skipping
TOPIC  sort() · limit() · skip() · Chaining · Pagination Patterns
LEVEL  Foundation
01
sort()
Ordering result documents
ordering

sort() orders the documents in the result set before they are returned. You pass a document specifying one or more fields and their sort direction.

Direction Values

ValueDirectionExamples
1AscendingA→Z, 0→9, oldest→newest
-1DescendingZ→A, 9→0, newest→oldest

Single-Field Sort

// Cheapest products first (ascending price)
db.products.find({}).sort({ price: 1 })

// Newest orders first (descending createdAt)
db.orders.find({}).sort({ createdAt: -1 })

// Natural insertion order (ascending _id ≈ insertion order for ObjectId)
db.logs.find({}).sort({ _id: 1 })

Compound Sort

Multiple fields are applied left to right. MongoDB sorts by the first field, then uses subsequent fields to break ties.

// Sort by category ascending, then by price descending within each category
db.products.find({}).sort({ category: 1, price: -1 })

// Sort by score descending, then by name ascending for players with equal scores
db.leaderboard.find({}).sort({ score: -1, name: 1 })

Sorting with Dates and Strings

// Dates sort chronologically when stored as ISODate
db.events.find({}).sort({ date: 1 })   // earliest first

// Strings sort lexicographically (dictionary order, case-sensitive by default)
// "Apple" < "banana" because uppercase letters have lower code points than lowercase
db.words.find({}).sort({ word: 1 })

// Numbers stored as numbers sort numerically — as expected
// Numbers stored as STRINGS sort lexicographically: "200" < "30" (see edge cases)

allowDiskUse() for Large Sorts

// MongoDB has a 100MB memory limit for sort operations
// For very large result sets that exceed this limit, use allowDiskUse()
db.bigCollection.find({}).sort({ field: 1 }).allowDiskUse()

// Without allowDiskUse(), MongoDB throws:
// "Sort exceeded memory limit of 104857600 bytes"
WARN
allowDiskUse() spills sort data to temporary disk files, which is slower than in-memory sorting. The real fix is to add an index on the sort field so the sort is satisfied by index order and no in-memory sort is needed at all.
02
limit()
Cap the number of documents returned
limiting

limit(n) restricts the cursor to return at most n documents. MongoDB stops fetching documents once the limit is reached, which is more efficient than fetching all and discarding the rest client-side.

Basic Usage

// Return at most 10 documents
db.products.find({}).limit(10)

// limit(0) means no limit — all documents returned (same as omitting limit)
db.products.find({}).limit(0)

Top-N Pattern — Combined with sort()

The most common use of limit() is the top-N pattern: sort by a ranking field descending, then limit to N results.

// Top 10 highest-scoring players
db.leaderboard.find({}).sort({ score: -1 }).limit(10)

// 5 most recent orders
db.orders.find({ userId: ObjectId("abc") }).sort({ createdAt: -1 }).limit(5)

// Cheapest 3 products in a category
db.products.find({ category: "electronics" }).sort({ price: 1 }).limit(3)
TIP
Always limit on the server side with limit(). Fetching all documents and slicing client-side wastes network bandwidth, driver memory, and server CPU. Let MongoDB do the work.

limit() Behavior at Boundary

// If collection has 3 documents but limit is 10, all 3 are returned
// limit() is a maximum, not a guarantee
db.col.find({}).limit(10)   // returns min(10, actual count)

// Combining with a filter
db.articles.find({ published: true }).sort({ views: -1 }).limit(20)
// Returns the 20 most-viewed published articles
03
skip()
Offset the start of the result set
skipping

skip(n) instructs MongoDB to discard the first n documents from the cursor result before returning any to the client. It is primarily used with limit() for offset-based pagination.

Basic Usage

// Skip the first 20 documents
db.articles.find({}).skip(20)

// Skip 0 = no skip (same as omitting it)
db.articles.find({}).skip(0)

Page 3 with 10 per Page

const pageSize = 10
const page = 3                          // 1-indexed page number
const skipCount = (page - 1) * pageSize // = 20

db.articles.find({})
  .sort({ createdAt: -1 })
  .skip(skipCount)
  .limit(pageSize)
// Returns articles 21–30 in reverse-chronological order
PERF
skip() always scans documents from the beginning — O(n) in offset size.

Even with an index on the sort field, MongoDB must traverse and discard the first n index entries before returning results. skip(1000000).limit(10) is slow even with a perfect index because 1,000,000 entries must be skipped.

For deep pages (large skip values), use cursor-based pagination instead (see Section 05).

When skip() Is Acceptable

  • Small datasets where total documents is in the thousands
  • Admin interfaces where users rarely navigate past page 5–10
  • One-time batch queries where you need to process a specific window
  • Situations where jumping to an arbitrary page number is a hard requirement
04
Chaining Order
Code order vs execution order
internals

Cursor modifier methods can be chained in any order in your code. MongoDB ignores the written order and always applies them in a fixed internal sequence.

MongoDB's Fixed Execution Order

// Internal execution is ALWAYS: SORT → SKIP → LIMIT
// regardless of the order you write them in code

// These two queries are completely identical in behavior:
db.col.find().sort({ price: 1 }).skip(20).limit(10)
db.col.find().limit(10).sort({ price: 1 }).skip(20)   // same result!

// Even this unusual ordering produces identical output:
db.col.find().limit(10).skip(20).sort({ price: 1 })   // still identical
NOTE
MongoDB executes sort first, then skip, then limit — always. Writing .limit(10).sort() does not sort only 10 documents. The full sort happens first, then skip, then the final 10 are returned. Write your code in logical order (sort → skip → limit) for readability.

Practical Consequence

// You might assume this skips 20 docs from the unsorted set, then sorts 10 docs:
// db.col.find().skip(20).sort({price:1}).limit(10)
//
// WRONG. MongoDB actually:
//   1. Sorts the ENTIRE collection by price ascending
//   2. Skips the first 20 sorted documents
//   3. Returns the next 10 sorted documents
//
// The written order is decorative — execution order is fixed.

Counting Methods Compared

MethodRespects skip/limit?AccuracyStatus
countDocuments(filter) No — counts all matching exact current
estimatedDocumentCount() No filter support approximate current — uses metadata
cursor.size() Yes — count after skip/limit exact current
cursor.count() No — like countDocuments exact deprecated
// Modern counting patterns
db.orders.countDocuments({ status: "pending" })   // accurate count with filter
db.orders.estimatedDocumentCount()                // fast approximate total (no filter)

// Count what the paginated cursor actually returns
db.orders.find({ status: "pending" }).skip(20).limit(10).size()
// Returns a number between 0 and 10
05
Pagination Patterns
skip/limit, keyset, and range-based
patterns

Pattern 1 — skip() / limit() Offset Pagination

The simplest approach. Works well for small datasets and early pages. Performance degrades with large skip values because MongoDB must scan and discard skipped documents every time.

// Generic offset pagination helper
function getPage(pageNum, pageSize) {
  return db.articles.find({ published: true })
    .sort({ createdAt: -1 })
    .skip((pageNum - 1) * pageSize)
    .limit(pageSize)
    .toArray()
}

getPage(1, 10)   // articles 1–10
getPage(2, 10)   // articles 11–20  (skip 10)
getPage(100, 10) // articles 991–1000  (skip 990 ← slow on large collections)
PERF
skip/limit pagination is O(offset) — getPage(1000, 10) makes MongoDB traverse and discard 9,990 documents before returning 10. Even with an index, the skip traversal itself is expensive at scale.

Pattern 2 — Cursor-Based (Keyset) Pagination

Uses the last seen _id (or any unique indexed field) as an anchor for the next page query. Always O(log n) regardless of how deep into the dataset you are.

// First page — no cursor yet
const firstPage = db.articles.find({ published: true })
  .sort({ _id: 1 })
  .limit(10)
  .toArray()

// Save the last _id from the returned batch
const lastId = firstPage[firstPage.length - 1]._id

// Next page — use lastId as the lower bound anchor
const nextPage = db.articles.find({
  published: true,
  _id: { $gt: lastId }        // only documents AFTER the last seen one
}).sort({ _id: 1 }).limit(10).toArray()

// Continue: save new lastId and repeat
// Each query is as fast as the first — always hits the _id index directly
TIP
Cursor-based pagination works with any unique, indexed field — not just _id. For a time-ordered feed, use a { createdAt: 1, _id: 1 } compound index and anchor on both fields to handle ties.

Pattern 3 — Range-Based Pagination

Uses a field value range (e.g., timestamp or score) as the page boundary. Useful when the data has a natural ordered dimension and you want human-readable anchors.

// Page by date range — show articles from a specific week
db.articles.find({
  published: true,
  createdAt: {
    $gte: ISODate("2024-01-01"),
    $lt:  ISODate("2024-01-08")
  }
}).sort({ createdAt: 1 })

// Page by score bracket — leaderboard band
db.players.find({
  score: { $gte: 1000, $lt: 2000 }
}).sort({ score: -1 }).limit(50)

Pattern Comparison

PatternPerformanceSupports Jump to Page?Best For
skip / limit O(offset) yes Small data, admin UIs, early pages only
Cursor / keyset O(log n) no — forward/back only Infinite scroll, large datasets, APIs
Range-based O(log n) Partial (jump to range) Time-series, score brackets, date filters
06
Edge Cases
Unexpected behaviors and gotchas
gotchas

String Sort Is Lexicographic — Numbers Can Surprise You

// If numbers are stored as strings, sort is lexicographic:
// "10" < "200" < "30" because "1" < "2" < "3" by character code

db.col.find({}).sort({ strNum: 1 })
// Result order: "1", "10", "100", "2", "20", "200", "3", "30"
// NOT: 1, 2, 3, 10, 20, 30, 100, 200
// Always store numbers as Number type, not String, if you need numeric sort

Sorting on Missing Fields — Null-First Behavior

// Documents without the sort field sort BEFORE documents that have it (ascending order)
// MongoDB treats missing/null fields as the lowest possible value

db.col.find({}).sort({ age: 1 })
// Documents with no "age" field come first, then documents with age values in order

db.col.find({}).sort({ age: -1 })
// Documents with age values come first (highest to lowest), null/missing come LAST

skip(0) and limit(0) Are No-Ops

db.col.find({}).skip(0)    // identical to omitting skip — no skipping
db.col.find({}).limit(0)   // identical to omitting limit — all documents returned

Sort on Array Field Uses Min/Max Element

// Document: { name: "Alice", scores: [85, 92, 78] }
// Sorting by "scores" ascending uses the MINIMUM element (78) for comparison
// Sorting by "scores" descending uses the MAXIMUM element (92) for comparison

db.students.find({}).sort({ scores: 1 })   // ordered by minimum score in array
db.students.find({}).sort({ scores: -1 })  // ordered by maximum score in array

allowDiskUse() Required for Large Sorts

// Without allowDiskUse(), sorting more than 100MB of data throws:
db.bigCol.find({}).sort({ name: 1 })
// Error: "Sort exceeded memory limit of 104857600 bytes.
//         Add an index, or specify a smaller limit."
//
// Fix option 1: add an index on the sort field (sort uses index order, no memory needed)
// Fix option 2: allow disk spill
db.bigCol.find({}).sort({ name: 1 }).allowDiskUse()

Large skip Without Index on Sort Field = Full Scan

// If no index covers the sort+skip combination, MongoDB does a collection scan,
// sorts everything in memory, then discards the skipped portion.
// This is extremely slow on large collections.

// Mitigate with an index covering the sort field:
db.articles.createIndex({ createdAt: -1 })
db.articles.find({}).sort({ createdAt: -1 }).skip(100).limit(10)
// Now skip traverses the index (still O(skip_size) but much faster than COLLSCAN)

cursor.count() Is Deprecated

// DEPRECATED — avoid in new code
db.col.find({ status: "active" }).count()

// Use instead:
db.col.countDocuments({ status: "active" })       // accurate, recommended
db.col.estimatedDocumentCount()                   // fast approximate (no filter)
WARN
cursor.count() is listed as deprecated in MongoDB 4.0+ drivers. It was removed from mongosh. Use countDocuments() for accurate counts with a filter, and estimatedDocumentCount() for fast collection-total estimates without a filter.