Beware the Edge (cases) of Time!

Ahh, time zones.  There are so many wonderful traps to fall into.

Consider this bit of code, from a recent blog post by Rick Strahl:

/// <summary>
/// Converts a local machine time to the user's timezone time
/// by applying the difference between the two timezones.
/// </summary>
/// <param name="localTime">local machine time</param>
/// <param name="tzi">Timezone to adjust for</param>
/// <returns></returns>
public DateTime AdjustTimeZoneOffset(DateTime localTime, TimeZoneInfo tzi = null)
{
    //if (tzi == null)
    //    tzi = TimeZoneInstance;

    var offset = tzi.GetUtcOffset(localTime).TotalHours;
    var offset2 = TimeZoneInfo.Local.GetUtcOffset(localTime).TotalHours;
    return localTime.AddHours(offset2 - offset);
}

Ignore the part I commented out, which just resolves the time zone from a locally cached copy.  The guts of the code are at the bottom of the function.

On the surface, this looks like reasonable code.  It converts a DateTime value from the computer's local time zone to the time zone passed in to the tzi variable.  But let's put this under test in some carefully chosen edge cases.

First Test Case

Since the code is dependent on the local time zone, let's change the computer's time zone to US Pacific Time.

Pacific Time Zone Setting

Now let's try a simple test using xUnit.net.

[Fact]
public void Test1()
{
    var tzi = TimeZoneInfo.FindSystemTimeZoneById("Mountain Standard Time");
    var localTime = new DateTime(2015, 1, 1, 0, 0, 0);
            
    var actual = AdjustTimeZoneOffset(localTime, tzi);
    Debug.WriteLine(actual);

    var expected = new DateTime(2015, 1, 1, 1, 0, 0);
    Assert.Equal(expected, actual);
}

This fails, but actually isn't even an edge case.  We intended to convert from midnight Pacific time, to Mountain time, which we can see here, should have been 1:00 AM.  But instead, it converted the opposite direction, to 11:00 PM the previous day.  The offset is being applied in the wrong direction.  So let's fix that.

public DateTime AdjustTimeZoneOffset(DateTime localTime, TimeZoneInfo tzi)
{
    var offset = tzi.GetUtcOffset(localTime).TotalHours;
    var offset2 = TimeZoneInfo.Local.GetUtcOffset(localTime).TotalHours;
    return localTime.AddHours(offset - offset2);  // <--- I changed this line
}

Now the test passes, so next let's look at the edge case.

Second Test Case

[Fact]
public void Test2()
{
    var tzi = TimeZoneInfo.FindSystemTimeZoneById("Mountain Standard Time");
    var localTime = new DateTime(2015, 3, 8, 1, 0, 0);
            
    var actual = AdjustTimeZoneOffset(localTime, tzi);
    Debug.WriteLine(actual);

    var expected = new DateTime(2015, 3, 8, 3, 0, 0);
    Assert.Equal(expected, actual);
}

The test is almost the same as the first one, except we've deliberately chosen a date near the spring-forward daylight saving time transition.  In both of the time zones we're testing, at 2:00 AM, the time advances to 3:00 AM. So even though these zones are normally an hour apart, on this day at 1:00 AM Pacific Standard Time, it is already 3:00 Mountain Daylight Time (verification here).

The test fails, returning 2015-03-08 02:00:00 - which is a local time that doesn't exist (since the clocks sprung forward).

Third Test Case

[Fact]
public void Test3()
{
    var tzi = TimeZoneInfo.FindSystemTimeZoneById("Mountain Standard Time");
    var localTime = new DateTime(2015, 3, 8, 1, 0, 0, DateTimeKind.Local);
            
    var actual = AdjustTimeZoneOffset(localTime, tzi);
    Debug.WriteLine(actual);

    var expected = new DateTime(2015, 3, 8, 3, 0, 0);
    Assert.Equal(expected, actual);
}

Test3 is almost identical to Test2, but it passes!  The only thing that changed is we specified DateTimeKind.Local when creating the input date.  You might think "isn't that redundant?", and you'd be correct.  Since the input time was being applied against the local time zone anyway, specifying that it's local shouldn't really matter.   But in this case, it does.  I'll explain why shortly.

A Root Cause

Let's dig deeper into what's going on with Test2 and Test3.

In our AdjustTimeZoneOffset method, we make two calls to TimeZoneInfo.GetUtcOffset.  We use the same input value against two different time zones.  Let's go to the MSDN.

... If the dateTime parameter's Kind property does not correspond to the time zone object, this method performs the necessary conversion before returning a result. For example, this can occur if the Kind property is DateTimeKind.Local but the time zone object is not the local time zone. ...

That's a mouthful, but what it's saying is that for this particular method, the TimeZoneInfo object that the method is called on is the intended frame of reference for the DateTime value that is passed in.  So if the value has a Kind of DateTimeKind.Unspecified, then it is assumed to be in that particular time zone already.  If it's DateTimeKind.Local or DateTimeKind.Utc, then the value is first converted to that time zone, from the local time zone or from UTC, respectively.

So in Test2, we passed the same value to both method calls, but is it really the same value?  No, it's not.  That is, it doesn't refer to the same moment in time in both cases.

Fully expanded:

TimeZoneInfo.FindSystemTimeZoneById("Mountain Standard Time")
            .GetUtcOffset(new DateTime(2015, 3, 8, 1, 0, 0))
            
TimeZoneInfo.Local.GetUtcOffset(new DateTime(2015, 3, 8, 1, 0, 0))

The first line gets the offset at 1:00 Mountain time, and the second line gets the offset at 1:00 local time (which we set as Pacific).  They're both 1:00, but they're in two different time zones, and the Kind is Unspecified, so they are interpreted as belonging to each time zone, without conversion.  (If we were to convert, we'd see they correspond to 8:00 AM UTC and 9:00 AM UTC respectively - two different moments in time.)

Since the input DateTime was expected to be in the local time zone, we did get the correct offset for the local time.  But we got the offset for the destination time zone for the moment one hour earlier than intended.

Explaining the Fix

So what about Test3.  Why did it pass?  Well remember what the MSDN remark said, if the Kind is Local or Utc, then it is first converted.

TimeZoneInfo.FindSystemTimeZoneById("Mountain Standard Time")
    .GetUtcOffset(new DateTime(2015, 3, 8, 1, 0, 0, DateTimeKind.Local))
            
TimeZoneInfo.Local
    .GetUtcOffset(new DateTime(2015, 3, 8, 1, 0, 0,  DateTimeKind.Local))

You can see, the local time was used in both cases - even when we weren't working with the local time zone.  Let's apply the conversions ourself, and we'll get the equivalent:

TimeZoneInfo.FindSystemTimeZoneById("Mountain Standard Time")
    .GetUtcOffset(new DateTime(2015, 3, 8, 3, 0, 0))  // <-- this value was adjusted
            
TimeZoneInfo.Local
    .GetUtcOffset(new DateTime(2015, 3, 8, 1, 0, 0))

Now you can see that the correct offsets are obtained, because one value was shifted to compensate it being passed with DateTimeKind.Local to a non-local time zone.

But what if neither time zone is local?

We were able to use DateTimeKind.Local to work around the first problem, but what about when the input value must have DateTimeKind.Unspecified, when the local time zone isn't involved?  Consider a slightly different method:

public DateTime AdjustTimeZoneOffset(DateTime dt, TimeZoneInfo sourceTZ, TimeZoneInfo destTZ)
{
    var sourceOffset = sourceTZ.GetUtcOffset(dt).TotalHours;
    var destOffset = destTZ.GetUtcOffset(dt).TotalHours;
    return dt.AddHours(destOffset - sourceOffset);
}

And the test:

public void Test4()
{
    var tz1 = TimeZoneInfo.FindSystemTimeZoneById("GMT Standard Time");
    var tz2 = TimeZoneInfo.FindSystemTimeZoneById("Romance Standard Time");
    var dt = new DateTime(2015, 3, 29, 2, 0, 0);

    var actual = AdjustTimeZoneOffset(dt, tz1, tz2);
    Debug.WriteLine(actual);

    var expected = new DateTime(2015, 3, 29, 3, 0, 0);
    Assert.Equal(expected, actual);
}

This will convert the local time in London to the local time in Paris, as validated here.  It should return 3:00 AM, but it only returns 2:00 AM.  That's the same value that we passed in! Again, that pesky DST transition is getting in the way.  You see, Europe handles DST slightly different than the USA.  In the USA, each time zone changes at 2:00 AM in their own local time.  But in Europe, all participating EU countries transition at exactly the same instant (at 1:00 AM UTC).

So what's the fix?  This time, we can't just pass DateTimeKind.Local, because neither value is our computer's local time zone.   We might be able to pass DateTimeKind.Utc, but that would only work when London was aligned to GMT.  After it's in daylight time (that is, "British Summer Time", or BST), then that wouldn't work at all.  In the general case, we will always need to pass values that are Unspecified.  So what to do?

Well there's a few options.  The first way will manually adjust the source time to UTC, and mark it as DateTimeKind.Utc, such that the correct instant is chosen for the destination time zone:

public DateTime AdjustTimeZoneOffset(DateTime dt, TimeZoneInfo sourceTZ, TimeZoneInfo destTZ)
{
    var sourceOffset = sourceTZ.GetUtcOffset(dt).TotalHours;
    var utcTime = DateTime.SpecifyKind(dt.AddHours(-sourceOffset), DateTimeKind.Utc);
    var destOffset = destTZ.GetUtcOffset(utcTime).TotalHours;
    return dt.AddHours(sourceOffset - destOffset);
}

Our test will now pass - but that's an awful lot of complexity.  We can reduce it a bit further:

public DateTime AdjustTimeZoneOffset(DateTime dt, TimeZoneInfo sourceTZ, TimeZoneInfo destTZ)
{
    var utcTime = TimeZoneInfo.ConvertTimeToUtc(dt, sourceTZ);
    var sourceOffset = sourceTZ.GetUtcOffset(utcTime).TotalHours;
    var destOffset = destTZ.GetUtcOffset(utcTime).TotalHours;
    return dt.AddHours(sourceOffset - destOffset);
}

Upon even further investigation, we'll find that we can actually complete the entire function in one step:

public DateTime AdjustTimeZoneOffset(DateTime dt, TimeZoneInfo sourceTZ, TimeZoneInfo destTZ)
{
    return TimeZoneInfo.ConvertTime(dt, sourceTZ, destTZ);
}

Now, there's not even any real need for the function in the first place, as it just wraps another built in function.

Applying this to the original problem

In the original code, we wanted to convert a DateTime from the local computer's time zone, to a specific destination time zone.  It turns out, this can just be done with one line of code:

public DateTime AdjustTimeZoneOffset(DateTime localTime, TimeZoneInfo tzi)
{
    return TimeZoneInfo.ConvertTime(localTime, tzi);
}

Isn't there a better way?

YES there are two better ways I can think of:

  • You could use DateTimeOffset types instead of DateTime types.  The .Kind value doesn't exist on a DateTimeOffset.  Instead, a specific offset is tracked, such as -08:00 for Pacific Standard Time, or -07:00 for Pacific Daylight Time.  The TimeZoneInfo object has many method overloads that work with DateTimeOffset instead of DateTime, which makes all of these problems much easier to solve.
  • You could use the Noda Time library.  You would probably do something like this:
public ZonedDateTime AdjustTimeZone(LocalDateTime ldt, DateTimeZone sourceTZ, DateTimeZone destTZ)
{
    return ldt.InZoneLeniently(sourceTZ).WithZone(destTZ);
}

The InZoneLeniently method brings up another edge case that we didn't even talk about.  What if the input date is invalid or ambiguous?  In other words, when the clocks jump from 2:00 to 3:00, what do we do with an invalid value of 2:30?  And when they fall back from 2:00 back to 1:00, what does a value of 1:30 mean?  Does it mean the first occurrence, or the second?  Noda Time offers several ways to handle that, of which leniently is one choice.  There are other choices, and you can even provide your own custom logic if desired.

Conclusion

Rick Strahl is a really smart guy.  I've been enjoying his blog for many years, and he's opened my eyes to many solutions to common problems.  The post this code came from was quite well thought through, and covered many of the concerns encountered when working with time zones.  But ultimately, several key mistakes were made - and it's not Rick's fault!  Many other really smart developers make similar mistakes, because it's just too easy to do so!

I'll leave you with these thoughts:

  • Date, Time, and Time Zone are not simple data types.  They make up a domain.  There is logic to consider about how the different components interact, and those interactions can be complex!
  • There are many edge cases, especially around DST transitions.  Test, Test, and Test again!
  • The built-in types can get the job done, but they are also easy to use incorrectly.  There was nothing that stopped us from passing values to a function that gave unintended results.  Ultimately, the simplicity of DateTime and its cousins are what leads to errors.
  • Noda Time offers a better path forward.  There is more to learn, but it's also much harder to hook things up in a way that creates problems.  If you aren't currently using Noda Time, I strongly encourage you to start.  Anywhere you encounter DateTime or TimeZoneInfo in your code is a good candidate for using Noda Time instead.