Recently, I’ve been tasked to provide data in a specific format from the backend to the frontend and I noticed a behavior that I found a bit odd.
- private static void OutputDateInfo(string value)
- {
- Console.WriteLine($"Input: {value}");
- if (DateTime.TryParse(value, out DateTime dateTimeValue))
- {
- Console.WriteLine($"Setialized to universal format
- {dateTimeValue.ToString("yyyy-MM-dd'T'HH:mm:ssZ")}");
- }
- Console.WriteLine();
- }
-
-
- OutputDateInfo("2021-04-15T21:01:00.0000000Z");
- OutputDateInfo("2021-04-15T21:01:00.0000000");
produces the output,
- Input: 2021-04-15T21:01:00.0000000Z
- Serialized to universal format 2021-04-16T00:01:00Z
-
- Input: 2021-04-15T21:01:00.0000000
- Serialized to universal format 2021-04-15T21:01:00Z
So as I was in Kyiv which is in UTC+3 timezone it looked as letter Z forced the ToString method to convert date to local time.
If s contains no time zone information, the result contains a DateTime value whose Kind property is DateTimeKind.Unspecified when the method returns. If the string to be parsed contains time zone information, the result contains a DateTime value whose Kind property is DateTimeKind.Local when the method returns.
Is this DateTimeKind that makes ToString cast date to the local timezone? Let’s log more properties to see if it is the case,
- private static void OutputDateInfo(string value)
- {
- Console.WriteLine($"Input: {value}");
- if (DateTime.TryParse(value, out DateTime dateTimeValue))
- {
- Console.WriteLine($"Setialized to universal format
- {dateTimeValue.ToString("yyyy-MM-dd'T'HHssZ")}");
- Console.WriteLine($"Setialized to default format {dateTimeValue}");
- Console.WriteLine($"Setialized with conversion to universal time
- {dateTimeValue.ToUniversalTime()}");
- Console.WriteLine($"Kind: {dateTimeValue.Kind}");
- }
- Console.WriteLine();
- }
Now we see in a console,
- Input: 2021-04-15T21:01:00.0000000Z
- Serialized to universal format 2021-04-16T00:01:00Z
- Serialized to default format 16.04.2021 0:01:00
- Serialized with conversion to universal time 15.04.2021 21:01:00
- Kind: Local
-
- Input: 2021-04-15T21:01:00.0000000
- Serialized to universal format 2021-04-15T21:01:00Z
- Serialized to default format 15.04.2021 21:01:00
- Serialized with conversion to universal time 15.04.2021 18:01:00
- Kind: Unspecified
So indeed when we call ToString on a DateTimeKind.Local instance, it will be adjusted according to server timezone.
But why letter Z is treated as timezone information? The documentation referred to above has no example with Z letter. The answer is that format in question is
ISO-8601 format and Z stands for “Zero UTC offset”. Is the string of this format treated just as any other string with timezone specified or is this some kind of special treatment? We’ll get an answer by comparing them,
- OutputDateInfo("2021-04-15T2100.0000000Z");
- OutputDateInfo("2021-04-15T2100.0000000");
- OutputDateInfo("2021-04-15T1400.0000000 -7:00");
yields,
- Input: 2021-04-15T21:01:00.0000000Z
- Serialized to universal format 2021-04-16T00:01:00Z
- Serialized to default format 16.04.2021 0:01:00
- Serialized with conversion to universal time 15.04.2021 21:01:00
- Kind: Local
-
- Input: 2021-04-15T21:01:00.0000000
- Serialized to universal format 2021-04-15T21:01:00Z
- Serialized to default format 15.04.2021 21:01:00
- Serialized with conversion to universal time 15.04.2021 18:01:00
- Kind: Unspecified
-
- Input: 2021-04-15T14:01:00.0000000 -7:00
- Serialized to universal format 2021-04-16T00:01:00Z
- Serialized to default format 16.04.2021 0:01:00
- Serialized with conversion to universal time 15.04.2021 21:01:00
- Kind: Local
As you can see, a string of ISO-8601 format is treated just like any other date string with a timezone specified.
My intent though wasn’t to treat it as local time but instead as UTC time. My goal was to provide universal time to the front end so it will adjust it to end-users local time. How can we trick .NET into thinking that this is universal time without any timezone? The answer is the overload that accepts DateTimeStyles:
- private static void OutputDateInfo(string value)
- {
- Console.WriteLine($"Input: {value}");
- if (DateTime.TryParse(value, null, DateTimeStyles.AdjustToUniversal,
- out DateTime universalTime))
- {
- Console.WriteLine($"Adjusted to universal {universalTime}");
- }
-
- Console.WriteLine();
- }
Outputs
- Input: 2021-04-15T21:01:00.0000000Z
- Adjusted to universal 15.04.2021 21:01:00
-
- Input: 2021-04-15T21:01:00.0000000
- Adjusted to universal 15.04.2021 21:01:00
-
- Input: 2021-04-15T14:01:00.0000000 -7:00
- Adjusted to universal 15.04.2021 21:01:00
Job done!
Conclusion
In my opinion, DateTimeKind is underrepresented in literature and blogs. However, this is the thing that should definitely be taken into account when parsing date strings and converting dates back to a string. Also, it is worth remembering that ISO-8601 format date strings are treated just like any other string with timezone info specified, although it might be unobvious at first glance.