$project, $sort, $limit, and $skip
Learn how to reshape documents with $project, sort results with $sort, and control output size with $limit and $skip in MongoDB aggregation pipelines.
$project, $sort, $limit, and $skip
These four stages control the shape and size of your pipeline output. You use them to clean up results, put them in the right order, and return only what you need.
$project— reshape documents, include or exclude fields, create computed fields$sort— sort documents by one or more fields$limit— keep only the first N documents$skip— skip the first N documents
$project
$project reshapes each document passing through the pipeline. You can include fields, exclude fields, rename fields, and create entirely new computed fields.
It is similar to projection in find() — but much more powerful because you can compute new values, not just include or exclude existing ones.
Syntax
{
$project: {
field: 1, // include field
field: 0, // exclude field
newField: "$oldField", // rename field
computed: expression // computed field
}
}Include specific fields
db.students.aggregate([
{
$project: {
name: 1,
grade: 1,
enrolled: 1
}
}
])Result:
[
{ _id: ObjectId("..."), name: "Ali Hassan", grade: "10th", enrolled: true },
{ _id: ObjectId("..."), name: "Sara Ahmed", grade: "9th", enrolled: true }
]Exclude _id
db.students.aggregate([
{
$project: {
_id: 0,
name: 1,
grade: 1
}
}
])Result:
[
{ name: "Ali Hassan", grade: "10th" },
{ name: "Sara Ahmed", grade: "9th" }
]Exclude specific fields
// Return everything except address and enrollmentDate
db.students.aggregate([
{
$project: {
address: 0,
enrollmentDate: 0
}
}
])Rename a field
// Rename grade to currentGrade
db.students.aggregate([
{
$project: {
_id: 0,
studentName: "$name",
currentGrade: "$grade"
}
}
])Result:
[
{ studentName: "Ali Hassan", currentGrade: "10th" },
{ studentName: "Sara Ahmed", currentGrade: "9th" }
]Create computed fields
This is where $project goes beyond what find() projection can do. You can create new fields based on calculations:
// Add a field showing if student is old enough to vote (18+)
db.students.aggregate([
{
$project: {
name: 1,
age: 1,
canVote: { $gte: ["$age", 18] }
}
}
])Result:
[
{ _id: ObjectId("..."), name: "Ali Hassan", age: 16, canVote: false },
{ _id: ObjectId("..."), name: "Sara Ahmed", age: 15, canVote: false }
]// Calculate year of birth from age
db.students.aggregate([
{
$project: {
name: 1,
age: 1,
birthYear: { $subtract: [2024, "$age"] }
}
}
])Result:
[
{ _id: ObjectId("..."), name: "Ali Hassan", age: 16, birthYear: 2008 },
{ _id: ObjectId("..."), name: "Sara Ahmed", age: 15, birthYear: 2009 }
]Access nested fields
// Pull city out of the address object into a top-level field
db.students.aggregate([
{
$project: {
name: 1,
grade: 1,
city: "$address.city"
}
}
])Result:
[
{ _id: ObjectId("..."), name: "Ali Hassan", grade: "10th", city: "Lahore" },
{ _id: ObjectId("..."), name: "Sara Ahmed", grade: "9th", city: "Karachi" }
]$project after $group
$project is commonly used after $group to clean up the output — especially to rename _id to something more readable:
db.students.aggregate([
{
$group: {
_id: "$grade",
totalStudents: { $sum: 1 },
averageScore: { $avg: "$examScore" }
}
},
{
$project: {
_id: 0,
grade: "$_id",
totalStudents: 1,
averageScore: 1
}
}
])Result:
[
{ grade: "10th", totalStudents: 45, averageScore: 81.2 },
{ grade: "11th", totalStudents: 38, averageScore: 84.5 },
{ grade: "9th", totalStudents: 41, averageScore: 78.9 }
]Much cleaner than having _id in the output.
After $group, the grouped field becomes _id in the output. Always use $project after $group to rename _id to something meaningful before returning results to your app.
$sort
$sort sorts documents in the pipeline. It works exactly like .sort() in find() — pass 1 for ascending and -1 for descending.
Syntax
{ $sort: { field: 1 } } // ascending
{ $sort: { field: -1 } } // descendingExample — Sort students by age
db.students.aggregate([
{ $match: { enrolled: true } },
{ $sort: { age: 1 } }
])Sort by multiple fields
// Sort by grade ascending, then by name ascending within each grade
db.students.aggregate([
{
$sort: { grade: 1, name: 1 }
}
])Sort after $group
// Count students per grade, sorted by count descending
db.students.aggregate([
{
$group: {
_id: "$grade",
totalStudents: { $sum: 1 }
}
},
{
$sort: { totalStudents: -1 }
}
])Result:
[
{ _id: "10th", totalStudents: 45 },
{ _id: "9th", totalStudents: 41 },
{ _id: "11th", totalStudents: 38 }
]Sort by text score
When using $text search in a pipeline, sort by relevance score:
db.courses.aggregate([
{ $match: { $text: { $search: "science" } } },
{ $sort: { score: { $meta: "textScore" } } }
])When $sort appears at the very beginning of a pipeline and sorts on an indexed field, MongoDB can use the index to sort — which is very fast. If $sort comes after other stages, MongoDB must sort in memory. For large result sets, this can be slow.
$limit
$limit keeps only the first N documents and discards the rest.
Syntax
{ $limit: number }Example — Top 5 students by exam score
db.students.aggregate([
{ $match: { enrolled: true } },
{ $sort: { examScore: -1 } },
{ $limit: 5 }
])This returns the 5 highest-scoring enrolled students.
Example — Top 3 grades by average score
db.students.aggregate([
{ $match: { enrolled: true } },
{
$group: {
_id: "$grade",
averageScore: { $avg: "$examScore" }
}
},
{ $sort: { averageScore: -1 } },
{ $limit: 3 }
])Always put $limit after $sort — otherwise you get an arbitrary set of documents, not the top ones. The pattern is always: filter → sort → limit.
$skip
$skip skips the first N documents and passes the rest through. Combined with $limit, it enables pagination.
Syntax
{ $skip: number }Example — Skip the first 10 students
db.students.aggregate([
{ $match: { enrolled: true } },
{ $sort: { name: 1 } },
{ $skip: 10 }
])Pagination with $skip and $limit
const page = 2;
const pageSize = 10;
db.students.aggregate([
{ $match: { enrolled: true } },
{ $sort: { name: 1 } },
{ $skip: (page - 1) * pageSize }, // skip: 10 for page 2
{ $limit: pageSize } // limit: 10
])- Page 1: skip 0, limit 10 — documents 1–10
- Page 2: skip 10, limit 10 — documents 11–20
- Page 3: skip 20, limit 10 — documents 21–30
$skip on large collections can be slow because MongoDB still has to scan and count through all the skipped documents. For very large datasets, consider cursor-based pagination instead — store the last _id from the previous page and use $match: { _id: { $gt: lastId } } instead of $skip.
Putting It All Together
Here is a complete pipeline using all four stages together:
// Enrolled students summary per grade
// Showing only grades with 30+ students
// Sorted by average score
// Page 1 — first 3 results
// Clean output — no _id
db.students.aggregate([
// Filter enrolled students
{ $match: { enrolled: true } },
// Group by grade
{
$group: {
_id: "$grade",
totalStudents: { $sum: 1 },
averageScore: { $avg: "$examScore" },
averageAge: { $avg: "$age" }
}
},
// Only grades with 30+ students
{ $match: { totalStudents: { $gte: 30 } } },
// Sort by average score descending
{ $sort: { averageScore: -1 } },
// First page — 3 results
{ $skip: 0 },
{ $limit: 3 },
// Clean output
{
$project: {
_id: 0,
grade: "$_id",
totalStudents: 1,
averageScore: { $round: ["$averageScore", 1] },
averageAge: { $round: ["$averageAge", 1] }
}
}
])Result:
[
{ grade: "11th", totalStudents: 38, averageScore: 84.5, averageAge: 16.7 },
{ grade: "10th", totalStudents: 45, averageScore: 81.2, averageAge: 15.9 },
{ grade: "9th", totalStudents: 41, averageScore: 78.9, averageAge: 14.8 }
]School System Examples
// Top 5 highest scoring students — name and score only
db.students.aggregate([
{ $match: { enrolled: true } },
{ $sort: { examScore: -1 } },
{ $limit: 5 },
{ $project: { _id: 0, name: 1, grade: 1, examScore: 1 } }
])
// Students sorted by grade then name — page 2 (10 per page)
db.students.aggregate([
{ $match: { enrolled: true } },
{ $sort: { grade: 1, name: 1 } },
{ $skip: 10 },
{ $limit: 10 },
{ $project: { _id: 0, name: 1, grade: 1 } }
])
// Course list — title and teacher only, sorted alphabetically
db.courses.aggregate([
{ $sort: { title: 1 } },
{ $project: { _id: 0, title: 1, teacherName: 1, grade: 1 } }
])
// Grades summary — sorted by total students, top 3
db.students.aggregate([
{ $match: { enrolled: true } },
{ $group: { _id: "$grade", total: { $sum: 1 } } },
{ $sort: { total: -1 } },
{ $limit: 3 },
{ $project: { _id: 0, grade: "$_id", total: 1 } }
])
// Teachers — active only, sorted by join date, name and subject only
db.teachers.aggregate([
{ $match: { active: true } },
{ $sort: { joinedDate: 1 } },
{ $project: { _id: 0, name: 1, subject: 1, joinedDate: 1 } }
])Quick Reference
| Stage | What it does | Example |
|---|---|---|
$project | Include, exclude, rename, or compute fields | { $project: { name: 1, _id: 0 } } |
$sort | Sort documents | { $sort: { age: -1 } } |
$limit | Keep first N documents | { $limit: 10 } |
$skip | Skip first N documents | { $skip: 20 } |
The most common pipeline pattern for paginated, sorted data is: $match → $sort → $skip → $limit → $project. Always sort before you skip and limit — otherwise you are paginating through an arbitrary order and results will be inconsistent.