DEV Community

Cover image for 5 C# DateTime bugs that pass every test
Naze Code
Naze Code

Posted on

5 C# DateTime bugs that pass every test

Date and time bugs are the worst kind: the code is correct on the day you test it, in the time zone you test it in. Then the clocks change, or a user in another country logs in, or the CI server runs on a Sunday.

Here are five, each replayed for real on .NET 10. The machine running them is set to UTC+8 (no daylight saving). That detail matters for two of them, which is exactly the point.

1. Subtracting local times across a clock change

// Night shift in New York, the night the clocks jump forward
var start = new DateTime(2026, 3, 8, 1, 0, 0);
var end   = new DateTime(2026, 3, 8, 4, 0, 0);

var hours = (end - start).TotalHours;
Console.WriteLine($"Hours paid: {hours}");
Enter fullscreen mode Exit fullscreen mode
Hours paid: 3
Enter fullscreen mode Exit fullscreen mode

On that night in New York, the clock goes from 1:59 straight to 3:00. 2 AM never happens. The shift lasted two real hours, but subtracting wall-clock times knows nothing about that, so it pays three.

Fix: convert both ends to UTC in the right time zone, then subtract.

var ny = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");

var hours = (TimeZoneInfo.ConvertTimeToUtc(end, ny)
           - TimeZoneInfo.ConvertTimeToUtc(start, ny)).TotalHours;
Enter fullscreen mode Exit fullscreen mode
Hours paid: 2
Enter fullscreen mode Exit fullscreen mode

The opposite night in November is harder: 1 AM happens twice, and a plain DateTime can't say which one it means. ConvertTimeToUtc treats an ambiguous time as standard time, so for those shifts you need to record the offset when the time is captured, not reconstruct it later.

2. DateTime.Parse and the vanishing Z

var json = "2026-10-12T09:00:00Z";      // 9 AM UTC, from the API

var meeting = DateTime.Parse(json);
Console.WriteLine($"Meeting: {meeting:HH:mm} ({meeting.Kind})");
Enter fullscreen mode Exit fullscreen mode
Meeting: 17:00 (Local)
Enter fullscreen mode Exit fullscreen mode

The Z means UTC. DateTime.Parse honors it by converting to the machine's local time zone and marking the result Local. On a UTC+8 machine, 9 AM becomes 5 PM. On a machine set to UTC, local time is UTC, so the bug is invisible there.

Fix: parse into DateTimeOffset, which keeps the offset that was in the string.

var meeting = DateTimeOffset.Parse(json, CultureInfo.InvariantCulture);
Console.WriteLine($"Meeting: {meeting:HH:mm} (offset {meeting.Offset})");
Enter fullscreen mode Exit fullscreen mode
Meeting: 09:00 (offset 00:00:00)
Enter fullscreen mode Exit fullscreen mode

3. == that ignores time zones

var deadlineUtc = new DateTime(2026, 10, 12, 9, 0, 0, DateTimeKind.Utc);
var nowLocal    = new DateTime(2026, 10, 12, 9, 0, 0, DateTimeKind.Local);

Console.WriteLine($"Same moment? {deadlineUtc == nowLocal}");
Enter fullscreen mode Exit fullscreen mode
Same moment? True
Enter fullscreen mode Exit fullscreen mode

On this machine, those two are 8 hours apart. DateTime equality compares the clock reading and ignores Kind completely.

Fix: DateTimeOffset compares actual moments in time.

var deadline = new DateTimeOffset(2026, 10, 12, 9, 0, 0, TimeSpan.Zero);
var now      = new DateTimeOffset(2026, 10, 12, 9, 0, 0, TimeSpan.FromHours(8));

Console.WriteLine($"Same moment? {deadline == now}");
Enter fullscreen mode Exit fullscreen mode
Same moment? False
Enter fullscreen mode Exit fullscreen mode

4. AddDays across a clock change

DateTimeOffset fixed traps 2 and 3. But it has its own blind spot (Ny is TimeZoneInfo.FindSystemTimeZoneById("America/New_York")):

// Daily standup, 9 AM New York. Saturday's meeting:
var saturday = new DateTimeOffset(2026, 3, 7, 9, 0, 0, TimeSpan.FromHours(-5));

var sunday = saturday.AddDays(1);
Console.WriteLine($"Sunday: {TimeZoneInfo.ConvertTime(sunday, Ny):HH:mm} New York time");
Enter fullscreen mode Exit fullscreen mode
Sunday: 10:00 New York time
Enter fullscreen mode Exit fullscreen mode

A DateTimeOffset has a fixed offset, not a time zone. AddDays(1) adds exactly 24 hours and keeps -05:00, but by Sunday morning New York has moved to -04:00. So the "9 AM" standup lands at 10.

Fix: do calendar math in wall-clock time, then ask the time zone for that day's offset.

var saturday = new DateTime(2026, 3, 7, 9, 0, 0);

var sundayLocal = saturday.AddDays(1);                  // wall clock: 9 AM
var sunday = new DateTimeOffset(sundayLocal, Ny.GetUtcOffset(sundayLocal));
Console.WriteLine($"Sunday: {sunday:HH:mm} New York time (offset {sunday.Offset})");
Enter fullscreen mode Exit fullscreen mode
Sunday: 09:00 New York time (offset -04:00:00)
Enter fullscreen mode Exit fullscreen mode

5. A test that depends on what day it is

public class Promo
{
    public bool IsWeekendSale() =>
        DateTime.Now.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
}

// Test: "no sale on a weekday"
var promo = new Promo();
Console.WriteLine(promo.IsWeekendSale() ? "FAIL: sale is on" : "PASS");
Enter fullscreen mode Exit fullscreen mode
FAIL: sale is on
Enter fullscreen mode Exit fullscreen mode

I ran it on a Sunday. From Monday to Friday this test passes, so it'll fail for whoever happens to work on a weekend, and nobody will be able to reproduce it on Monday.

Fix: don't read the clock directly; take a TimeProvider (built into .NET 8 and later).

public class PromoWithClock(TimeProvider clock)
{
    public bool IsWeekendSale() =>
        clock.GetLocalNow().DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
}
Enter fullscreen mode Exit fullscreen mode

Production passes TimeProvider.System. Tests pass a FakeTimeProvider (from the Microsoft.Extensions.TimeProvider.Testing package) set to whatever moment they need:

var clock = new FakeTimeProvider(
    new DateTimeOffset(2026, 10, 14, 12, 0, 0, TimeSpan.Zero));   // a Wednesday
var promo = new PromoWithClock(clock);
Console.WriteLine(promo.IsWeekendSale() ? "FAIL: sale is on" : "PASS");
Enter fullscreen mode Exit fullscreen mode
PASS
Enter fullscreen mode Exit fullscreen mode

Recap

  1. Durations: convert to UTC in the right time zone before subtracting.
  2. Parse timestamps with an offset into DateTimeOffset, not DateTime.
  3. Compare moments with DateTimeOffset; DateTime == ignores Kind.
  4. Calendar math ("same time tomorrow") in wall-clock time, then get the offset from the time zone.
  5. Inject TimeProvider; never read DateTime.Now inside logic you want to test.

What's the worst date bug you've shipped? Tell me in the comments.

I make short, tested videos about C# bugs that compile fine and still break things. More on the Naze Code YouTube channel.


Sources: Microsoft Learn, Choosing between DateTime, DateTimeOffset and TimeZoneInfo, TimeZoneInfo.ConvertTimeToUtc, TimeProvider, FakeTimeProvider. Tested on .NET SDK 10.0.302, machine set to UTC+8.

Top comments (1)

Collapse
 
arhancanli profile image
Arhan Canli •

Two edge cases worth adding to #1 and #4, both on the clock change you already use. In #4 the fix passes sundayLocal (an Unspecified DateTime) to Ny.GetUtcOffset, which is fine for 9 AM. If the standup were 2:30 AM, March 8 has no 2:30 in New York: ConvertTimeToUtc throws ArgumentException for that value, while GetUtcOffset quietly returns the standard offset, so the result is really 3:30 EDT. Neither is wrong, but you need a stated policy (shift forward, or skip the day) and a test that pins it with TimeZoneInfo.IsInvalidTime.

November is the mirror image: 1:30 AM exists twice and ConvertTimeToUtc takes the standard-time reading, so a shift ending at 1:30 in the repeated hour can come out an hour off. A test pair fixing both dates (2026-03-08 and 2026-11-01) with FakeTimeProvider and an explicit zone would catch all of these on any machine, not only the UTC+8 one. I have not run these on .NET 10 myself; this is from the documented behavior. Did your machine's zone change which of the five reproduce?