
Handling Dates and Time Zones Correctly in MongoDB
Date bugs are some of the most frustrating bugs in software because they hide. Everything looks right in your local time zone, the tests pass on your laptop, and then a customer in Sydney reports that their "orders today" report is missing half the day. Or a daily job runs twice on the night the clocks change. Or a query for September returns nothing because some documents store dates as strings and others as real dates.
MongoDB's date handling is actually simple and solid: every date is a single point in time, stored in UTC. Almost every date bug comes from the edges, where values enter and leave the database: how your application creates them, how it computes query boundaries, and how it groups them for reporting.
This guide covers how BSON dates work, how to create them correctly from JavaScript and Python, how to query time ranges in a user's time zone, how to group and format by local time in aggregations, how daylight saving time interferes, and how to handle values like birthdays that aren't really points in time at all.
How MongoDB Stores Dates
A BSON Date is a signed 64-bit integer: the number of milliseconds since the Unix epoch (January 1, 1970, 00:00:00 UTC). That's all it is. There's no time zone stored with the value, no offset, and no calendar information. It represents an instant.
In mongosh, you create one with new Date() or ISODate():
db.events.insertOne({
name: "deploy",
at: new Date("2026-09-16T14:30:00Z"),
});
db.events.findOne();
{
_id: ObjectId("66e8404a7f1c3b0f5a9c1d20"),
name: "deploy",
at: ISODate("2026-09-16T14:30:00.000Z")
}
mongosh always displays dates in UTC with a trailing Z. That's a display choice, not a conversion: the stored value is the same instant no matter where you view it from. Millisecond precision is the limit; if you need microseconds, you'll have to store an extra integer field.
The server's own time zone doesn't matter either. A MongoDB server running in Frankfurt and one in Virginia store the same bytes for the same instant.
Don't Store Dates as Strings
It's common to see documents like this, especially from imported JSON:
{ orderId: 1041, createdAt: "2026-09-16T14:30:00Z" }
{ orderId: 1042, createdAt: "09/16/2026 10:31 AM" }
Strings break everything that makes dates useful. Range queries become lexicographic string comparisons, which only work if every value uses exactly the same ISO format and offset. Date operators in aggregation won't accept them without conversion. They take more space than 8-byte BSON dates. And because MongoDB compares values of different BSON types by type first, a query with a real Date never matches a string field:
db.orders.find({ createdAt: { $gte: new Date("2026-09-01") } }); // skips every string value
If you have string dates already, convert them in place with an update pipeline:
db.orders.updateMany({ createdAt: { $type: "string" } }, [
{
$set: {
createdAt: {
$dateFromString: { dateString: "$createdAt", onError: "$createdAt" },
},
},
},
]);
The onError option leaves unparseable values untouched so you can find and fix them afterwards with another $type: "string" query. For non-ISO formats, $dateFromString accepts a format and a timezone, which you'll need for values like "09/16/2026 10:31 AM" that don't say which zone they were recorded in.
Creating Dates Correctly in Your Application
The database side is simple. The application side is where things go wrong.
JavaScript and Node.js
A JavaScript Date is also an instant (milliseconds since the epoch), so it maps one-to-one onto a BSON Date. The Node.js driver and Mongoose store it without conversion. The trouble is parsing:
new Date("2026-09-16"); // 2026-09-16T00:00:00.000Z (date-only ISO: parsed as UTC)
new Date("2026-09-16T00:00"); // midnight LOCAL time (date-time without offset: local)
new Date(2026, 8, 16); // midnight LOCAL time, and months are 0-indexed
Those three look similar and produce different instants depending on where the code runs. On a server set to UTC they agree; on a developer laptop in New York, the second and third are four hours later. Rules of thumb:
- Always include an offset or
Zwhen parsing strings:new Date("2026-09-16T00:00:00Z"). - Prefer
Date.UTC(2026, 8, 16)over the local-time constructor when building dates from parts. - Run servers in UTC anyway, so that any lingering local-time code behaves predictably.
For anything involving named time zones, use a library that understands them. Luxon is a solid choice today, and the built-in Temporal API is arriving in JavaScript runtimes; check support in your Node.js version before depending on it.
Python and PyMongo
Python has a trap that JavaScript doesn't: naive datetime objects with no time zone attached. PyMongo assumes naive datetimes are already in UTC and stores them as-is. If your code produced a naive local time, it's now silently wrong by your UTC offset.
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
# Good: timezone-aware, converted to UTC on write
now = datetime.now(timezone.utc)
meeting = datetime(2026, 9, 16, 10, 0, tzinfo=ZoneInfo("America/New_York"))
db.events.insert_one({"name": "standup", "at": meeting})
# Bad: naive local time, stored as if it were UTC
wrong = datetime.now()
datetime.utcnow() is deprecated in recent Python versions because it returns a naive value that's easy to misuse. Use datetime.now(timezone.utc) instead.
On the way out, PyMongo returns naive UTC datetimes by default. Configure the client to return aware ones, optionally converted to a zone:
from pymongo import MongoClient
from zoneinfo import ZoneInfo
client = MongoClient(uri, tz_aware=True, tzinfo=ZoneInfo("UTC"))
doc = client.app.events.find_one({"name": "standup"})
print(doc["at"]) # 2026-09-16 14:00:00+00:00
Setting tz_aware=True is one of the best one-line changes you can make in a Python codebase that deals with dates.
Mongoose
Mongoose's Date schema type casts strings and numbers into Date objects, which helps with API input. The timestamps option adds createdAt and updatedAt automatically:
const orderSchema = new mongoose.Schema(
{
total: Number,
shipBy: { type: Date, required: true },
},
{ timestamps: true },
);
Casting a string still follows JavaScript parsing rules, so the "always include an offset" advice applies to data coming from clients.
Querying by the User's Day, Week, or Month
Here's the central problem. A user in New York asks for "today's orders." Today, for them, doesn't start at midnight UTC. It starts at midnight in America/New_York, which is 04:00 UTC during daylight saving time and 05:00 UTC in winter.
The fix is to compute the boundaries in the user's time zone, convert them to instants, and query with a half-open range: inclusive start, exclusive end.
import { DateTime } from "luxon";
function dayRange(zone, date = DateTime.now()) {
const start = date.setZone(zone).startOf("day");
const end = start.plus({ days: 1 });
return { start: start.toJSDate(), end: end.toJSDate() };
}
const { start, end } = dayRange("America/New_York");
const orders = await db
.collection("orders")
.find({ createdAt: { $gte: start, $lt: end } })
.toArray();
Use $lt with the next day's start rather than $lte with 23:59:59.999. It's simpler, it can't miss the last millisecond, and it works for any unit: weeks, months, and quarters all follow the same pattern.
Note that start.plus({ days: 1 }) is calendar arithmetic, not "add 24 hours." On the day clocks spring forward, that local day is 23 hours long, and on the day they fall back it's 25. A library with time zone support handles this; manual millisecond math doesn't.
An index on createdAt (or a compound index like { storeId: 1, createdAt: -1 }) makes these range queries fast regardless of the time zone, because the bounds are plain instants.
Store the Time Zone You Need
If a document's meaning depends on a local time zone, store the zone name alongside the instant:
{
_id: ObjectId("66e8..."),
storeId: "nyc-soho",
openedAt: ISODate("2026-09-16T13:00:00Z"),
timezone: "America/New_York"
}
Use IANA zone names like America/New_York, not fixed offsets like -04:00. An offset is only correct for part of the year; a zone name encodes the full daylight saving history and future rules.
Time Zones in Aggregation
MongoDB's date expression operators accept a timezone parameter, which is how you do local-time reporting on the server. The timezone can be an IANA name or a UTC offset, and it can come from a field in the document.
Grouping by Local Day with $dateTrunc
$dateTrunc (MongoDB 5.0+) rounds a date down to the start of a unit in a given time zone. It's the cleanest way to bucket data by local day:
db.orders.aggregate([
{
$match: {
createdAt: {
$gte: ISODate("2026-09-01T04:00:00Z"),
$lt: ISODate("2026-10-01T04:00:00Z"),
},
},
},
{
$group: {
_id: {
$dateTrunc: {
date: "$createdAt",
unit: "day",
timezone: "America/New_York",
},
},
orders: { $sum: 1 },
revenue: { $sum: "$total" },
},
},
{ $sort: { _id: 1 } },
]);
[
{ _id: ISODate("2026-09-01T04:00:00Z"), orders: 418, revenue: 21940.5 },
{ _id: ISODate("2026-09-02T04:00:00Z"), orders: 392, revenue: 20318.25 },
// ...
];
The group keys are instants representing local midnight in New York, which is why they show 04:00 UTC. The $match bounds were computed the same way. $dateTrunc also supports week (with startOfWeek), month, quarter, year, and binSize for buckets like 15 minutes.
Without the timezone, every order placed between 8 PM and midnight Eastern would be counted on the following day. That's exactly the "missing half the day" bug from the introduction.
Formatting and Extracting Parts
$dateToString formats a date for display, and $hour, $dayOfWeek, and friends extract components, all with time zone support:
db.orders.aggregate([
{ $match: { storeId: "nyc-soho" } },
{
$project: {
localDate: {
$dateToString: {
date: "$createdAt",
format: "%Y-%m-%d %H:%M",
timezone: "$timezone",
},
},
localHour: { $hour: { date: "$createdAt", timezone: "$timezone" } },
},
},
]);
Using $timezone from each document means a single pipeline can report in each store's own local time. A "busiest hour" report across stores in different zones is then just a $group on localHour.
Date Arithmetic with $dateAdd and $dateDiff
$dateAdd, $dateSubtract, and $dateDiff (all 5.0+) do calendar-aware math, including across daylight saving transitions when you pass a timezone:
db.subscriptions.aggregate([
{
$project: {
renewsAt: {
$dateAdd: {
startDate: "$startedAt",
unit: "month",
amount: 1,
timezone: "Europe/Berlin",
},
},
ageInDays: {
$dateDiff: {
startDate: "$startedAt",
endDate: "$$NOW",
unit: "day",
timezone: "Europe/Berlin",
},
},
},
},
]);
Adding a month to January 31 gives the last day of February, which is usually what billing logic wants. $$NOW is the current time on the server, the same for every document in the operation.
Daylight Saving Time Gotchas
DST causes a specific set of bugs worth naming.
Adding 86,400,000 milliseconds is not "one day later." Across a spring-forward transition, it lands at 1 AM instead of midnight. Use calendar-aware functions ($dateAdd with a timezone, Luxon's plus) whenever the unit is days or larger.
Some local times don't exist, and some happen twice. In New York, 2:30 AM doesn't exist on the spring-forward day, and 1:30 AM occurs twice in the fall. Scheduling a job for "2:30 AM local" needs a policy for both cases. Many teams schedule recurring jobs in UTC to sidestep this.
Fixed offsets drift. A store saved with timezone: "-05:00" is correct in winter and wrong all summer. Store IANA names.
Values That Aren't Instants
Not every date-like value is a point in time. A birthday, a holiday, or a hotel check-in date is a calendar date that means the same thing in every zone. If you store a birthday as ISODate("1990-05-14T00:00:00Z"), a user in California will see May 13 when your front end converts it to local time.
You have two reasonable options:
- Store the calendar date as a string in ISO format,
"1990-05-14". ISO date strings sort and compare correctly, and nobody will "helpfully" convert them. This is the rare case where a string is the right choice. - Store it as a Date at midnight UTC and treat it strictly as UTC everywhere: always format with
timeZone: "UTC", never convert to local time.
The first is harder to misuse. Similarly, a recurring local time like "store opens at 09:00" is best stored as "09:00" plus a zone name, not as a Date.
Avoid the BSON Timestamp Type
BSON also has a Timestamp type, which shows up as Timestamp({ t: 1726497000, i: 1 }). Despite the name, it's an internal type used for replication (the oplog) and cluster time. It has seconds resolution plus an ordinal counter and isn't supported by date operators the way Dates are. Use Date for application data.
If you need a document's creation time and you're using default ObjectIds, ObjectId.getTimestamp() gives you the creation second for free, as covered in the ObjectId deep dive. An explicit createdAt Date is still better for querying and indexing.
Common Mistakes
Mixing strings and Dates in one field. Type bracketing means range queries silently skip one kind. Audit with db.orders.countDocuments({ createdAt: { $type: "string" } }) and convert.
Parsing strings without an offset. Whether a value is interpreted as UTC or local depends on the format and the runtime. Always send ISO 8601 with Z or an explicit offset over your APIs.
Naive datetimes in Python. Use aware datetimes everywhere and set tz_aware=True on the client.
Computing day boundaries in UTC for local users. Compute them in the user's zone, then query with instants and a half-open range.
Forgetting timezone in aggregation. Any $dateTrunc, $dateToString, $hour, or $dayOfWeek without a timezone reports in UTC. That's correct for some reports and wrong for most user-facing ones.
Storing birthdays as instants. Calendar dates shift by a day when converted across zones. Store them as ISO date strings.
Conclusion
MongoDB's date model is deliberately minimal: an instant in UTC, with time zone logic applied when you query, group, and display. Once you treat the database as the single source of UTC truth, the rules follow naturally. Store real Dates, never strings. Create them with explicit offsets or aware datetimes. Compute local boundaries in the user's zone and query with half-open ranges. Pass timezone to every date operator in user-facing reports. And keep calendar dates like birthdays out of the instant business entirely.
Pick one user-facing report in your app, like daily revenue or signups per day, and check whether its grouping stage passes a timezone. If it doesn't, add $dateTrunc with the user's IANA zone name and compare the numbers for a day near midnight. The difference is usually the first date bug you'll fix this week.


