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.