Expressions: dates, strings and conditionals
The expression operators you will use every day: time bucketing, string manipulation, type conversion and null handling.
- Bucket by day, week and month with $dateTrunc and $dateToString
- Clean strings with $toLower, $trim, $split, $concat
- Convert types safely with $convert
- Handle missing values with $ifNull and $cond
conceptThe concept
- Dates:
$dateTrunc(5.0+) truncates to a unit with an optional timezone;$dateToStringformats;$dateDiff,$dateAdd;$year,$month,$dayOfWeek,$hour. - Strings:
$concat,$toLower/$toUpper,$trim,$split,$substrCP,$regexMatch,$strLenCP. - Types:
$toInt,$toDouble,$toDate,$toString, and$convertwithonError/onNullfor dirty data. - Conditionals:
$cond,$switch,$ifNull.
whyWhy it matters
Time-bucketed reports are the most common analytic query. Timezones matter: a day in India starts 5.5 hours before a UTC day, so "orders per day" differs depending on where you truncate.
Use caseReal-world scenario
A finance report needs revenue per calendar month in the Asia/Kolkata timezone. $dateTrunc: { date: "$createdAt", unit: "month", timezone: "Asia/Kolkata" } groups correctly; truncating in UTC would put the first hours of each month into the previous one.
Try itExamples
Run and edit these in the interactive lesson.
db.orders.aggregate([
{ $match: { createdAt: { $gte: ISODate("2026-01-01") } } },
{ $group: { _id: { $dateTrunc: { date: "$createdAt", unit: "month" } }, orders: { $sum: 1 }, revenue: { $sum: "$total" } } },
{ $sort: { _id: 1 } },
{ $project: { _id: 0, month: { $dateToString: { date: "$_id", format: "%Y-%m" } }, orders: 1, revenue: { $round: ["$revenue", 0] } } }
])db.events.aggregate([
{ $match: { type: "checkout" } },
{ $group: { _id: { $dayOfWeek: "$ts" }, n: { $sum: 1 }, p95ish: { $max: "$latencyMs" } } },
{ $sort: { _id: 1 } }
])db.customers.aggregate([
{ $limit: 5 },
{ $project: {
_id: 0,
first: { $arrayElemAt: [{ $split: ["$name", " "] }, 0] },
domain: { $arrayElemAt: [{ $split: ["$email", "@"] }, 1] },
label: { $concat: [{ $toUpper: "$tier" }, " / ", "$address.city"] },
phone: { $ifNull: ["$phone", "not provided"] } } }
])db.lab_dirty.drop()
db.lab_dirty.insertMany([{ v: "42" }, { v: "4.5" }, { v: "n/a" }, { v: null }])
db.lab_dirty.aggregate([{ $project: { _id: 0, v: 1, n: { $convert: { input: "$v", to: "double", onError: -1, onNull: 0 } } } }])internalsUnder the hood
Expressions are evaluated per document in the pipeline; they cannot use indexes unless they appear in a leading $match as simple comparisons. That is why you filter on the raw createdAt range first and bucket afterwards, never $match: { $expr: { $eq: [{ $month: "$createdAt" }, 9] } } on a large collection.
Avoid / preferDo this, not that
{ $group: { _id: { $dateToString: { format: "%Y-%m-%d", date: "$createdAt" } } } }{ $group: { _id: { $dateToString: {
format: "%Y-%m-%d", date: "$createdAt", timezone: "Asia/Kolkata"
} } } }Dates are stored in UTC. Without a timezone, an order placed at 01:00 in India lands on the previous day.
{ $project: { initials: { $substr: ["$name", 0, 3] } } }{ $project: { initials: { $substrCP: ["$name", 0, 3] } } }$substr counts bytes, so it can cut a multi-byte character (Zoƫ, or Hindi script) in half and fail. $substrCP counts characters.
PitfallsCommon mistakes
- Bucketing by day in UTC when the business reports in local time.
- Filtering with
$expron computed date parts instead of a raw date range, which forces a full scan. - Using
$toInton dirty data without$convert+onError, which fails the whole pipeline on the first bad value.
LimitsWhen not to use it
Heavy string work such as fuzzy matching or extracting fields from free text across millions of documents is slow in a pipeline. Do it once at write time, in the application, or in a search engine.
SecuritySecurity notes
Validate timezone names and regex patterns that come from users. An unknown timezone fails the whole pipeline, and an unescaped pattern in $regexMatch can be made to backtrack for a very long time.
ChallengeTry it yourself
Count 2025 orders by ISO weekday (Monday = 1) in India time.
Show one solution
db.orders.aggregate([
{ $match: { createdAt: { $gte: ISODate("2025-01-01T00:00:00+05:30"), $lt: ISODate("2026-01-01T00:00:00+05:30") } } },
{ $group: { _id: { $isoDayOfWeek: { date: "$createdAt", timezone: "Asia/Kolkata" } }, orders: { $sum: 1 } } },
{ $sort: { _id: 1 } }
])InterviewQuestions you should be able to answer
How do you produce a daily report in a non-UTC timezone?
Filter with a UTC date range that corresponds to the local period, then group by $dateTrunc (or $dateToString) with timezone: "Asia/Kolkata". Dates are stored in UTC; the timezone is applied only when bucketing or formatting.
QuizCheck your understanding
4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.