June 30, 2019

7 "Tick-bait" gotchas for Date-Times in .NET (C#)

.NET provides structs to help you manage time and timezones. They represent the bare-basics for what you need to handle time accurately.

7 "Tick-bait" gotchas for Date-Times in .NET (C#)

.NET has classes built in to help you manage time. They represent the bare basics for what you need to get Time-zones working correctly. Here I'll discuss what you get out of the box to signpost you in the right direction, and traps to avoid.

TimeSpan

A TimeSpan represents a duration of time. Here are some useful methods:

new Timespan(days, hours, minutes, seconds);
var ts = Timespan.FromHours(1.5) + Timespan.FromMinutes(6);
ts.TotalMinutes; // outputs 96
ts.Minutes;      // outputs 36
ts.TotalHours;   // outputs 1.6
ts.Hours;        // outputs 1

TimeSpans always represent exact lengths of time, so they don't support concepts of like months or years. (If you want to find difference between dates by these amounts, Check out System.Data.Linq.SqlClient.SqlMethods.DateDiffMonth and DateDiffYear functions)

Leap seconds and daylight savings are ignored, so minutes and days are considered exact time lengths.

DateTime

A DateTime represents a date and a time. It has constructors that accept the units of a DateTime you would expect, and which I won't go into. I will briefly point out that DateTime.Parse can accept a string to create a new DateTime, and that passing an ISO8601 DateTime (extended format) like "2019-06-24T13:01:24.400" is ideal because it avoids any formatting ambiguity between cultures. Even so, you can use other formats safely if you supply the CultureInfo argument.

Here are some useful methods:

new DateTime(2017, 7, 30);
var dt = DateTime.Parse("2019-06-24T13:01:24.4");
DateTime dt2;
dt2 = dt.AddSeconds(2);
dt2 = dt.AddMonths(4);                           // see note below
dt2 = dt.Add(new TimeSpan(0, 1, 15, 0));
dt2 = dt.Subtract(new TimeSpan(0, 1, 15, 0));    //returns Date
var ts = dt.Subtract(DateTime.Now);                 //returns TimeSpan difference
var xDay = dt2.DayOfWeek;

Note all the methods create new Dates - none of the methods modify the original date object. In fact, this is not possible at all, because DateTime is a struct.

Gotcha 1: AddMonths

The behaviour of the AddMonths function is a little peculiar by necessity. It adds 1 month to the new date, and then if the new date is invalid, it will subtract days until it is valid.
This means March 31 + 1 month = April 30.
And March 31 + 2 months = May 31
But, March 31 + 1 month + 1 month = May 30.

DateTimes support non-Gregorian calendars such as Persian, Chinese and Hijri, but this is beyond the scope of this article - see DateTime(Int32, Int32, Int32, Calendar).

Gotcha 2: DateTimeKind

Did you know that a DateTime has a concept of a timezone offset in it? There is a property of a DateTime called "Kind". Which has three values:

  • Unspecified - this is the default when you use a Date constructor that doesn't specify a Kind as an argument.
  • Local - this is the default when you use DateTime.Now
  • Utc - this is the default when you use DateTime.NowUtc

If you create a DateTimeOffset from a DateTime it's important to make sure that the "Kind" is correct.

You can freely convert times between Local and UTC via .ToUniversalTime() and .ToLocalTime().

If you maintain the Kind, then you're fine, but you may find weird behaviour happens when you start comparing DateTimes of different DateTimeKinds together.

Gotcha 3: DateTimeKind.Local for Web

For web, keep in mind "Local" means the server's timezone - not the user's timezone. If this is all you need to support, you can get away with using DateTime with careful management.

Gotcha 4: Unspecified DateTimeZoneHandling for Web

Specifying a default serialisation DateTimeKind is useful for web development. If your kind is Utc, you will send Dates back to client with a UTC timezone signified. If local, you will send the EQUIVALENT Utc back to the client with a UTC timezone signified. Both of these are fine. However, if your kind is Unspecified, no timezone conversion occurs and no timezone is indicated in the message to the client. If your date makes it to JavaScript in this form, the client will assume the date is in the timezone OF THE USER. What behavior you actually want will depend on your application.

Specifying the default Kind is different depending on the server setup. In MVC Core, the kind can be set as follows:

services.AddMvc().AddJsonOptions(options => {
    options.SerializerSettings.DateTimeZoneHandling = DateTimeZoneHandling.Utc;
});

DateTimeOffset

A DateTimeOffset is effectively a DateTime and a TimeSpan. The TimeSpan is the offset from UTC. I.e. subtracting the timespan from the date and time will give you the equivalent UTC date and time.

Here are some useful methods:

var dto = DateTimeOffset.UtcNow;
dto = dt.AddSeconds(4);
dto = dto.ToOffset(TimeSpan.FromHours(1)); //time in UTC+1:00

var dt = DateTime.Parse("2019-06-24T13:01:24.4-06:20");

//the current date and local system offset
dto = DateTimeOffset.Now;

//the current date and local system offset (long version)
dto = new DateTimeOffset(DateTime.Now,
    TimeZoneInfo.Local.GetUtcOffset(DateTime.UtcNow));

An offset from UTC is not the same thing as a timezone, as time-zones can have multiple UTC offsets that take place at different times of the year due to daylight savings or historical changes. This can be a common source of errors which users in daylight saving areas have learned to put up with.

DateTimeOffset.Parse can accept a string to create a new DateTimeOffset. Use ISO8601 where possible to avoid ambiguity.

TimeZoneInfo

TimeZoneInfo contains information about a particular timezone. You have the ability to create Timezones yourself, but using the system provided timezones is also possible:

System.TimeZoneInfo.GetSystemTimeZones();

Gotcha 5: TimeZone ID in Windows vs others

When you use system time zones in a client executable or similar, keep in mind that if you save the TimeZone ID, you're not guaranteed another system will recognise the TimeZone by that ID - especially for different operating systems.

Despite TimeZoneInfo simplifying the process of working out exact dates across the globe, it's also another source of disagreement. Windows has a TimeZone list which is different to other operating systems, and different to the browser. Also, different versions of Windows have different TimeZones, which could make migration a challenge if the timezone is saved in the database by ID. Linux uses IANA timezones, which matches what modern web browsers tend to use. (By the way, NodaTime has a handy conversion feature for different timezone systems)

As a further note, as the System TimeZoneInfo definition list is stored in the registry settings, it's possible for the user to editing their timezone definitions directly or indirectly through some other program installation, though unlikely. This might be important for stand-alone executables.


Gotaha 6: Converting Timezones with BaseUtcOffset

With a DateTimeOffset and a target TimeZoneInfo, you can easily shift the offset to match the TimeZone.

Here is the wrong way to make your DateTimeOffset's timezone match:

TimeZoneInfo tzi = ...
dto.toOffset(tzi.BaseUtcOffset);

While in majority of situations this may work, many TimeZones implement daylight savings, and the above doesn't take this into account.

There are about 70 countries in the world that use Daylight Savings - including Australia, United States, Egypt, UK, Mexico and Brazil.

This means, at certain times of the year, the UtcOffset shifts backwards and forwards. The dates when the times shift themselves can sometimes change.

Thankfully, the operating system has these rules defined and updated regularly, and has a way to retrieve the offset that applies based on a date - another good reason to use GetSystemTimeZones().

dto.toOffset(tzi.getUtcOffset(dto));

Gotcha 7: Daylight Savings and Adding time

Because a DateTimeOffset doesn't take timezones into account, simply adding days will not always be the best thing to do. If you are dealing with some kind of concept where time of day is preserved before/after daylight savings, (e.g. setting an alarm for 5PM every day), then the below is appropriate:

dto.toOffset(tzi.getUtcOffset(dto)).AddDays(1);

However, if you are dealing with a concept where you want to specifically add a duration of time, (i.e. 24 hours), then daylight savings could mean that the above code actually added 23 or 25 hours. Ironically, to ignore the effect of daylight savings, we have to CONSIDER daylight savings in our code.

INCORRECT METHOD: Using BaseUTCOffset, then adding days

dto.toOffset(tzi.BaseUtcOffset).AddDays(1);

GetBaseUtcOffset pretends daylight savings isn't possible for the timezone. This means it is almost the same as the above code, except it has the added mistake of "undoing" daylight saving that might already be in effect.

CORRECT METHOD 1: Using UTC Offset on the adjusted date

dto = dto.AddDays(1);
dto = dto.toOffset(tzi.getUtcOffest(dto));  //change Timezoneoffset for DST?

I used to think this was an incorrect method for adding a day to a time. After all, if adding 1 day makes the new time falls within the hour of a daylight saving change, then an invalid or ambiguous time has been specified. However in both situations, no exception is thrown. This is because whilst the time may be ambiguous or invalid for the timezone, it is never so for the specified offset.

When you try to find the UtcOffset for a time that is invalid in the timezone, you will get the offset for the next offset to be applied:

var tz = TimeZoneInfo.GetSystemTimeZones()
    .First(x => x.Id.Equals("Cen. Australia Standard Time"));
var d = new DateTimeOffset(2019, 10, 6, 2, 30, 00, tz.BaseUtcOffset());
        
//"d" does not occur in the timezone.
var offset = tz.GetUtcOffset(d.AddHours(-1));   //9:30
offset = tz.GetUtcOffset(d);                    //10:30
offset = tz.GetUtcOffset(d.AddHours(1));        //10:30

On October 6, 2019 in Australia Standard Timezone, the clocks go forward at 2am to 3am. Hence 2:30 am that day is not a real time, but we still get an offset of 10:30 for that time.

This is because even though the time you are looking for doesn't exist in the Timezone, A DateTimeOffset is just a UTC time and an offset, so it will always refer to some real instant/moment in time.

When you try to find the UtcOffset for a time that is ambiguous in the timezone, you will also get an offset, not an exception:

var tz = TimeZoneInfo.GetSystemTimeZones()
    .First(x => x.Id.Equals("Cen. Australia Standard Time"));
var d = new DateTimeOffset(2019, 4, 7, 2, 30, 00, tz.BaseUtcOffset());
        
//"d" does not occur in the timezone.
var offset = tz.GetUtcOffset(d.AddHours(-1));   //10:30
offset = tz.GetUtcOffset(d);                    //9:30
offset = tz.GetUtcOffset(d.AddHours(1));        //9:30

On April 7, 2019 in Australia Standard Timezone, the clocks go backward at 3am to 2am. Hence 2:30 am occurs twice on that day. GetUtcOffset returns the offset that was enforced at the instant you specified, which is free from any DST ambiguity. In this example, it returned 9:30 because we were using the 9:30 timezone offset (baseutcoffset). If we were using the 10:30 offset, it would have returned 10:30.

This is because even though the time you stated occurred twice in the timezone, it only occurred once for that UTC offset - it is still an instant, and you can work out which offset was enforced at that instant for the timezone being observed.

CORRECT METHOD 2: Method 1 but UTC.

dto = dto.toOffset(TimeSpan.Zero).AddDays(1);
dto = dto.toOffset(tzi.getUtcOffset(dto));

Converting dto to a UTC offset (Timespan of zero) means we very clearly will avoid any daylight savings conversions when adding the day. Then we convert this new date to the timezone we want with timezones taken into account. It is basically the same logic as the other correct method, but I favour it because logically it makes it very clear that you are ignoring the effects of daylight savings.

One Final Gotcha

dto = dto.AddHours(24);

The above is very straightforward - we just use hours to add a duration of time equaling a day, rather than increasing the DATE by a day, and most of the time this will work exactly as you want. HOWEVER Daylight Savings strikes again, and if you're unlucky enough to be adding time over the period where DST begins or ends, this logic will not compensate - meaning you may be 1 hour short or 1 hour long. The best way to facilitate this logic properly is to use Method 2 - BUT keep in mind when you convert BACK to local time from UTC you may still get into an unfortunate situation where your time is 2:10 AM local , and there are two 2:10 AMs that day...

💀
Morbid Fact!
Hospitals come into this situation sometimes, where it can even look like someone died before their last observation where it said they were alive - i.e. 2:30 observation is "they're alive", DST change happens at 3AM, clocks go back and then time of death is the second "2:10".


For more advanced time concepts, NodaTime is a handy library that expresses more time-related concepts, and with less ambiguity than .NET.

Cover photo credit:  Jon Tyson