TL;DR

I had to learn the hard way that time zones change over the course of history. And that converting times to/from UTC needs to take this into account, but that it’s dependent on the system that you’re running on how sophisticated this gets. Read the full story to learn how this can impact your code as well.

The challenge

So here’s the challenge: when it’s 1900-01-02 at 00:00 local time in Amsterdam, The Netherlands, what’s the time in UTC? The math is easy: The Netherlands uses Central European Time (CET), which is 1 hour later than UTC, or 2 hours in the summer with Daylight Savings Time.

Seems simple, right? In January, there’s 1 hour difference, so it must be 11pm the day before. We can use the TimeZoneInfo.ConvertTimeToUtc function to convert the local DateTime to UTC using the specified time zone:

// We're using the TimeZoneConverter NuGet package to handle some of the
// nastier details of retrieving time zone info from the system.
using TimeZoneConverter; 

var localDateTime = new DateTime(1900, 1, 2, 0, 0, 0, DateTimeKind.Unspecified);
var amsterdamTimeZone = TZConvert.GetTimeZoneInfo("W. Europe Standard Time");

var utcTime = TimeZoneInfo.ConvertTimeToUtc(localDateTime, amsterdamTimeZone);

We can write a simple test for this, and sure enough, the utcTime is 1900-01-01 23:00, one hour earlier than the local time, just as we’d expect.

// using FluentAssertions
utcTime.Should().Be(new DateTime(1900, 1, 1, 23, 0, 0, DateTimeKind.Utc)); 

Test passes. Done. Commit the code, and move on to the next feature.

The problem

But then the build pipeline breaks. When running this test in our Continuous Integration build in Azure DevOps, the test fails. It says:

Expected utcTime to be <1900-01-01 23:00:00>, but found <1900-01-02>.

So when running this same conversion logic on the build server, there is no difference between local time and UTC. When double-checking locally, the test still passes. So what’s going on here? We must be dependent on some platform-specific piece of logic, but what could it be?

The method to convert the local time to UTC takes 2 parameters: the DateTime to convert to UTC, and the Time Zone where to convert it from.

  • The DateTime is a local time, without any associated time zone information. That’s not dependent on the system on which the code runs.
  • The Time Zone information is just that: information about a given Time Zone. Sometimes problems can occur when the system cannot find the TimeZone that you’re looking for. But in our case, it could find the TimeZone just fine, on both systems. So that can’t be platform-specific either…

Maybe the problem is in the surrounding code. In our production code, we were serializing and deserializing DateTime objects from JSON, perhaps the difference crept in there. But still, we could not find anything that was different between the local system and the build agent.

The diagnosis

The only possible way we could think of to diagnose this was to try and log anything and everything that was used.

So we logged the local date time.

We logged the DateTimeKind of the date time.

We logged the name of the TimeZoneInfo object that was returned.

We logged the BaseUtcOffset of the TimeZoneInfo that was used.

None of it showed any difference between local and the build agent. But the test still failed. So we dove deeper into the TimeZoneInfo object and found the AdjustmentRules. And that’s where we found the difference: when running locally, the TimeZoneInfo only had one AdjustmentRule, but the TimeZoneInfo object that was created on the build agent contained a large array of AdjustmentRule objects.

The understanding

As it turns out, Time Zones change during the course of history. And in our particular test case, we just happened to use a date in the past, in 1900, when time zone rules in The Netherlands were indeed different from what they are today. Unlike today, when local time is UTC+1, in 1900 there was no difference between local time and UTC!

But our local system did not know this. When running the code on my Windows 11 laptop, it listed just one AdjustmentRule that it always applies, regardless of the DateTime that must be converted (pay special attention to the dateStart and dateEnd properties):

{
  "dateStart": "0001-01-01T00:00:00",
  "dateEnd": "9999-12-31T00:00:00",
  "daylightDelta": "01:00:00",
  "daylightTransitionStart": {
    "timeOfDay": "0001-01-01T02:00:00",
    "month": 3,
    "week": 5,
    "day": 1,
    "dayOfWeek": 0,
    "isFixedDateRule": false    },
  "daylightTransitionEnd": {
    "timeOfDay": "0001-01-01T03:00:00",
    "month": 10,
    "week": 5,
    "day": 1,
    "dayOfWeek": 0,
    "isFixedDateRule": false    },
  "baseUtcOffsetDelta": "00:00:00" 
}

However, the build agent in Azure DevOps, running ubuntu-latest, shows a very large list of Adjustment Rules, going all the way back to 1892.

As it turns out, the pipeline was right all along! When converting 1900-01-02 00:00 to UTC, the correct time is in fact 1900-01-02 00:00, the same as UTC.

So even though the TimeZoneInfo object that you retrieve from the platform looks the same from the outside, it can be very much platform-specific when it comes to its historical data.


I hope this story helps you to debug any problems you might encounter with DateTime conversion. Or avoid them altogether! And I’m sure I’m still not painting the full picture here. So please leave a comment if you have anything to add, or if you have your own struggles with DateTime handling.