Skip to content
ExponaBLOG
MONGODB, NOSQL & DATA MODELING · DAY 023 / 100

Working with dates in MongoDB: best practices explained

Store and query time correctly.

Expona Admin7 min read

Introduction

Date handling is one of those areas where a small mistake early in your schema design can compound into serious trouble months later. We've seen systems where timestamps were stored as strings, leading to impossible sorting, broken range queries, and timezone nightmares across distributed teams. Working with dates in MongoDB: best practices explained covers the critical decisions you need to make now to avoid refactoring later.

In this article, we'll walk through the patterns that work in production systems handling orders, events, user activity, and time-series data. The good news is that MongoDB gives us the tools to get this right—but only if we use them intentionally.

Store as BSON Date, not strings

The most common mistake we see is developers storing dates as ISO 8601 strings. It feels natural because you can read them in logs and they're easy to serialize to JSON. But this creates friction everywhere downstream.

MongoDB has a native BSON Date type—a 64-bit integer representing milliseconds since the Unix epoch. When you store dates this way, MongoDB understands them natively. You get comparison operators that work correctly, efficient indexing, and the ability to use date aggregation operators without type conversion overhead.

Here's what the difference looks like:

javascript

// ❌ Anti-pattern: storing as string
db.orders.insertOne({
  orderId: "ORD-12345",
  createdAt: "2024-01-15T14:30:00Z",  // String
  amount: 299.99
});

// When you query, MongoDB treats this as text
db.orders.find({ createdAt: { $gt: "2024-01-15" } });
// Returns unexpected results due to lexicographic comparison

// ✅ Pattern: storing as BSON Date
db.orders.insertOne({
  orderId: "ORD-12345",
  createdAt: new Date("2024-01-15T14:30:00Z"),  // BSON Date
  amount: 299.99
});

// Query works correctly with temporal semantics
db.orders.find({ createdAt: { $gt: new Date("2024-01-15") } });
// Returns all orders created after midnight on Jan 15, 2024

When you use BSON Date, MongoDB's query engine understands that you're working with time. Range queries execute faster, comparisons are semantically correct, and date aggregation operators become available. The investment in getting this right at insert time pays dividends throughout the lifetime of your system.

Store UTC

Timezones are the other date minefield we encounter regularly. The rule is simple: always store dates in UTC in the database, never in local time. Conversion to local time happens in the application layer when displaying or reporting data.

Why? Because if you store times in the user's local timezone, every time they travel, your data becomes ambiguous. Daylight saving time transitions create actual impossible times. And if you need to query across users in different timezones, your queries become complex and error-prone.

Consider an order system serving customers across multiple regions:

javascript

// ❌ Anti-pattern: storing in local time
db.orders.insertOne({
  orderId: "ORD-12345",
  createdAt: new Date("2024-01-15T09:30:00"),  // EST 9:30 AM
  timezone: "America/New_York",  // Storing timezone separately
  customerLocation: "New York"
});

// ✅ Pattern: convert to UTC before storing
const userLocalTime = new Date("2024-01-15T09:30:00");  // User's 9:30 AM
const utcTime = new Date(userLocalTime.toISOString());

db.orders.insertOne({
  orderId: "ORD-12345",
  createdAt: utcTime,  // Always UTC
  customerLocation: "New York",
  timezone: "America/New_York"  // Only for display purposes
});

// When you need the local time for display
const formatter = new Intl.DateTimeFormat('en-US', {
  timeZone: 'America/New_York',
  year: 'numeric',
  month: '2-digit',
  day: '2-digit',
  hour: '2-digit',
  minute: '2-digit'
});
const displayTime = formatter.format(order.createdAt);

Storing the timezone string alongside your UTC timestamp lets you preserve the user's original timezone for display and audit purposes, but your queries and aggregations work on the canonical UTC representation. This separation of concerns keeps your data clean and your queries predictable.

Index date fields used in ranges

A date field without an index is a performance disaster waiting to happen. If you're querying date ranges—and you almost certainly are—MongoDB will scan every document unless you've told it to use an index.

Any field that appears in a $gt, $gte, $lt, $lte, or $between query should be indexed. For time-series data, this is non-negotiable.

javascript

// Create an ascending index on createdAt
db.orders.createIndex({ createdAt: 1 });

// This now uses the index efficiently
db.orders.find({ 
  createdAt: { 
    $gte: new Date("2024-01-01"),
    $lt: new Date("2024-02-01")
  }
});

// For time-series collections, consider a compound index
// if you're also filtering by type, status, or userId
db.events.createIndex({ 
  userId: 1, 
  createdAt: 1 
});

// This query benefits from the compound index
db.events.find({
  userId: "user123",
  createdAt: { $gte: new Date("2024-01-01") }
});

// Check your index is being used
db.orders.find({ 
  createdAt: { $gte: new Date("2024-01-01") }
}).explain("executionStats");
// Look for "stage": "IXSCAN" in the output

The order of fields in a compound index matters. Put the equality filter first (userId), then the range filter (createdAt). This lets MongoDB narrow down the result set efficiently before scanning the index for the date range.

Monitor your slow query log. If you see date range queries taking longer than a few milliseconds on large collections, you probably need an index.

Use $dateTrunc for grouping

When you need to group data by time buckets—hourly reports, daily summaries, weekly trends—MongoDB's $dateTrunc aggregation operator is your friend. It truncates a date to a specified unit (year, month, week, day, hour, minute, second) and handles timezone logic for you.

Before $dateTrunc, developers used complex $dateToString calls or JavaScript logic. Now you have a clean, efficient operator that works on UTC dates:

javascript

// Generate hourly order volume report
db.orders.aggregate([
  {
    $match: {
      createdAt: {
        $gte: new Date("2024-01-01"),
        $lt: new Date("2024-01-31")
      }
    }
  },
  {
    $group: {
      _id: {
        $dateTrunc: {
          date: "$createdAt",
          unit: "hour",
          binSize: 1
        }
      },
      totalOrders: { $sum: 1 },
      totalRevenue: { $sum: "$amount" },
      avgOrderValue: { $avg: "$amount" }
    }
  },
  {
    $sort: { _id: 1 }
  }
]);

// Generate daily summary by customer
db.orders.aggregate([
  {
    $group: {
      _id: {
        customerId: "$customerId",
        date: {
          $dateTrunc: {
            date: "$createdAt",
            unit: "day"
          }
        }
      },
      dailySpend: { $sum: "$amount" },
      orderCount: { $sum: 1 }
    }
  }
]);

$dateTrunc also supports timezone conversion. If you need reports in a specific timezone without storing timezone data in every document, you can truncate with timezone awareness:

javascript

// Truncate to day in Eastern Time
db.orders.aggregate([
  {
    $group: {
      _id: {
        $dateTrunc: {
          date: "$createdAt",
          unit: "day",
          timezone: "America/New_York"
        }
      },
      revenue: { $sum: "$amount" }
    }
  }
]);

This avoids the performance hit of converting every document after retrieval. The grouping happens in MongoDB, giving you efficient aggregation pipelines.

Common mistakes

  • Storing timestamps as epoch seconds instead of milliseconds: JavaScript's Date.now() returns milliseconds, but some legacy systems use seconds. Be consistent. BSON Date expects milliseconds.
  • Comparing dates with strings in queries: Even though MongoDB might return results, string comparison is lexicographic, not temporal. Always convert to BSON Date.
  • Forgetting to index before querying: Date range queries on unindexed fields will kill your performance as data grows. Index first, test second.
  • Mixing UTC and local time in the same field: If some documents have UTC and others have local times, your aggregations become unreliable. Make the decision once and enforce it across your application.
  • Using $dateToString without considering timezone: If you need timezone-aware grouping, use $dateTrunc with the timezone parameter instead of converting to string and back.
  • Not validating dates at the application layer: MongoDB will accept any value as a Date if you construct it correctly, but garbage in means garbage out in your reports.

Key takeaways

  • Use BSON Date type for all temporal data. Never store dates as strings unless you have a very specific reason to do so.
  • Always store dates in UTC in MongoDB. Convert to local time only when displaying or reporting.
  • Index any date field used in range queries. Monitor slow query logs and add indexes preemptively for time-series data.
  • Use $dateTrunc for aggregation-based grouping by time bucket. It's more efficient and cleaner than string manipulation.
  • Validate date inputs at the application layer before inserting. Bad data in your database is expensive to fix later.
  • Document your timezone strategy. Whether you store it for audit purposes or compute it on the fly, make it explicit in your schema documentation.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles