This guide assumes Powershell 5.1 or 7+. The .NET date types behave the same on both.
Half of every "it worked yesterday" outage involves a date. The defaults in Powershell are friendly but lose information at every step kind unset, time zone unspecified, format dependent on the user's culture. This post is the small set of habits that keep dates correct.
DateTime Is Three Things in One
$now = Get-Date
$now.Kind # Local
DateTime.Kind can be Local, Utc, or Unspecified. Two DateTime values that look identical can be hours apart depending on Kind. Worse, Unspecified is the default when you parse from a string:
([datetime]'2025-04-01T12:00:00').Kind # Unspecified
When Kind is Unspecified, .ToUniversalTime() and .ToLocalTime() assume the value is local silently producing garbage if you parsed a UTC string.
Treat
DateTimewithKind = Unspecifiedas a bug. Either tag it explicitly with[System.DateTime]::SpecifyKind($d, 'Utc'), or skipDateTimeentirely and useDateTimeOffset.
DateTimeOffset Is the Right Default
DateTimeOffset carries the offset from UTC as part of the value. You can't lose track of "what zone is this":
$now = [DateTimeOffset]::Now
$now.Offset # local UTC offset, e.g. -05:00:00
$now.UtcDateTime # the same moment as UTC
Use it for anything that crosses a process boundary log lines, API payloads, database columns. Reserve DateTime for purely local "wall clock" calculations where the zone genuinely doesn't matter.
# Round-trip-safe
$now = [DateTimeOffset]::UtcNow
$str = $now.ToString('o') # ISO 8601 with offset
$back = [DateTimeOffset]::Parse($str) # exact same instant
Get-Date -Format vs -UFormat
Two completely different format languages on the same cmdlet:
Get-Date -Format 'yyyy-MM-dd HH:mm:ss' # .NET format strings
Get-Date -UFormat '%Y-%m-%d %H:%M:%S' # POSIX strftime style
-Format is the modern one and supports everything .NET can do. -UFormat exists for shell-script portability and has surprising gaps (%N for nanoseconds doesn't work on all hosts, %V for ISO week varies by version).
Default to
-Format. Use-UFormatonly when you're porting a shell script verbatim and need the exact same tokens.
ISO 8601 Is the Only Safe Wire Format
Every API, log line, and database column should use ISO 8601 with offset:
[DateTimeOffset]::UtcNow.ToString('o') # 2025-06-23T14:30:00.0000000+00:00
[DateTimeOffset]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ') # 2025-06-23T14:30:00Z (no fractions)
Avoid Get-Date without an explicit format anywhere a string crosses a system boundary. The default is locale-dependent (6/23/2025 2:30:00 PM in en-US, 23/06/2025 14:30:00 elsewhere) and will eventually parse wrong somewhere.
Parsing Be Explicit
# Locale-dependent - use only for trusted local input
[datetime]::Parse('06/23/2025')
# Explicit format - never ambiguous
[datetime]::ParseExact('2025-06-23', 'yyyy-MM-dd', $null)
# Lenient - try a list of allowed formats
[datetime]::ParseExact(
'2025-06-23T14:30:00Z',
@('yyyy-MM-ddTHH:mm:ssZ','yyyy-MM-ddTHH:mm:sszzz'),
[Globalization.CultureInfo]::InvariantCulture,
[Globalization.DateTimeStyles]::AssumeUniversal -bor [Globalization.DateTimeStyles]::AdjustToUniversal)
Three habits worth memorizing:
- Pass
InvariantCultureto anything that parses or formats. Otherwise the user's regional settings can change behavior. AssumeUniversalwhen parsing strings without an explicit zone. Default behavior is "assume local" almost never what you want.AdjustToUniversalto normalize to UTC at the boundary.
Time Zones Use TimeZoneInfo
To convert between zones explicitly:
$utc = [DateTimeOffset]::UtcNow
$tz = [System.TimeZoneInfo]::FindSystemTimeZoneById('Europe/Madrid') # IANA
$local = [System.TimeZoneInfo]::ConvertTime($utc, $tz)
Powershell 7+ on Windows accepts both Windows zones (
'W. Europe Standard Time') and IANA zones ('Europe/Madrid') thanks to ICU. On 5.1 you're stuck with Windows zones keep a translation map handy if you're going cross-platform.
Arithmetic TimeSpan and the Add* Methods
$start = [DateTimeOffset]::UtcNow
# ... do work ...
$elapsed = ([DateTimeOffset]::UtcNow - $start) # TimeSpan
$elapsed.TotalSeconds
$tomorrow = (Get-Date).AddDays(1)
$nextHour = (Get-Date).AddHours(1)
$lastMonth = (Get-Date).AddMonths(-1)
AddMonths / AddYears are calendar-aware (Feb 29 + 1 year = Feb 28). AddDays(30) is not "one month" it's exactly 30 × 24 hours.
Watch out for DST when you
AddDayson aDateTimewithKind = Local. "Tomorrow at the same time" can be 23 or 25 hours away.DateTimeOffset.AddDaysis unambiguous because the offset is fixed.
Comparing Don't Mix Kinds
$a = (Get-Date).ToUniversalTime() # Kind = Utc
$b = Get-Date # Kind = Local
$a -lt $b # works but the comparison treats both as the same instant - confusingly
Powershell will compare them, but the result is rarely what a beginner expects. Always normalize before comparing:
$a.ToUniversalTime() -lt $b.ToUniversalTime()
Or just use DateTimeOffset everywhere and never have the problem.
A Few Useful Idioms
Yesterday at midnight UTC:
[DateTimeOffset]::UtcNow.Date.AddDays(-1)
Start of the current hour:
$now = [DateTimeOffset]::UtcNow
$now.AddTicks(-($now.Ticks % [TimeSpan]::TicksPerHour))
Unix epoch seconds:
[DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
[DateTimeOffset]::FromUnixTimeSeconds(1719144600)
ISO week number:
[System.Globalization.ISOWeek]::GetWeekOfYear((Get-Date))
(ISOWeek is .NET 5+; on 5.1 you need a tiny custom function.)
What to Stop Doing
- Stop using
Get-Datefor log timestamps without an explicit format. The default representation is culture-dependent. - Stop concatenating dates as strings when arithmetic would do.
"$($d.AddDays(1))"is fine;"$d + 1"is a bug. - Stop trusting
[datetime]'2025-04-01'to mean midnight UTC. It's midnight unspecified. - Stop using
[int][double]::Parse((Get-Date -UFormat %s))to get epoch seconds.[DateTimeOffset]::UtcNow.ToUnixTimeSeconds()is the modern answer.
What to Do Next
Default to DateTimeOffset everywhere a value crosses a boundary. Format and parse with InvariantCulture and explicit ISO 8601 strings. Convert zones with TimeZoneInfo.ConvertTime. Watch for Kind = Unspecified on parsed values.
Three quick wins to lock the habits in:
- Audit the most-recent log-shipping script you've written. Search for
Get-Datewithout an explicit format. Replace with(Get-Date).ToUniversalTime().ToString('o')or[DateTimeOffset]::UtcNow.ToString('o'). Future-you debugging at 3 AM will not have to mentally convert timezones. - Find the next
[datetime]'2025-04-01'literal in your codebase. It's almost certainly wrong; either it should be[datetime]::SpecifyKind('2025-04-01', 'Utc')(you really meant midnight UTC) or[DateTimeOffset]::Parse('2025-04-01T00:00:00-05:00')(you meant a specific offset). Pick deliberately. - Replace any epoch-seconds math (
[int][double]::Parse((Get-Date -UFormat %s))) with[DateTimeOffset]::UtcNow.ToUnixTimeSeconds(). The modern call is shorter, faster, and immune to the locale issues of-UFormat.
Pairs naturally with the error-handling post (parse errors on dates are one of the easiest exception types to catch specifically) and the logging post (a log timestamp without timezone is half a log entry).


