What I wish my .NET timezone library handled
Five recurring footguns in .NET timezone code — and the operations we shipped to make them disappear. Including the one where 02:30 AM doesn't exist.
Spring Forward 2026 broke our scheduler.
A user in New York picked “02:30 AM” for a recurring meeting. The scheduler called new DateTime(2026, 3, 8, 2, 30, 0) and TimeZoneInfo.ConvertTimeToUtc() threw. The clock had jumped from 02:00 to 03:00 — 02:30 didn’t exist that day.
That was the third timezone bug we’d shipped that quarter. We sat down and built the library we wished existed.
Five recurring footguns
Handling time correctly in .NET is notoriously difficult. The same friction points show up over and over:
- Identifier hell. Linux and macOS use IANA IDs (
Europe/London). Windows uses legacy IDs (GMT Standard Time). Cross-platform deployment causesTimeZoneNotFoundExceptioncrashes if you don’t handle the fallback yourself. DateTimeKindtraps. Forgetting to forceDateTimeKind.Utcor.Unspecifiedbefore converting times causes silent logic errors. The compiler can’t help you.- Daylight Saving Time gaps. The bug above. Constructing schedules using local components occasionally asks for an invalid time, because the clock jumped over it.
- Boundary race conditions. Calling
.ConvertTimeFromUtc(DateTime.UtcNow, ...)to find the start of the day, and again a microsecond later to find the end, can return dates exactly 24 hours apart if the system clock ticked over midnight between the calls. - Database query gymnastics. Filtering UTC database columns by a “local” user date requires manual conversion, math, and offset manipulation that clutters every repository with duplicated logic.
We hit all five.
What we built
SystemTimezoneExtensions exposes intuitive, DST-safe, strongly-typed operations on a SystemTimezone enum.
var tz = SystemTimezone.AmericaNewYork;
// Identifier independence — cached internally for performance
string iana = tz.ToIanaId(); // "America/New_York"
string win = tz.ToWindowsId(); // "Eastern Standard Time"
TimeZoneInfo info = tz.ToTimeZoneInfo();
// Right now in any timezone
DateTime localDateTime = tz.NowIn(); // Kind = Unspecified
DateTimeOffset localDto = tz.NowOffsetIn(); // includes the +09:00-style offset
DateOnly today = tz.TodayIn();
TimeOnly now = tz.TimeNowIn();
The library handles cross-platform resolution behind the scenes. tz.ToTimeZoneInfo() works the same way on Lambda, your Linux dev container, and your Windows CI box.
The DST gap example
This is the one that caused the original bug:
var tz = SystemTimezone.AmericaNewYork;
// March 8 2026, NY clocks jump 02:00 → 03:00. 02:30 does not exist.
DateTimeOffset attempt = tz.At(2026, 3, 8, 2, 30, 0, out bool wasAdjusted);
// wasAdjusted = true.
// Result automatically pushed forward to next valid tick:
// 2026-03-08T03:30:00-04:00 (EDT)
// Manual validation if you're building custom UI logic:
bool isInvalid = tz.IsInvalidTime(new DateTime(2026, 3, 8, 2, 30, 0)); // true
bool isAmbiguous = tz.IsAmbiguousTime(new DateTime(2026, 11, 1, 1, 30, 0)); // fall-back overlap
The out bool wasAdjusted is the part that matters. If you care, you can warn the user. If you don’t, the library picked the deterministic correct answer for you and your code keeps moving.
The fall-back overlap (when clocks repeat the 1 AM hour in November) gets the same treatment — IsAmbiguousTime lets you detect it, and .At() resolves to the first occurrence by default.
Snapshots — the race-free boundary
When you need both the start and end of a day, take a snapshot:
var tz = SystemTimezone.EuropeLondon;
TimezoneSnapshot snap = tz.Snapshot(); // captures one UtcNow tick
snap.Now; // exact moment captured
snap.Today; // the date it belongs to
snap.TimeOfDay; // local time of day
snap.TodayStart; // local 00:00:00
snap.TodayEnd; // local 23:59:59.999
snap.Elapsed; // since midnight
snap.Remaining; // until next midnight
Everything derives from one UtcNow reading. The physical clock can tick over midnight while you’re computing — your snapshot won’t.
Period boundaries that actually work
Daily, weekly (ISO or US), monthly, yearly — all timezone-aware:
var tz = SystemTimezone.AfricaLagos;
DateTimeOffset startOfDay = tz.TodayStartIn();
DateTimeOffset endOfDay = tz.TodayEndIn();
DateTimeOffset isoWeek = tz.StartOfWeekIn(); // Monday
DateTimeOffset usWeek = tz.StartOfWeekIn(DayOfWeek.Sunday);
DateTimeOffset monthStart = tz.StartOfMonthIn();
DateTimeOffset yearStart = tz.StartOfYearIn();
Perfect for reports, UI calendars, and database aggregations. No more “let me UTC-convert this date and see what midnight in Lagos looks like.”
Wall-clock duration that respects DST
The detail every other library gets wrong:
var tz = SystemTimezone.AmericaNewYork;
// Spring-Forward day: returns 23 hours, not 24
TimeSpan d = tz.WallClockDurationBetween(
new DateTime(2026, 3, 8, 0, 0, 0),
new DateTime(2026, 3, 9, 0, 0, 0));
If your billing system charges by the hour and a customer’s session spans Spring Forward, this is the difference between billing them for 24 hours of compute (wrong) and 23 hours (right). Naïvely subtracting wall-clock times loses an hour every spring and gains one every fall.
Cross-zone conversion in one call
var ny = SystemTimezone.AmericaNewYork;
var tokyo = SystemTimezone.AsiaTokyo;
// Database UTC → user's local
DateTime nyTime = dbUtc.FromUtcTo(ny);
// User's local → database UTC
DateTime saveToDb = userInput.ToUtc(ny);
// Direct between zones
DateTime arrivalInTokyo = userInput.ConvertTo(from: ny, to: tokyo);
// Wrap an unspecified DateTime with its DST-aware offset
DateTimeOffset dto = userInput.AsDateTimeOffsetIn(ny);
Every method returns the right type. No DateTimeKind ambiguity. No “did this get UTC-converted twice?” doubt at code review.
Same-day comparison that respects timezone
var tz = SystemTimezone.AfricaLagos;
var orderTime = new DateTimeOffset(2026, 3, 5, 23, 0, 0, TimeSpan.Zero);
var orderTwo = new DateTimeOffset(2026, 3, 6, 1, 0, 0, TimeSpan.Zero);
bool sameDayInLagos = orderTime.IsSameDayIn(orderTwo, tz); // true (both are March 6 in Lagos)
bool sameInstant = orderTime.IsSameInstant(orderTwo); // false (2 hours apart)
DateOnly orderDate = orderTime.DateIn(tz); // for GroupBy / Select
TimeOnly orderTime = orderTime.TimeIn(tz);
The killer detail: extracting DateOnly/TimeOnly for a specific zone makes GroupBy queries trivial. No more “let me LINQ-Project this through a manual ConvertTime in the projection” gymnastics.
Testable via AsyncLocal TimeProvider
Time-based tests shouldn’t be flaky or require DI mock framework gymnastics:
using Microsoft.Extensions.Time.Testing;
[Fact]
public async Task Daily_Report_Filters_Correctly()
{
// Freeze time to exactly midnight on New Year's Eve
var fakeTime = new FakeTimeProvider(
new DateTimeOffset(2026, 12, 31, 0, 0, 0, TimeSpan.Zero));
// Bound to the current async execution context only.
// Flows into all awaited tasks. Doesn't bleed into other tests.
SystemTimezoneExtensions.SetTimeProvider(fakeTime);
var tokyoToday = SystemTimezone.AsiaTokyo.TodayIn();
Assert.Equal(new DateOnly(2026, 12, 31), tokyoToday);
SystemTimezoneExtensions.ResetTimeProvider();
}
The AsyncLocal<T> wrapper means time-frozen tests can run in parallel without conflicting with each other. We had a test suite of 200 timezone tests that took 90 seconds serially. After this rework: 9 seconds, parallel.
The takeaway
Don’t write timezone code that knows about DST. Use a library that already does.
The general principle ports beyond .NET: every operation that touches local time should be a single function call that takes a timezone, returns a typed result, and handles the DST and identifier-resolution edge cases internally. If you find yourself reaching for TimeZoneInfo.ConvertTimeToUtc and DateTimeKind in your domain code, you’re one Spring Forward away from an incident.
“Today in Lagos” should be one line. So should everything else.