Time conversions in PowerShell (and .NET in general)
I've been dealing with the timestamps on the files, and I needed to convert between 3 formats:
- the PowerShell/.NET DateTime object;
- the FILETIME as it's used in the timestamps on the files;
- the strings in an arbitrary format (specifically, the format I'm interested in is "yyyyMMddHHmmssfffffff", since it preserves the full precision of the FILETIME but in a more human-readable form).
The orthogonal problem is the choice between the local time and UTC. I really like all the numeric and machine-parseable timestamps to be in UTC, so that there is no doubt to when did things happen.
Figuring out all the appropriate conversions took me a while, so I want to write down my findings for posterity.
First of all, here is how the DateTime object records the difference between the local time and UTC: it has the property Kind that may be either Local, Utc or Unspecified (the enumeration DateTimeKind). This property is marked when the object gets created and then stays unchanged. Most of the constructors mark the Kind as Local, and marking the time as UTC requires a bit of extra effort, such as using a constructor with "Utc" in its name. Possibly, parsing the time with a custom timezone, would set the kind to Unspecified. I haven't seen the Unspecified used much, and it seems to act very much like Local.
The Kind determines, which timezone will be used for the DateTime object's internal representation. And this representation is used when the object gets converted to a string.
Let's create two DateTime objects by reading the modification time of the current directory and print them, to show this point:
PS C:\Users\sbabkin> $dt = (Get-Item ".").LastWriteTime
PS C:\Users\sbabkin> $dt.Kind
Local
PS C:\Users\sbabkin> $dt
Friday, February 20, 2015 1:21:32 PM
PS C:\Users\sbabkin> $dtu = (Get-Item ".").LastWriteTimeUtc
PS C:\Users\sbabkin> $dtu.Kind
Utc
PS C:\Users\sbabkin> $dtu
Friday, February 20, 2015 9:21:32 PM
I'm in the Pacific time zone, so naturally the printed time differs by 8 hours in the local zone and in UTC.
The same happens when the values get converted to a custom-formatted string, they also differ by 8 hours:
PS C:\Users\sbabkin> $dt.ToString("yyyyMMddHHmmssfffffff")
201502201321326752346
PS C:\Users\sbabkin> $dtu.ToString("yyyyMMddHHmmssfffffff")
201502202121326752346
To format a DateTime as a string in UTC or local time, the DateTime object must be appropriately marked with the Kind of Utc or Local.
Interestingly, the time zone is not taken into account when comparing the DateTime objects, it just compares the time components (year, month, and so on down to the fractions of the seconds):
PS C:\Users\sbabkin> $dt.CompareTo($dtu)
-1
Even though these values represent the same time, the comparison says that $dt is less.
But how do we know that they represent the same time? By converting them to an absolute format, the FILETIME in UTC. They produce the same value:
PS C:\Users\sbabkin> $dt.ToFileTimeUtc()
130689408926752346
PS C:\Users\sbabkin> $dtu.ToFileTimeUtc()
130689408926752346
Even more interestingly, we get the same values if we use ToFileTime() instead of ToFileTimeUtc():
PS C:\Users\sbabkin> $dt.ToFileTime()
130689408926752346
PS C:\Users\sbabkin> $dtu.ToFileTime()
130689408926752346
Basically, it means that the FILETIME is always represented in UTC, and the "Utc" at the end of the name ToFileTimeUtc() is just a syntactic sugar. Which is not really a surprise, the NTFS filesystem always stores the timestamps in UTC.
And by the way, all this means that the right way to compare two DateTime values that might be in the different time zones is by converting them to the FILETIME format and then comparing them:
PS C:\Users\sbabkin> $dt.ToFileTime() -eq $dtu.ToFileTime()
True
We can convert back from the FILETIME to the DateTime:
PS C:\Users\sbabkin> $dt2 = [DateTime]::FromFileTime(130689408926752346)
PS C:\Users\sbabkin> $dt2
Friday, February 20, 2015 1:21:32 PM
PS C:\Users\sbabkin> $dt2.Kind
Local
PS C:\Users\sbabkin> $dt.CompareTo($dt2)
0
PS C:\Users\sbabkin> $dtu2 = [DateTime]::FromFileTimeUtc(130689408926752346)
PS C:\Users\sbabkin> $dtu2
Friday, February 20, 2015 9:21:32 PM
PS C:\Users\sbabkin> $dtu2.Kind
Utc
PS C:\Users\sbabkin> $dtu.CompareTo($dtu2)
0
The resulting values are exactly the same as before. Again, the FILETIME value was accepted as being in UTC, and the difference between FromFileTime() and FromFileTimeUtc() is that FromFileTime() converts that value to the local time zone during parsing and marks is as the Local kind while FromFileTimeUtc() leaves it as is and marks it as the Utc kind.
To change the time zone between local and Utc but keep the time the same, the time needs to be converted through the FILETIME:
PS C:\Users\sbabkin> $dtu2 = [DateTime]::FromFileTimeUtc($dt.ToFileTime())
PS C:\Users\sbabkin> $dtu2
Friday, February 20, 2015 9:21:32 PM
PS C:\Users\sbabkin> $dtu2.Kind
Utc
PS C:\Users\sbabkin> $dt2 = [DateTime]::FromFileTime($dtu.ToFileTime())
PS C:\Users\sbabkin> $dt2
Friday, February 20, 2015 1:21:32 PM
PS C:\Users\sbabkin> $dt2.Kind
Local
There is also the method SpecifyKind() but it's unsuitable for converting between the time zones, it does something different:
PS C:\Users\sbabkin> $dt
Friday, February 20, 2015 1:21:32 PM
PS C:\Users\sbabkin> $dt.Kind
Local
PS C:\Users\sbabkin> $dt.ToFileTime()
130689408926752346
PS C:\Users\sbabkin> $dt2 = [DateTime]::SpecifyKind($dt, "Utc")
PS C:\Users\sbabkin> $dt2
Friday, February 20, 2015 1:21:32 PM
PS C:\Users\sbabkin> $dt2.Kind
Utc
PS C:\Users\sbabkin> $dt2.ToFileTime()
130689120926752346
It produces a new DateTime object with the same time components (year, month, and so on down to the fractions of a second) but a different time zone. The actual time represented by it changes, moving by the difference between the time zones. This method is useful if you've got the DateTime object marked incorrectly when parsing it, and want to change the time zone mark.
The next trick is parsing the DateTime from an arbitrarily formatted string. Lets start by preparing a couple of strings, the same as were shown before with ToString():
PS C:\Users\sbabkin> $st = $dt.ToString("yyyyMMddHHmmssfffffff")
PS C:\Users\sbabkin> $st
201502201321326752346
PS C:\Users\sbabkin> $stu = $dtu.ToString("yyyyMMddHHmmssfffffff")
PS C:\Users\sbabkin> $stu
201502202121326752346
The magic method to parse them is ParseExact():
PS C:\Users\sbabkin> $xt = [DateTime]::ParseExact($st, "yyyyMMddHHmmssfffffff", $null, "AssumeLocal")
PS C:\Users\sbabkin> $xt.Kind
Local
PS C:\Users\sbabkin> $xt
Friday, February 20, 2015 1:21:32 PM
PS C:\Users\sbabkin> $xtu = [DateTime]::ParseExact($stu, "yyyyMMddHHmmssfffffff", $null, "AssumeUniversal, AdjustToUniversal")
PS C:\Users\sbabkin> $xtu.kind
Utc
PS C:\Users\sbabkin> $xtu
Friday, February 20, 2015 9:21:32 PM
The $null is the IFormatProvider, just using the default one, and the "Assume" and "Adjust" strings are really the bitmasks from the enumeration System.Globalization.DateTimeStyles, PowerShell nicely converts them from the string to the numeric values. "AssumeLocal" and "AssumeUniversal" tell the time zone of the time in the string being parsed. If neither is used, the resulting object comes out with the kind of Unspecified, but appears to behave just like Local for all the practical purposes. The "AdjustToUniversal" tells that the resulting DateTime object must be translated to Utc, otherwise it will be Local (or Unspecified). If "Assume" and "AdjustTo" point the same way, the string gets simply parsed and marked with the requested kind. If "Assume" and "AdjustTo" point the opposite ways, the time also gets converted. Here is what happens if they point the opposite ways:
PS C:\Users\sbabkin> $xxt = [DateTime]::ParseExact($st, "yyyyMMddHHmmssfffffff", $null, "AssumeLocal, AdjustToUniversal")
PS C:\Users\sbabkin> $xxt.Kind
Utc
PS C:\Users\sbabkin> $xxt
Friday, February 20, 2015 9:21:32 PM
PS C:\Users\sbabkin> $xxtu = [DateTime]::ParseExact($stu, "yyyyMMddHHmmssfffffff", $null, "AssumeUniversal")
PS C:\Users\sbabkin> $xxtu.Kind
Local
PS C:\Users\sbabkin> $xxtu
Friday, February 20, 2015 1:21:32 PM
The values are still correct but now a different one is in UTC.
The last thing I want to mention is that the .NET DateTime is craftier than the native structure SYSTEMTIME. It can keep the timestamp with the same precision as FILETIME, to the hundreds of nanoseconds, and not just the milliseconds of SYSTEMTIME.