← Back to Blog

Debugging a DateTime Comparison Bug That Only Failed in Production

Our payment expiry check worked perfectly in development and staging, but users reported that recently paid invoices were being marked as overdue in production. The cause was a timezone mismatch that only manifested when the server's timezone differed from our development machines.

Pankaj Kumar
Senior Software Engineer — .NET, Blazor, ASP.NET Core
4+ years building production .NET and Blazor applications. Every DevToolsHub tool and article comes from real daily development work — not documentation summaries.
Published 21 Jul 2026· Last reviewed Jul 2026· 9 min read · About the author →
The bug was in a payment expiry check that had been in production for six months. It only surfaced when we moved our servers from a Windows VM with IST timezone to Azure App Service, which runs in UTC.
Key takeaways
  • DateTime.Now returns local server time, which differs between development machines and cloud servers
  • DateTime with Kind=Unspecified is the silent killer — it has no timezone information but comparisons proceed anyway
  • DateTimeOffset always carries an explicit UTC offset and compares by UTC value — use it for all timestamps
  • Unix timestamps from external APIs are always UTC seconds — always convert with DateTimeOffset.FromUnixTimeSeconds()
  • The simplest audit: grep your codebase for DateTime.Now and review every occurrence
Table of Contents

The incident

We were building a billing module that checked whether invoices had passed their payment due date. When an invoice became overdue (payment date passed), the system would send a reminder email and flag the account. The logic was simple: compare the current time against the invoice's DueDate field, which we received from the payment provider's API as a Unix timestamp.

For six months, this worked correctly. Then we migrated our hosting from an on-premises Windows Server (configured with IST timezone, UTC+5:30) to Azure App Service (UTC timezone). Within a day, our support inbox started receiving complaints from customers whose invoices were being flagged as overdue when they had just paid them — sometimes within the last five hours.

What the buggy code looked like

// Buggy: comparing DateTime.Now (local server time) with a Unix timestamp
public bool IsInvoiceOverdue(long dueDateUnixSeconds)
{
    // Convert Unix timestamp to DateTime — creates Kind=Utc
    var dueDate = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)
        .AddSeconds(dueDateUnixSeconds);

    // DateTime.Now returns LOCAL server time
    // On our dev machines (IST, UTC+5:30): returns IST time
    // On Azure App Service (UTC): returns UTC time
    var now = DateTime.Now;

    // Comparing Utc DateTime against Local DateTime
    // This compares the ticks directly — no timezone adjustment is made
    return now > dueDate;
}

// The specific problem:
// dueDate = 2026-07-21 12:00:00 UTC (noon UTC)
//
// On IST dev machine (UTC+5:30):
//   DateTime.Now = 2026-07-21 17:30:00 (IST) with Kind=Local
//   Comparison: 17:30 > 12:00? Yes. But both are in different timezones...
//   .NET's comparison uses ticks: 17:30 ticks > 12:00 ticks → true (correct result by accident)
//
// On Azure (UTC):
//   DateTime.Now = 2026-07-21 12:00:00 (UTC) with Kind=Local
//   At exactly noon UTC, the invoice is not yet overdue
//   But 5 hours earlier at 07:00 UTC, DateTime.Now = 07:00 Local
//   Comparison: 07:00 ticks > 12:00 ticks → false (correct)
//
// Wait — actually the bug is subtler than this.
// The REAL problem is when Kind=Unspecified gets involved.

The bug was subtler than a simple timezone difference. When we stored the DueDate in our database via EF Core (which uses SQL Server's datetime2 column), the DateTimeKind was stripped. When EF Core read the value back, it returned a DateTime with Kind=Unspecified. This is the most dangerous state: Unspecified means "we don't know if this is UTC or local," but comparison operations treat it as local time.

// The full bug chain:
// 1. Receive Unix timestamp from payment API
long dueDateUnix = 1753084800L; // 2026-07-21 12:00:00 UTC

// 2. Convert to DateTime — Kind=Utc
var dueDateUtc = DateTimeOffset.FromUnixTimeSeconds(dueDateUnix).DateTime;
// dueDateUtc.Kind = DateTimeKind.Utc ✓

// 3. Save to SQL Server via EF Core
// SQL Server datetime2 column does not store Kind
var invoice = new Invoice { DueDate = dueDateUtc, /* ... */ };
await _context.SaveChangesAsync();

// 4. Read back from database
var invoiceFromDb = await _context.Invoices.FindAsync(invoiceId);
// invoiceFromDb.DueDate.Kind = DateTimeKind.Unspecified  ← Kind is lost!

// 5. The comparison
var now = DateTime.Now; // Kind=Local (server's local time = UTC on Azure)
bool isOverdue = now > invoiceFromDb.DueDate;
// Compares Local ticks vs Unspecified ticks
// On Azure (UTC): Local = UTC, Unspecified = UTC → comparison is accidentally correct
// On IST machine: Local = IST (UTC+5:30), Unspecified still has UTC ticks
// IST ticks are 5.5 hours ahead of UTC ticks → IST now appears to be ahead of UTC due date
// Invoice appears overdue 5.5 hours before it actually is

Why this only appeared after the migration

On our on-premises Windows Server configured with IST timezone, DateTime.Now returned IST time. Our stored DueDate values had UTC ticks (from the Unix timestamp conversion). The comparison was comparing IST ticks (higher value) against UTC ticks (lower value). On a machine where IST is UTC+5:30, the IST clock shows a higher number than the UTC clock for the same instant — which meant we were accidentally comparing correctly for most cases (IST now > UTC due-date worked when the invoice was far past due), but failing when the invoice was within the 5.5-hour window before the actual UTC due time.

After moving to Azure (UTC), DateTime.Now returned UTC time, and the UTC vs Unspecified comparison was suddenly closer to correct — which ironically made the bug harder to reproduce since the false positives disappeared. But the underlying data model was still broken, and edge cases were still producing incorrect results.

The fix: DateTimeOffset everywhere

// Fixed: use DateTimeOffset throughout — it always carries an explicit UTC offset
public bool IsInvoiceOverdue(long dueDateUnixSeconds)
{
    // DateTimeOffset.FromUnixTimeSeconds always produces a UTC-based DateTimeOffset
    var dueDate = DateTimeOffset.FromUnixTimeSeconds(dueDateUnixSeconds);

    // DateTimeOffset.UtcNow always returns UTC, regardless of server timezone
    var now = DateTimeOffset.UtcNow;

    // This comparison is always UTC vs UTC — no ambiguity
    return now > dueDate;
}

// In the EF Core entity:
public class Invoice
{
    public int Id { get; set; }

    // DateTimeOffset in EF Core maps to datetimeoffset in SQL Server
    // datetimeoffset stores the UTC offset alongside the value — Kind is preserved
    public DateTimeOffset DueDate { get; set; }
    public DateTimeOffset CreatedAt { get; set; }
}

// In EF Core configuration — ensure the column type is datetimeoffset
// (EF Core maps DateTimeOffset to datetimeoffset automatically in SQL Server)
// In your DbContext.OnModelCreating or entity configuration:
builder.Property(i => i.DueDate).HasColumnType("datetimeoffset");

// The corrected overdue check:
public async Task<bool> IsOverdueAsync(int invoiceId)
{
    var invoice = await _context.Invoices.FindAsync(invoiceId);
    if (invoice is null) return false;

    // DateTimeOffset.UtcNow vs invoice.DueDate: both have explicit UTC info
    // Comparison always correct regardless of server timezone
    return DateTimeOffset.UtcNow > invoice.DueDate;
}

The database migration

Changing a datetime2 column to datetimeoffset requires an EF Core migration. Since the existing values were stored as UTC ticks without an offset, we needed to specify the offset during migration:

// EF Core migration to convert datetime2 to datetimeoffset
public partial class ConvertDueDateToDateTimeOffset : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        // Add new datetimeoffset column
        migrationBuilder.AddColumn<DateTimeOffset>(
            name: "DueDateOffset",
            table: "Invoices",
            type: "datetimeoffset",
            nullable: false,
            defaultValue: DateTimeOffset.UtcNow);

        // Copy existing UTC values with explicit +00:00 offset
        migrationBuilder.Sql(
            "UPDATE Invoices SET DueDateOffset = TODATETIMEOFFSET(DueDate, '+00:00')");

        // Drop old column and rename
        migrationBuilder.DropColumn(name: "DueDate", table: "Invoices");
        migrationBuilder.RenameColumn(name: "DueDateOffset",
            table: "Invoices", newName: "DueDate");
    }
}

The audit: finding other DateTime.Now usages

After fixing this bug, we searched the entire codebase for DateTime.Now and DateTime.UtcNow usage to find other potential timezone-sensitive comparisons:

// Search pattern to audit your codebase
// In Visual Studio: Ctrl+Shift+F with these search terms:
// DateTime.Now
// new DateTime(
// DateTimeKind.Unspecified
// .Kind ==

// For each occurrence, ask:
// 1. Does this DateTime get compared against another DateTime from an external source?
// 2. Does this DateTime get stored in a database and read back later?
// 3. Does this DateTime get formatted and sent to a client or external API?
//
// If any answer is yes, migrate to DateTimeOffset and validate the timezone handling.

The generalizable lesson

DateTime.Now is a trap for distributed .NET applications. It returns the server's local time, which is UTC on cloud infrastructure and IST (or whatever your OS is configured to) on a development machine. The discrepancy is hidden until you move from one environment to the other — exactly when you can least afford to debug it.

The rule I follow now: use DateTimeOffset.UtcNow everywhere instead of DateTime.Now. Use DateTimeOffset for all entity properties that store timestamps. Use DateTimeOffset.FromUnixTimeSeconds() when converting external Unix timestamps. And use the Timestamp Converter to verify any Unix timestamp you receive from an external API matches the human-readable time you expect — a quick sanity check that catches timezone errors before they reach production.

Try the free tool
Timestamp Converter

Convert Unix timestamps to human-readable dates and back. Essential for debugging exactly the kind of UTC vs local time mismatches described in this article.

Open Timestamp Converter