Pitfalls and traps
General advice
Dates, times, and time zones are tricky. In .NET, there are subtle differences that can be challenging to spot. My general advice is:
- Use
DateTimeOffset
when possible (instead ofDateTime
) - Store, load, work (and think) in UTC as much as possible
- Side-step time zones altogether by displaying time in relative time units when possible (e.g. "2 hours ago" instead of "12/21/2024 9:54:06 AM").
- Avoid methods like
ToLocalTime
orToUniversalTime
as these make assumptions and rely on the server's time zone, which is often not helpful. UseTimeZoneInfo.ConvertTime
instead.
Other gotchas
TimeZoneInfo.ConvertTime(...)
returns a DateTime
with Kind = Unspecified
.
For example:
DateTime dt = DateTime.UtcNow; // dt.ToString("o") -> 2024-12-21T16:54:06.8226301Z // dt.Kind -> Utc TimeZoneInfo tz = TimeZoneInfo.FindSystemTimeZoneById("America/Chicago"); var sf = TimeZoneInfo.ConvertTime(d, tz); // sf.ToString("o") -> 2024-12-21T10:54:06.8226301 // sf.Kind -> Unspecified
Note that the Unspecified time lacks a time zone offset in the output. You might be tempted to set its
Kind
to Local
, but this is usually not a good idea:
// don't do this var sfLocal = DateTime.SpecifyKind(sf, DateTimeKind.Local); sfLocal.ToString("o") -> 2024-12-21T10:54:06.8226301+00:00
The time is correct, but the time zone offset is not. Chicago is -06:00, not +00:00. "Local" in this context is the server's time zone (-00:00).
1That undesired time zone also shows up in the related format strings %K
, %z
,
zz
, and zzz
above. Again, these show the time zone of the server, not the time
zone the value was converted to. Luckily, DateTimeOffset
handles this better:
var dto = DateTimeOffset.UtcNow; var chicagoDto = TimeZoneInfo.ConvertTime(dto, tz); // dto.ToString("zzz") --> +00:00 // chicagoDto.ToString("zzz") --> -06:00
Other surprising things
The Ticks
property is not
agnostic of time zones/kind:
DateTime utc = DateTime.UtcNow; DateTime local = utc.ToLocalTime(); // don't do this DateTime unspecified = TimeZoneInfo.ConvertTime(utc, tz); // utc.Ticks --> 638703968468226672 // local.Ticks --> 638703968468226672 // unspecified.Ticks --> 638703752468226672
I guess it makes sense that Ticks
would vary here, but I personally would have guessed that it was
always in UTC, sort of like Javascript's
getTime
.
And DateTimeOffset
works in the same spirit by factoring in the offset:
// these are the same for DateTimeOffset, but not DateTime // dto.Ticks --> 638703968468226600 // chicagoDto.Ticks --> 638703752468226600 // // and the difference is the same as the time zone offset ✔️: // (chicagoDto.Ticks - dto.Ticks) / TimeSpan.TicksPerHour --> -6