Date Operators in MongoDB Aggregations: $dateTrunc, $dateDiff, $dateToString, and Friends
Date handling in MongoDB aggregations got dramatically better in version 5.0, which added $dateTrunc, $dateDiff, $dateAdd, and $dateSubtract. Before that, grouping orders by week meant assembling $year/$week pairs by hand or formatting dates into strings and grouping on those. If you find an old Stack Overflow answer doing either of those things, there is now a cleaner way.
This guide covers the operators you actually need: truncating dates into buckets, computing differences, date arithmetic, formatting, parsing, and extracting parts -- plus the timezone behavior that runs through all of them.
First, how MongoDB stores dates
BSON's date type is a 64-bit integer: milliseconds since the Unix epoch, always UTC. There is no timezone stored on the value itself. Every "what day is this?" question is therefore timezone-dependent, and every date operator accepts an optional timezone parameter (an Olson name like "Europe/Madrid" or a UTC offset like "+02:00") that defaults to UTC.
This is the single most common source of wrong date reports: an event at 2026-06-09T23:30:00Z belongs to June 9 in UTC but June 10 in Madrid. If your users are not in UTC, pass timezone explicitly or your daily buckets will be shifted. (For the full picture of how dates relate to other BSON types, including the Date vs Timestamp confusion, see our BSON data types guide.)
$dateTrunc: bucketing dates (5.0+)
$dateTrunc rounds a date down to the start of a unit. It is the right tool for time-bucket grouping:
db.orders.aggregate([
{
$group: {
_id: {
$dateTrunc: { date: "$createdAt", unit: "day", timezone: "Europe/Madrid" }
},
orders: { $count: {} },
revenue: { $sum: "$amount" }
}
},
{ $sort: { _id: 1 } }
])The group key stays a real Date, so it sorts chronologically and your driver hands your application a date object, not a string. That is the main reason to prefer $dateTrunc over the older $dateToString grouping trick.
Options worth knowing:
unit:year,quarter,month,week,day,hour,minute,second,millisecond.binSize: bucket width in units.{ unit: "minute", binSize: 15 }gives quarter-hour buckets;{ unit: "week", binSize: 2 }gives fortnights.startOfWeek: only used withunit: "week". Defaults to Sunday, which surprises everyone outside the US. SetstartOfWeek: "monday"for ISO-style weeks.
$dateDiff: differences between dates (5.0+)
{
$set: {
daysToShip: {
$dateDiff: { startDate: "$createdAt", endDate: "$shippedAt", unit: "day" }
}
}
}The behavior to internalize: $dateDiff counts unit boundaries crossed, not elapsed time. From 11:59 PM to 12:01 AM is 1 day by $dateDiff, because one midnight boundary was crossed -- even though only two minutes elapsed. Conversely, 12:01 AM to 11:59 PM the same day is 0 days.
This is usually what you want for calendar questions ("how many distinct days?") and usually not what you want for duration questions ("how many 24-hour periods?"). For elapsed time, subtract directly: { $subtract: ["$shippedAt", "$createdAt"] } returns milliseconds, which you can divide into whatever unit you need.
Like $dateTrunc, it takes timezone (boundaries move with the timezone) and startOfWeek for unit: "week".
$dateAdd and $dateSubtract: date arithmetic (5.0+)
{
$set: {
expiresAt: {
$dateAdd: { startDate: "$createdAt", unit: "month", amount: 3 }
}
}
}These are calendar-aware where raw millisecond math is not. Adding one month to January 31 gives February 28 (or 29); adding one day across a daylight-saving transition in a given timezone keeps the local clock time rather than blindly adding 86,400,000 ms. If you have ever seen a "daily" report drift by an hour twice a year, this is the fix.
For filtering in find() you still compute dates in application code (new Date(Date.now() - 30*24*3600*1000)); these operators exist inside aggregation expressions.
$dateToString: formatting for output
{
$project: {
day: {
$dateToString: { format: "%Y-%m-%d", date: "$createdAt", timezone: "Europe/Madrid", onNull: "no date" }
}
}
}Common specifiers: %Y year, %m month, %d day, %H hour (00-23), %M minute, %S second, %L millisecond, %j day of year, %u ISO weekday (1=Monday), %G/%V ISO year/week. onNull supplies a fallback when the date field is missing or null; without it, missing dates make the whole expression return null.
Use $dateToString for display, not for grouping. Grouping on formatted strings works, but you lose the date type, string sorting only matches chronological order if the format is big-endian, and $dateTrunc does the same job better.
$dateFromString: parsing strings into dates
The inverse operation, essential when dates were ingested as strings (a very common data-quality problem; see the type-audit query in the BSON guide):
{
$set: {
realDate: {
$dateFromString: {
dateString: "$dateStr",
format: "%d/%m/%Y",
timezone: "Europe/Madrid",
onError: null, // value to use when parsing fails
onNull: null // value to use when input is null/missing
}
}
}
}Without onError, a single malformed string fails the entire aggregation. Set it. To fix the data permanently rather than re-parsing on every read, run this through an update pipeline (updateMany with [{ $set: ... }]).
Extracting parts: $year, $month, $hour, and friends
$year, $month, $dayOfMonth, $hour, $minute, $second, $dayOfWeek (1=Sunday), $isoDayOfWeek (1=Monday), $isoWeek, $isoWeekYear, $dayOfYear all extract a single number from a date, each accepting timezone. There is also $dateToParts, which returns all the parts in one object.
These are good for "by hour of day" or "by weekday" analyses, where you want to merge all Mondays together:
db.events.aggregate([
{ $group: {
_id: { $isoDayOfWeek: { date: "$at", timezone: "Europe/Madrid" } },
count: { $count: {} }
} },
{ $sort: { _id: 1 } }
])For calendar buckets ("per week of 2026") use $dateTrunc instead -- grouping on { $week: ... } alone merges week 23 of 2026 with week 23 of 2025.
Common mistakes
- Forgetting
timezoneeverywhere. All-UTC is fine if you decide it deliberately. Deciding it accidentally shifts every daily metric for users east or west of Greenwich. - Expecting
$dateDiffto measure duration. It counts boundary crossings. Use$subtractfor elapsed milliseconds. - Sunday-start weeks. Both
$dateTruncand$dateDiffdefaultstartOfWeekto Sunday, while$week(0-53, Sunday-based) and$isoWeek(1-53, Monday-based) disagree with each other too. Pick ISO semantics explicitly if your business weeks start Monday. - Grouping on
$dateToStringoutput. Works, but$dateTrunckeeps the date type and is index-friendlier downstream. Reserve string formatting for the final projection. - Storing dates as strings. Comparisons become lexicographic, all of the above operators need a
$dateFromStringpreamble, and timezones are ambiguous. Store BSON dates; format on the way out. - Version assumptions.
$dateTrunc,$dateDiff,$dateAdd,$dateSubtractrequire MongoDB 5.0+ (as of June 2026, all supported MongoDB versions qualify, but older self-hosted deployments still exist). The extraction operators and$dateToStringwork everywhere.
Date bucketing is also the foundation of MongoDB's time series collections, which apply these ideas at the storage level. And if your time-bucketed data feeds a $group-heavy dashboard pipeline, the general performance guidance in our aggregation pipeline guide applies: $match on an indexed date range first, then truncate and group.
Mako connects to MongoDB with AI-powered autocomplete that helps you write date-bucketing pipelines without memorizing format specifiers. Try it free at mako.ai.
Skip the terminal. Use Mako.
Connect your database, write queries with AI assistance, and import/export data in clicks. Free to start.