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 DateTime with Kind = Unspecified as a bug. Either tag it explicitly with [System.DateTime]::SpecifyKind($d, 'Utc'), or skip DateTime entirely and use DateTimeOffset.

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 -UFormat only 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 InvariantCulture to anything that parses or formats. Otherwise the user's regional settings can change behavior.
  • AssumeUniversal when parsing strings without an explicit zone. Default behavior is "assume local" almost never what you want.
  • AdjustToUniversal to 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 AddDays on a DateTime with Kind = Local. "Tomorrow at the same time" can be 23 or 25 hours away. DateTimeOffset.AddDays is 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-Date for 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:

  1. Audit the most-recent log-shipping script you've written. Search for Get-Date without 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.
  2. 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.
  3. 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).