Projection, Sorting, Limiting, and Skipping

Filtering with query operators determines which documents come back. Projection, sorting, limiting, and skipping determine how those documents are shaped and ordered. Together, these four cursor modifiers let you retrieve exactly the data you need in exactly the right order โ€” nothing more, nothing less. Sending only the fields a client needs reduces network payload and response time. Returning results in a predictable order prevents UI flickering and makes pagination reliable. Understanding these modifiers is essential for building efficient, professional API endpoints.

Projection โ€” Selecting Fields to Return

Projection Syntax Effect Example
{ field: 1 } Include field โ€” return only specified fields (+_id) { title: 1, status: 1 }
{ field: 0 } Exclude field โ€” return all fields except specified { password: 0, __v: 0 }
{ _id: 0 } Exclude _id from inclusion projection { title: 1, _id: 0 }
{ 'nested.field': 1 } Include a specific nested field { 'assignee.name': 1 }
{ tags: { $slice: 3 } } Return only first N elements of an array { tags: { $slice: 5 } }
{ tags: { $slice: [-2, 2] } } Return N elements starting from index Last 2 elements

Sort Syntax

Sort Syntax Effect
.sort({ createdAt: -1 }) Sort by createdAt descending (newest first)
.sort({ createdAt: 1 }) Sort by createdAt ascending (oldest first)
.sort({ priority: -1, title: 1 }) Sort by priority desc, then title asc (multi-field)
.sort('createdAt') String shorthand โ€” ascending
.sort('-createdAt') String shorthand with dash โ€” descending
.sort({ score: { $meta: 'textScore' } }) Sort by text search relevance score

Pagination Modifiers

Modifier Effect Example
.limit(n) Return at most n documents .limit(10)
.skip(n) Skip the first n matching documents .skip(20)
.limit(0) No limit โ€” return all matching documents Use with caution
Note: You cannot mix include (field: 1) and exclude (field: 0) projections in the same projection object โ€” except for the _id field. Either include specific fields or exclude specific fields. The _id field is always included unless you explicitly set { _id: 0 }. In Mongoose, .select('-password -__v') is the shorthand for excluding the password and the Mongoose version key.
Tip: Always exclude the password field in any query that returns user documents to the API client, even if you think the field will not be used. A defensive exclusion โ€” User.find().select('-password') โ€” ensures the hash never accidentally leaks in a response, regardless of how the result is forwarded. Add it as a Mongoose schema option select: false on the password field to exclude it from all queries by default.
Warning: .sort() must be called before .skip() and .limit() for pagination to work correctly. If documents are returned in an unpredictable order and you paginate without a consistent sort, page 2 may return items that were already on page 1. Always use a stable sort field โ€” _id (auto-sorted by insertion time) or createdAt โ€” as the final sort key for deterministic pagination results.

Basic Example

// โ”€โ”€ Projection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

// Include projection โ€” return ONLY these fields (+ _id by default)
await Task.find({}, { title: 1, status: 1, priority: 1 });
// Result: [{ _id: ..., title: '...', status: '...', priority: '...' }]

// Exclude projection โ€” return everything EXCEPT these fields
await User.find({}, { password: 0, __v: 0, refreshTokens: 0 });

// Exclude _id from an include projection
await Task.find({}, { _id: 0, title: 1, status: 1 });

// Mongoose .select() โ€” chainable shorthand
await Task.find().select('title status priority');          // include: space-separated
await User.find().select('-password -__v -refreshTokens'); // exclude: dash prefix
await Task.find().select({ title: 1, status: 1 });         // object syntax

// Nested field projection
await Task.find({}, { 'assignee.name': 1, 'assignee.email': 1, title: 1 });

// Array slice โ€” return only first 5 tags
await Task.find({}, { title: 1, tags: { $slice: 5 } });

// โ”€โ”€ Sorting โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

// Single field sorts
await Task.find().sort({ createdAt: -1 });       // newest first
await Task.find().sort({ createdAt: 1 });        // oldest first
await Task.find().sort({ title: 1 });             // alphabetical Aโ†’Z

// Mongoose string shorthand
await Task.find().sort('-createdAt');              // newest first
await Task.find().sort('title');                   // Aโ†’Z

// Multi-field sort โ€” sort by priority desc, then createdAt desc within same priority
await Task.find({ status: 'pending' })
    .sort({ priority: -1, createdAt: -1 });

// Custom sort order with $switch (Mongoose โ€” requires aggregation)
// For enum field sort: high โ†’ medium โ†’ low
await Task.aggregate([
    { $match: { status: 'pending' } },
    { $addFields: {
        priorityOrder: {
            $switch: {
                branches: [
                    { case: { $eq: ['$priority', 'high'] },   then: 1 },
                    { case: { $eq: ['$priority', 'medium'] }, then: 2 },
                    { case: { $eq: ['$priority', 'low'] },    then: 3 },
                ],
                default: 4,
            },
        },
    }},
    { $sort: { priorityOrder: 1, createdAt: -1 } },
]);

// โ”€โ”€ Limit and skip โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

// First 10 tasks
await Task.find().limit(10);

// Page-based pagination
const page  = 3;
const limit = 10;
await Task.find()
    .sort('-createdAt')
    .skip((page - 1) * limit)   // skip 20 documents
    .limit(limit);               // return 10 documents

// Cursor-based pagination (faster for large collections)
// Instead of skip, query by last seen _id
const lastId = '64a1f2b3c8e4d5f6a7b8c9d0';  // from previous page
await Task.find({
    _id:    { $lt: new ObjectId(lastId) },    // documents before the last seen one
    userId: req.user.id,
}).sort({ _id: -1 }).limit(10);

// โ”€โ”€ Combining all four โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const tasks = await Task
    .find({ userId: req.user.id, status: { $ne: 'deleted' } })  // filter
    .select('title status priority dueDate createdAt')            // projection
    .sort({ priority: -1, createdAt: -1 })                       // sort
    .skip((page - 1) * limit)                                     // skip
    .limit(limit)                                                  // limit
    .lean();                                                       // plain JS objects (faster)

Mongoose .lean() โ€” Returning Plain JavaScript Objects

// Without .lean() โ€” Mongoose documents (heavy objects with methods and getters)
const tasks = await Task.find({ userId });
tasks[0].save();          // Mongoose document has save(), remove(), toObject(), etc.
tasks[0] instanceof Task; // true

// With .lean() โ€” plain JavaScript objects (much faster, lower memory)
const tasks = await Task.find({ userId }).lean();
tasks[0].save;            // undefined โ€” plain object, no Mongoose methods
tasks[0] instanceof Task; // false

// When to use .lean():
// โœ… Read-only API responses (GET endpoints) โ€” always use lean()
// โœ… Large result sets โ€” significant memory savings
// โŒ When you need to call .save() on the result
// โŒ When you need virtual properties (virtuals are not included in lean())
// โŒ When using getters/setters defined in the schema

// .lean() benchmark impact:
// Without lean: ~2ms per document (creates full Mongoose document)
// With lean:    ~0.1ms per document (plain object assignment)
// For 1000 documents: 2000ms vs 100ms โ€” 20x faster

How It Works

Step 1 โ€” Projection Is Applied by MongoDB Before Sending Results

Projection is not a client-side filter โ€” MongoDB applies it at the server level before transmitting data. If a document has 20 fields but you project only 3, MongoDB sends only those 3 fields over the network. This reduces both bandwidth and the time spent deserialising the result in Node.js. For documents with large fields (long descriptions, embedded arrays), projection can dramatically reduce response size.

Step 2 โ€” Sort Order Determines the Cursor’s Result Set

Sorting is applied before skip and limit. .sort().skip().limit() is the correct order: sort all matching documents by the given criteria, skip the first N, return the next M. If you apply limit before sort, you get an unpredictable subset โ€” MongoDB may return different results on repeated calls if the sort is non-deterministic. Always sort before paginating.

Step 3 โ€” skip() Becomes Slow on Deep Pages

MongoDB’s skip(n) works by scanning and discarding the first n matching documents. For page 1 (skip 0) and page 2 (skip 10), this is negligible. For page 1000 (skip 9990), MongoDB scans and discards 9,990 documents on every request. This is why deep pagination is slow. Cursor-based pagination avoids skip entirely by filtering to documents after the last seen _id โ€” query complexity stays O(1) regardless of how deep into the result set you paginate.

Step 4 โ€” .lean() Bypasses Mongoose Document Instantiation

By default, Mongoose wraps every returned document in a full Mongoose Document object โ€” a heavyweight class with change tracking, validation, getters, setters, and all schema methods. For read-only GET endpoints, this overhead is unnecessary. .lean() tells Mongoose to return plain JavaScript objects directly from the MongoDB driver without wrapping. The result is faster, uses less memory, and is safe to serialise with JSON.stringify() without any special handling.

Step 5 โ€” select(‘field -otherField’) Is Mongoose’s Projection Shorthand

Mongoose’s .select() accepts a space-separated string of field names. Fields prefixed with - are excluded; fields without a prefix are included. You can also pass an object identical to MongoDB’s native projection syntax. The result is identical to passing a projection object to find(filter, projection) โ€” it is simply more readable when chained.

Real-World Example: Optimised Task List Endpoint

// GET /api/v1/tasks โ€” fully optimised with projection + sort + pagination + lean
exports.getAll = asyncHandler(async (req, res) => {
    const {
        status, priority, q,
        page  = 1, limit  = 10,
        sort  = '-createdAt',
        fields,    // optional: client can request specific fields
    } = req.query;

    // Build filter
    const filter = { userId: req.user.id, deletedAt: { $exists: false } };
    if (status)   filter.status   = status;
    if (priority) filter.priority = priority;
    if (q)        filter.$text    = { $search: q };

    // Sanitise page/limit
    const p = Math.max(1, parseInt(page,  10));
    const l = Math.min(100, parseInt(limit, 10));

    // Build safe projection โ€” client can request subset of default fields
    const DEFAULT_FIELDS = 'title status priority dueDate createdAt updatedAt';
    const ALLOWED_FIELDS = new Set(DEFAULT_FIELDS.split(' '));
    let projection = DEFAULT_FIELDS;

    if (fields) {
        const requested = fields.split(',').filter(f => ALLOWED_FIELDS.has(f));
        if (requested.length) projection = requested.join(' ');
    }

    // Validate sort field
    const SORT_MAP = {
        'createdAt':  '-createdAt', '-createdAt': '-createdAt',
        'title':      'title',      '-title':     '-title',
        'priority':   '-priority',  '-priority':  '-priority',
        'dueDate':    'dueDate',    '-dueDate':   '-dueDate',
    };
    const safeSort = SORT_MAP[sort] ?? '-createdAt';

    // Execute filter + projection + sort + pagination + lean in one go
    const [data, total] = await Promise.all([
        Task.find(filter)
            .select(projection)
            .sort(safeSort)
            .skip((p - 1) * l)
            .limit(l)
            .lean(),
        Task.countDocuments(filter),
    ]);

    res.set('X-Total-Count', total);
    res.json({
        success: true,
        data,
        meta: {
            total,
            page:       p,
            limit:      l,
            totalPages: Math.ceil(total / l),
            hasNextPage: p < Math.ceil(total / l),
            hasPrevPage: p > 1,
        },
    });
});

Common Mistakes

Mistake 1 โ€” Mixing include and exclude in the same projection

โŒ Wrong โ€” throws MongoServerError:

await Task.find({}, { title: 1, status: 1, password: 0 });
// MongoServerError: Projection cannot have a mix of inclusion and exclusion

✅ Correct โ€” only _id can be excluded in an inclusion projection:

await Task.find({}, { title: 1, status: 1, _id: 0 });          // valid โ€” _id exception
await User.find({}, { password: 0, __v: 0, resetToken: 0 });   // exclusion only

Mistake 2 โ€” Calling .sort() after .limit() โ€” wrong results

โŒ Wrong โ€” limits first, then sorts โ€” returns arbitrary subset sorted:

await Task.find().limit(10).sort('-createdAt');  // sorts 10 random tasks
// Not the 10 most recent tasks!

✅ Correct โ€” sort first, then limit:

await Task.find().sort('-createdAt').limit(10);  // 10 most recent tasks

Mistake 3 โ€” Not using .lean() on read-only GET endpoints

โŒ Wrong โ€” creates heavy Mongoose document objects unnecessarily:

const tasks = await Task.find({ userId }).limit(100);
// 100 full Mongoose document objects created โ€” slow and memory-heavy
res.json({ data: tasks });

✅ Correct โ€” use lean() for GET endpoints that do not modify documents:

const tasks = await Task.find({ userId }).limit(100).lean();
// 100 plain JS objects โ€” 20x faster, lower memory
res.json({ data: tasks });

Quick Reference

Task Code
Include fields .select('title status priority')
Exclude fields .select('-password -__v')
Sort descending .sort('-createdAt') or .sort({ createdAt: -1 })
Multi-field sort .sort({ priority: -1, createdAt: -1 })
Limit results .limit(10)
Paginate (offset) .skip((page-1)*limit).limit(limit)
Plain JS objects .lean()
Count only Model.countDocuments(filter)
Slice array field { tags: { $slice: 5 } } in projection

🧠 Test Yourself

A GET endpoint returns user documents to the client and uses .select('-password'). A colleague suggests also adding .lean(). Why is this a good idea?