Windows counts time from 1601, Unix counts it from 1970, and .NET counts its own ticks from the year 1. A file time, the value behind every NTFS timestamp and every date attribute in Active Directory, is a 64-bit count of 100-nanosecond intervals since 1 January 1601 UTC. A Unix timestamp is a count of seconds since 1 January 1970 UTC. The two numbers describe the same instants and differ by 11,644,473,600 seconds, which is why a conversion done with the wrong origin lands 369 years away from the truth.
That part is easy to spot. The expensive mistakes are quieter: a value that is not a time at all and converts to a date in 1601 anyway, a DateTime that carries no time zone and is read as local by one method and as UTC by the next, and a stale account report that counts an account as 425 years idle because nobody asked what zero meant in that field.
Every number on this page was produced by a script you can run, and every documented claim is quoted from the page that makes it. By the end you will have a small folder of scripts that convert any Windows timestamp in either direction, decide whether a value is a time at all, and report on an exported set of Active Directory attributes without inventing idle accounts. If you only need a quick one-off conversion, the Unix timestamp converter on this site does it in the browser.
Applies to: Windows 10 and 11, Windows Server 2016 through 2025, PowerShell 7 and Windows PowerShell 5.1
What you are about to build, and why
The lab is one folder, C:\timelab, holding thirteen scripts and one sample export that one of the scripts writes. Nothing is installed, no module is downloaded, no registry key is written and no directory object is touched. Every script is self-contained and reads only files that sit next to it.
Each script answers one question and prints its answer as a plain aligned table, so you can compare your screen against this page line by line. The last script lists the files and can delete them.
| File | What it answers |
|---|---|
Show-TimeOrigins.ps1 | Where each of the three clocks starts, and the one constant between them |
Show-FileTimeAsymmetry.ps1 | Why the same value produces two different file times |
Show-KindSubtraction.ps1 | What subtracting two timestamps costs when one carries no zone |
Test-FileTimeSentinels.ps1 | Which raw values convert, which throw, and which are not times |
Convert-ADTimestamp.ps1 | A function that reports the meaning of an AD time value |
New-TimestampFixture.ps1 | Writes the sample export used by the report |
Get-TimestampReport.ps1 | The same report counted naively and counted carefully |
Show-SystemFlags.ps1 | The one bit that separates lastLogon from lastLogonTimestamp |
Show-UnixSeconds.ps1 | Both documented meanings of Get-Date -UFormat %s |
Show-CultureParsing.ps1 | What a culture does to a timestamp that arrives as text |
Show-DstConversion.ps1 | Why a fixed offset is wrong for part of the year |
Convert-WindowsTimestamp.ps1 | The wrapper to keep: one value in, every spelling out |
Remove-TimeLab.ps1 | Lists everything the lab created and removes it |
Before you start
Five steps, and the first two are the ones people skip.
- 1. Decide which shell you are in.
pwsh.exeis PowerShell 7 andpowershell.exeis Windows PowerShell 5.1. They disagree about one of the values on this page, so run the version check below and remember the answer. - 2. Create the working folder. Everything lives in
C:\timelaband nothing is written outside it. - 3. Allow the scripts to run in this session only. The execution policy line below is scoped to
Process, so it lapses when you close the window. - 4. Use the same folder for every step. The scripts resolve their own location with
$PSScriptRoot, so they work from any current directory, but the sample export is written beside them. - 5. Mind the path syntax. Windows paths in these scripts are written with single backslashes inside single-quoted strings, which PowerShell does not treat as escapes.
Run these three lines first. The first prints your version, the second creates the folder, the third relaxes the policy for this window only.
# $PSVersionTable.PSVersion is the authoritative answer, not the window title.
$PSVersionTable.PSVersion.ToString()
# -Force makes this safe to run twice: an existing folder is not an error.
New-Item -Path 'C:\timelab' -ItemType Directory -Force | Out-Null
Set-Location -Path 'C:\timelab'
# Process scope only, so nothing about the machine is changed permanently.
Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process -Force
You should see one version string, then two lines of silence. A version beginning with 7 is PowerShell 7 and one beginning with 5.1 is Windows PowerShell 5.1. If it begins with 5.1, keep reading: one section on this page is specifically about what your shell does differently.
New-TimestampFixture.ps1 writes for you. Nothing on this page queries a domain controller or needs the RSAT module, so the whole lab runs on a workstation that is not joined to a domain.
Quick answer
Three conversions cover most of the work. Paste this block into the shell rather than saving it: it defines nothing and changes nothing.
# A file time from NTFS or from an Active Directory attribute, to a UTC DateTime.
[datetime]::FromFileTimeUtc(134176302000000000)
# A Unix timestamp in seconds, to a UTC DateTime. The Offset of the result is zero.
[datetimeoffset]::FromUnixTimeSeconds(1773156600).UtcDateTime
# A DateTime back out again, in both spellings. Set the kind first or the
# answer depends on the machine you happen to be standing at.
$utc = [datetime]::SpecifyKind([datetime]'2026-03-10T15:30:00', 'Utc')
$utc.ToFileTimeUtc()
[datetimeoffset]::new($utc).ToUnixTimeSeconds()
Four values come back. The first two are the same instant in March 2026 printed twice, in whatever long date format your machine uses, and the last two are 134176302000000000 and 1773156600, which are the two numbers the first two lines started from. If you are converting one value by hand and do not want a shell at all, the Unix timestamp converter takes either direction in the browser.
Three origins and the one constant between them
A number on its own is not a time. Before converting anything, you need to know which origin it was counted from and in what unit. Windows uses three.
| Origin | Unit | Where you meet it |
|---|---|---|
| 1 January 0001 UTC | 100-nanosecond ticks | DateTime.Ticks, and [datetime]::new($ticks) |
| 1 January 1601 UTC | 100-nanosecond ticks | NTFS timestamps, AD date attributes, FILETIME |
| 1 January 1970 UTC | seconds or milliseconds | Unix tools, JSON payloads, JWT claims, log shippers |
Save this as Show-TimeOrigins.ps1 in C:\timelab and run it with .\Show-TimeOrigins.ps1.
# Show-TimeOrigins.ps1
# Prints the three origins a Windows admin meets, and derives the constant
# that converts between the 1601 and 1970 origins instead of hardcoding it.
$netOrigin = [datetime]::MinValue # .NET tick origin
$fileOrigin = [datetime]::FromFileTimeUtc(0) # Windows file time origin
$unixOrigin = [datetimeoffset]::FromUnixTimeSeconds(0).UtcDateTime # Unix epoch
'{0,-26}{1}' -f 'Origin', 'UTC instant'
'{0,-26}{1}' -f '------', '-----------'
'{0,-26}{1}' -f '.NET DateTime ticks', $netOrigin.ToString('yyyy-MM-dd HH:mm:ss')
'{0,-26}{1}' -f 'Windows file time', $fileOrigin.ToString('yyyy-MM-dd HH:mm:ss')
'{0,-26}{1}' -f 'Unix epoch', $unixOrigin.ToString('yyyy-MM-dd HH:mm:ss')
''
# The gap between 1601 and 1970 is the only constant you ever need.
$gap = $unixOrigin - $fileOrigin
'{0,-26}{1}' -f 'Gap in days', $gap.TotalDays
'{0,-26}{1}' -f 'Gap in seconds', [int64]$gap.TotalSeconds
'{0,-26}{1}' -f 'Gap in 100ns ticks', $unixOrigin.ToFileTimeUtc()
''
# Both directions, derived from the gap rather than typed in.
$ticksPerSecond = 10000000
$sampleUnix = 1577836800
$asFileTime = ($sampleUnix + [int64]$gap.TotalSeconds) * $ticksPerSecond
'{0,-26}{1}' -f 'Unix seconds in', $sampleUnix
'{0,-26}{1}' -f 'Same instant as filetime', $asFileTime
'{0,-26}{1}' -f 'Round trip back to Unix', [datetimeoffset]::new([datetime]::FromFileTimeUtc($asFileTime)).ToUnixTimeSeconds()
'{0,-26}{1}' -f 'Ticks per second', $ticksPerSecond
You should see three origin rows, then the gap between 1601 and 1970 printed three ways, then one value converted in both directions. The last line is the figure that makes the arithmetic work: there are ten million ticks in a second.
Origin UTC instant
------ -----------
.NET DateTime ticks 0001-01-01 00:00:00
Windows file time 1601-01-01 00:00:00
Unix epoch 1970-01-01 00:00:00
Gap in days 134774
Gap in seconds 11644473600
Gap in 100ns ticks 116444736000000000
Unix seconds in 1577836800
Same instant as filetime 132223104000000000
Round trip back to Unix 1577836800
Ticks per second 10000000
11644473600 seconds, or 116444736000000000 ticks. That second number is the Unix epoch expressed as a file time, which is the cleanest way to remember it: if you ever see 116444736000000000 in a report, something converted zero.
What the file system actually stores
Before converting a file timestamp it is worth knowing what the file system wrote. Microsoft states the difference plainly: NTFS and FAT do not store the same thing.
| Documented statement | What it means when you convert |
|---|---|
| “The NTFS file system stores time values in UTC format, so they are not affected by changes in time zone or daylight saving time.” | A file time read from NTFS is already UTC. Convert it with FromFileTimeUtc and nothing else is needed. |
| “The FAT file system stores time values based on the local time of the computer.” | A timestamp from a USB stick or SD card was written in the local time of whichever machine wrote it, which is usually not yours. |
| “The NTFS file system delays updates to the last access time for a file by up to 1 hour after the last access.” | Last access time is not evidence of the minute a file was opened, and a one-hour discrepancy is documented behaviour rather than a fault. |
PowerShell gives you both readings of a file timestamp without any conversion work. The UTC properties are the ones to put in a report.
# Both readings of the same instant. The Utc properties are the stored value on
# NTFS; the others are that value rendered in the local zone of this machine.
Get-Item -Path 'C:\Windows\explorer.exe' |
Select-Object -Property FullName, LastWriteTimeUtc, LastWriteTime, CreationTimeUtc
# The same value as the number the file system holds.
(Get-Item -Path 'C:\Windows\explorer.exe').LastWriteTimeUtc.ToFileTimeUtc()
The first command prints four properties for one file and the second prints a single 18-digit number. The exact values are different on every machine, which is the point: the number is the stored value and the two date columns are two renderings of it.
One value, two file times: the word that changes
Microsoft documents ToFileTime and ToFileTimeUtc in what is almost the same sentence. Read them next to each other and the whole problem is one word.
| Method | Documented sentence |
|---|---|
ToFileTime() | “The ToFileTime method uses the Kind property to determine whether the current DateTime object is a local time, a UTC time, or an unspecified kind of time, which is treated as a local time.” |
ToFileTimeUtc() | “The ToFileTimeUtc method uses the Kind property to determine whether the current DateTime object is a local time, a UTC time, or an unspecified kind of time, which is treated as a UTC time.” |
A DateTime parsed from text, read from a CSV column or typed into a script is almost always Unspecified. Both methods accept it, neither warns, and the two numbers they return are not the same instant.
Save this as Show-FileTimeAsymmetry.ps1 in C:\timelab and run it with .\Show-FileTimeAsymmetry.ps1.
# Show-FileTimeAsymmetry.ps1
# ToFileTime() and ToFileTimeUtc() are documented in almost the same sentence.
# One word differs: an Unspecified kind is treated as a local time by the first
# and as a UTC time by the second. This script models both rules against a named
# time zone, so the numbers below are the same on every machine.
$zoneId = 'Pacific Standard Time'
$tz = [TimeZoneInfo]::FindSystemTimeZoneById($zoneId)
$wall = [datetime]'2026-03-10T08:30:00' # one wall clock reading, no zone attached
# Rule 1: the value is already UTC. This is what ToFileTimeUtc() does with a
# Utc kind, and what it also does with an Unspecified kind.
$asUtc = [datetime]::SpecifyKind($wall, 'Utc')
# Rule 2: the value is local to the machine. This is what ToFileTime() does with
# an Unspecified kind. Converting through a named zone gives the same arithmetic
# without depending on how the machine running the script is configured.
$asZone = [TimeZoneInfo]::ConvertTimeToUtc([datetime]::SpecifyKind($wall, 'Unspecified'), $tz)
'{0,-30}{1}' -f 'Wall clock reading', $wall.ToString('yyyy-MM-dd HH:mm:ss')
'{0,-30}{1}' -f 'Assumed zone for rule 2', $zoneId
'{0,-30}{1}' -f 'Offset in force that day', $tz.GetUtcOffset($asUtc)
'{0,-30}{1}' -f 'Daylight saving in force', $tz.IsDaylightSavingTime($asUtc)
''
'{0,-22}{1,-22}{2}' -f 'Read the value as', 'UTC instant', 'File time'
'{0,-22}{1,-22}{2}' -f '-----------------', '-----------', '---------'
'{0,-22}{1,-22}{2}' -f 'already UTC', $asUtc.ToString('HH:mm:ss'), $asUtc.ToFileTimeUtc()
'{0,-22}{1,-22}{2}' -f 'local to that zone', $asZone.ToString('HH:mm:ss'), $asZone.ToFileTimeUtc()
''
$deltaTicks = $asZone.ToFileTimeUtc() - $asUtc.ToFileTimeUtc()
'{0,-30}{1}' -f 'Difference in ticks', $deltaTicks
'{0,-30}{1}' -f 'Difference in hours', ([TimeSpan]::FromTicks($deltaTicks)).TotalHours
'{0,-30}{1}' -f 'Difference equals the offset', ([TimeSpan]::FromTicks($deltaTicks) -eq -$tz.GetUtcOffset($asUtc))
''
# Both numbers are valid file times. Converted back, each one claims a different
# instant for the same eight characters of wall clock.
'{0,-30}{1}' -f 'Rule 1 back to UTC text', [datetime]::FromFileTimeUtc($asUtc.ToFileTimeUtc()).ToString('yyyy-MM-dd HH:mm:ss')
'{0,-30}{1}' -f 'Rule 2 back to UTC text', [datetime]::FromFileTimeUtc($asZone.ToFileTimeUtc()).ToString('yyyy-MM-dd HH:mm:ss')
The script models both documented rules against a named zone rather than against the machine it runs on, so the numbers below are the same everywhere. You should see the zone header, then a two-row table where the same wall clock reading becomes two file times, then the difference expressed three ways, then each number converted back.
Wall clock reading 2026-03-10 08:30:00
Assumed zone for rule 2 Pacific Standard Time
Offset in force that day -07:00:00
Daylight saving in force True
Read the value as UTC instant File time
----------------- ----------- ---------
already UTC 08:30:00 134176050000000000
local to that zone 15:30:00 134176302000000000
Difference in ticks 252000000000
Difference in hours 7
Difference equals the offset True
Rule 1 back to UTC text 2026-03-10 08:30:00
Rule 2 back to UTC text 2026-03-10 15:30:00
-07:00:00 and not -08:00:00, because 10 March 2026 is inside the daylight saving period for that zone. The zone identifier contains the word Standard all year round. There is a section on that further down.
To see what your own machine would have done, ask it for its offset at that instant with [TimeZoneInfo]::Local.GetUtcOffset([datetime]'2026-03-10T08:30:00'). If it prints 00:00:00 your machine is on UTC and the two methods agree on it.
The Unspecified kind, and four methods that each guess differently
The asymmetry is not limited to the two file time methods. Four conversion methods in the same class each decide for themselves what an Unspecified value means, and each one is documented separately.
| Method | What it does with an Unspecified value | Result |
|---|---|---|
ToFileTime() | treats it as a local time | file time shifted by the machine offset |
ToFileTimeUtc() | treats it as a UTC time | file time taken at face value |
ToUniversalTime() | assumes it is a local time and converts as if Kind were Local | value moves by the machine offset |
ToLocalTime() | assumes it is a UTC time and converts as if Kind were Utc | value moves by the machine offset, the other way |
Two of those four assume local and two assume UTC, so chaining them can cancel the shift instead of applying it: on a machine away from UTC, an Unspecified value put through ToLocalTime() and then ToFileTimeUtc() produces the same number as ToFileTimeUtc() on its own, while the same value through ToUniversalTime() first does not. The fix is not to remember the table. The fix is to set the kind where the value enters your script, and to use DateTimeOffset wherever it crosses a machine boundary.
# Three ways to stop guessing. Each one produces a value that carries its
# own meaning, so nothing downstream has to assume anything.
# 1. Say what the text meant when you parsed it.
[datetime]::SpecifyKind([datetime]'2026-03-10T15:30:00', 'Utc')
# 2. Or parse it with a rule attached, which is better because it also
# rejects a string that does not match the pattern you were promised.
[datetime]::ParseExact('2026-03-10 15:30:00', 'yyyy-MM-dd HH:mm:ss',
[System.Globalization.CultureInfo]::InvariantCulture,
[System.Globalization.DateTimeStyles]::AssumeUniversal -bor
[System.Globalization.DateTimeStyles]::AdjustToUniversal)
# 3. Or stop using DateTime for the value entirely. A DateTimeOffset cannot
# be ambiguous: the offset travels with it.
[datetimeoffset]::new(2026, 3, 10, 15, 30, 0, [TimeSpan]::Zero)
The first two print a date and time, and .Kind on either of them returns Utc. The third prints a property list, because that is how PowerShell renders a DateTimeOffset: read the Offset row, which is 00:00:00 here. Appending .ToString('o') to any of the three gives one unambiguous line instead, ending in Z for the first two and +00:00 for the third.
Subtracting two timestamps ignores the kind
This is the one that reverses the order of events during an incident. Microsoft states it directly in the remarks for DateTime.Subtract: “The Subtract(DateTime) method does not consider the value of the Kind property of the two DateTime values when performing the subtraction. Before subtracting DateTime objects, ensure that the objects represent times in the same time zone.” A note on the same page adds that “The DateTimeOffset.Subtract(DateTimeOffset) method does consider the difference between time zones when performing the subtraction.”
This script needs nothing from the earlier steps. It takes one timestamp from a log that writes UTC and one typed in from a ticket in local wall clock time, and subtracts them twice.
Save this as Show-KindSubtraction.ps1 in C:\timelab and run it with .\Show-KindSubtraction.ps1.
# Show-KindSubtraction.ps1
# Two timestamps for the same incident: one from a log that writes UTC, one typed
# in from a ticket in Pacific wall clock time. Subtracting the DateTime values
# ignores the kind, so the answer is wrong by the offset. DateTimeOffset does not.
$tz = [TimeZoneInfo]::FindSystemTimeZoneById('Pacific Standard Time')
# 15:30:00 UTC, the way an event log exports it.
$fromLog = [datetime]::SpecifyKind([datetime]'2026-03-10T15:30:00', 'Utc')
# 08:45:00 local, the way the person who opened the ticket wrote it down.
$ticketWall = [datetime]::SpecifyKind([datetime]'2026-03-10T08:45:00', 'Unspecified')
$ticketUtc = [TimeZoneInfo]::ConvertTimeToUtc($ticketWall, $tz)
'{0,-34}{1}' -f 'Log entry (UTC)', $fromLog.ToString('yyyy-MM-dd HH:mm:ss')
'{0,-34}{1}' -f 'Ticket wall clock (Pacific)', $ticketWall.ToString('yyyy-MM-dd HH:mm:ss')
'{0,-34}{1}' -f 'Ticket as a UTC instant', $ticketUtc.ToString('yyyy-MM-dd HH:mm:ss')
''
# Subtracting DateTime values: the kinds are not consulted at all.
$naive = $fromLog - $ticketWall
'{0,-34}{1}' -f 'DateTime subtraction', $naive
'{0,-34}{1}' -f 'DateTime subtraction, minutes', $naive.TotalMinutes
''
# The same two instants as DateTimeOffset values, which carry their offset.
$logOffset = [datetimeoffset]::new($fromLog)
$ticketOffset = [datetimeoffset]::new($ticketWall, $tz.GetUtcOffset($ticketUtc))
$correct = $logOffset - $ticketOffset
'{0,-34}{1}' -f 'Ticket offset used', $ticketOffset.Offset
'{0,-34}{1}' -f 'DateTimeOffset subtraction', $correct
'{0,-34}{1}' -f 'DateTimeOffset, minutes', $correct.TotalMinutes
''
'{0,-34}{1}' -f 'Error introduced', ($naive - $correct)
'{0,-34}{1}' -f 'Error equals the offset', (($naive - $correct) -eq $tz.GetUtcOffset($ticketUtc).Negate())
'{0,-34}{1}' -f 'Both kinds were set, not missing', ($fromLog.Kind.ToString() + ' and ' + $ticketWall.Kind.ToString())
Read the two subtraction results against each other. One says the log entry came hours after the ticket was raised; the other says it came a quarter of an hour before. The sign is the part that matters: the naive answer puts the events in the wrong order.
Log entry (UTC) 2026-03-10 15:30:00
Ticket wall clock (Pacific) 2026-03-10 08:45:00
Ticket as a UTC instant 2026-03-10 15:45:00
DateTime subtraction 06:45:00
DateTime subtraction, minutes 405
Ticket offset used -07:00:00
DateTimeOffset subtraction -00:15:00
DateTimeOffset, minutes -15
Error introduced 07:00:00
Error equals the offset True
Both kinds were set, not missing Utc and Unspecified
Kind of whatever a cmdlet hands you before putting it on either side of a subtraction.
Zero is not midnight
Active Directory stores its date attributes as 64-bit file times, and several values in those fields are not times. FromFileTimeUtc has no way of knowing that. It is documented to throw ArgumentOutOfRangeException when the value “is less than 0 or represents a time greater than DateTime.MaxValue”, and zero is neither of those, so zero converts quietly.
Save this as Test-FileTimeSentinels.ps1 in C:\timelab and run it with .\Test-FileTimeSentinels.ps1.
# Test-FileTimeSentinels.ps1
# Active Directory stores several time attributes as a 64-bit file time, and
# several values in those fields are not times at all. This runs every value an
# export can hand you through FromFileTimeUtc() and reports what comes back.
$candidates = @(
[pscustomobject]@{ RawValue = 0; Note = 'documented sentinel' }
[pscustomobject]@{ RawValue = 1; Note = 'one tick after the origin' }
[pscustomobject]@{ RawValue = -1; Note = 'negative' }
[pscustomobject]@{ RawValue = 9223372036854775807; Note = 'Int64 maximum' }
[pscustomobject]@{ RawValue = 2650467743999999999; Note = 'last representable instant' }
[pscustomobject]@{ RawValue = 2650467744000000000; Note = 'one tick past the maximum' }
[pscustomobject]@{ RawValue = 133000000000000000; Note = 'an ordinary value' }
)
'{0,-22}{1,-32}{2}' -f 'Raw value', 'FromFileTimeUtc result', 'Note'
'{0,-22}{1,-32}{2}' -f '---------', '----------------------', '----'
foreach ($c in $candidates) {
try {
$r = [datetime]::FromFileTimeUtc($c.RawValue).ToString('yyyy-MM-dd HH:mm:ss.fffffff')
} catch {
# PowerShell wraps the .NET exception, so report the inner type - that is
# the one the documentation names.
$inner = $_.Exception.InnerException
$r = if ($inner) { $inner.GetType().Name } else { $_.Exception.GetType().Name }
}
'{0,-22}{1,-32}{2}' -f $c.RawValue, $r, $c.Note
}
''
# The documented range, read from the type rather than typed in.
'{0,-34}{1}' -f 'Highest accepted file time', [datetime]::MaxValue.ToFileTimeUtc()
'{0,-34}{1}' -f 'Int64 maximum is larger by', ([decimal]9223372036854775807 - [decimal][datetime]::MaxValue.ToFileTimeUtc())
'{0,-34}{1}' -f 'Zero converts without error', ([datetime]::FromFileTimeUtc(0).Year -eq 1601)
'{0,-34}{1}' -f 'Int64 maximum throws', $(try { [void][datetime]::FromFileTimeUtc(9223372036854775807); $false } catch { $true })
Seven values, one row each. Read the first two rows together, then the three rows that throw: zero and one both land in 1601 and differ by a single tick, while a negative value, the Int64 maximum and one tick past the highest representable instant all raise the same exception. The four lines underneath show where the ceiling actually is.
Raw value FromFileTimeUtc result Note
--------- ---------------------- ----
0 1601-01-01 00:00:00.0000000 documented sentinel
1 1601-01-01 00:00:00.0000001 one tick after the origin
-1 ArgumentOutOfRangeException negative
9223372036854775807 ArgumentOutOfRangeException Int64 maximum
2650467743999999999 9999-12-31 23:59:59.9999999 last representable instant
2650467744000000000 ArgumentOutOfRangeException one tick past the maximum
133000000000000000 2022-06-18 04:26:40.0000000 an ordinary value
Highest accepted file time 2650467743999999999
Int64 maximum is larger by 6572904292854775808
Zero converts without error True
Int64 maximum throws True
MethodInvocationException, so a script that filters on the documented type name has to look at $_.Exception.InnerException to find it.
The highest file time .NET will accept is 2650467743999999999, which is the last tick of 9999. The Int64 maximum is more than six quintillion ticks above that, so any field holding 9223372036854775807 is holding a flag, not a date.
Never expires, spelled two different ways
The schema pages say what each sentinel means, and they do not agree with each other, because each attribute defines its own. The same zero carries three different documented meanings across these four attributes, and in the fourth it carries none at all.
| Attribute | Documented meaning of the sentinel |
|---|---|
lastLogon | “A value of zero means that the last logon time is unknown.” |
pwdLastSet | “If this value is set to 0 and the User-Account-Control attribute does not contain the UF_DONT_EXPIRE_PASSWD flag, then the user must set the password at the next logon.” |
accountExpires | “A value of 0 or 0x7FFFFFFFFFFFFFFF (9223372036854775807) indicates that the account never expires.” |
lastLogonTimestamp | The schema page documents no meaning for zero on this attribute. An account that has never authenticated has no value in the field at all. |
The accountExpires row is the one worth reading twice. Two different values mean the same thing, and the page explains why: accounts configured never to expire “may have either value, depending on whether they were originally configured with an expiration value”. So a report that handles only one of the two spellings looks correct on a fresh domain and fails on an old one, where accounts have had expiry dates set and cleared again.
$_.accountExpires -lt $cutoff. Zero is less than any cutoff, so every account that never expires is reported as expired, and 9223372036854775807 is greater than any cutoff, so the other half of the same population is reported as fine. One filter, two opposite wrong answers, no error.
dsquery user -inactive silently misses accounts that have never logged on, because their timestamp attribute is empty rather than old.
A converter that answers what the value means
Rather than remembering the table, put it in a function. This one takes the raw value and the attribute it came from, and returns an object that says either what the instant is or what the value means. It never throws on a value an export can legitimately contain.
Save this as Convert-ADTimestamp.ps1 in C:\timelab and run it with .\Convert-ADTimestamp.ps1.
# Convert-ADTimestamp.ps1
# Converts a 64-bit Active Directory time attribute into either a UTC instant or
# the meaning the schema documents for that value. Dot-source it, then call it.
# The sentinel rules below come from the schema pages for each attribute, not from
# a general rule: the same zero means a different thing in each field.
function Convert-ADTimestamp {
[CmdletBinding()]
param(
[Parameter(Mandatory)][int64]$RawValue,
[Parameter(Mandatory)]
[ValidateSet('lastLogon','lastLogonTimestamp','pwdLastSet','accountExpires')]
[string]$Attribute
)
$never = 9223372036854775807 # 0x7FFFFFFFFFFFFFFF
# Values that are not times. Checked before any conversion is attempted.
if ($RawValue -eq 0) {
$meaning = switch ($Attribute) {
'lastLogon' { 'last logon time is unknown' }
'pwdLastSet' { 'password must be set at next logon' }
'accountExpires' { 'account never expires' }
default { 'zero is not documented for this attribute' }
}
return [pscustomobject]@{
Attribute = $Attribute; RawValue = $RawValue
UtcTime = $null; Meaning = $meaning; IsTime = $false
}
}
if ($Attribute -eq 'accountExpires' -and $RawValue -eq $never) {
return [pscustomobject]@{
Attribute = $Attribute; RawValue = $RawValue
UtcTime = $null; Meaning = 'account never expires'; IsTime = $false
}
}
# Anything else is converted, and a value outside the representable range is
# reported rather than allowed to throw inside a reporting loop.
try {
$utc = [datetime]::FromFileTimeUtc($RawValue)
[pscustomobject]@{
Attribute = $Attribute; RawValue = $RawValue
UtcTime = $utc; Meaning = 'converted'; IsTime = $true
}
} catch {
[pscustomobject]@{
Attribute = $Attribute; RawValue = $RawValue
UtcTime = $null; Meaning = 'outside the representable range'; IsTime = $false
}
}
}
# A self-test so the file can be run directly as well as dot-sourced.
if ($MyInvocation.InvocationName -ne '.') {
$cases = @(
@{ v = 0; a = 'lastLogon' }
@{ v = 0; a = 'pwdLastSet' }
@{ v = 0; a = 'accountExpires' }
@{ v = 0; a = 'lastLogonTimestamp' }
@{ v = 9223372036854775807; a = 'accountExpires' }
@{ v = 9223372036854775807; a = 'lastLogon' }
@{ v = 133904736000000000; a = 'lastLogonTimestamp' }
)
'{0,-20}{1,-22}{2,-21}{3}' -f 'Attribute', 'Raw value', 'UTC time', 'Meaning'
'{0,-20}{1,-22}{2,-21}{3}' -f '---------', '---------', '--------', '-------'
foreach ($c in $cases) {
$r = Convert-ADTimestamp -RawValue $c.v -Attribute $c.a
$t = if ($r.UtcTime) { $r.UtcTime.ToString('yyyy-MM-dd HH:mm:ss') } else { '(none)' }
'{0,-20}{1,-22}{2,-21}{3}' -f $r.Attribute, $r.RawValue, $t, $r.Meaning
}
}
Run it directly and it tests itself against seven cases. You should see a table of seven rows where six of them have no UTC time, the same zero producing three different meanings and one undocumented case, and the Int64 maximum meaning “never expires” in one attribute and failing conversion in another.
Attribute Raw value UTC time Meaning
--------- --------- -------- -------
lastLogon 0 (none) last logon time is unknown
pwdLastSet 0 (none) password must be set at next logon
accountExpires 0 (none) account never expires
lastLogonTimestamp 0 (none) zero is not documented for this attribute
accountExpires 9223372036854775807 (none) account never expires
lastLogon 9223372036854775807 (none) outside the representable range
lastLogonTimestamp 133904736000000000 2025-04-30 08:00:00 converted
9223372036854775807, is a documented flag in accountExpires and an out-of-range number in lastLogon. That is the whole argument for passing the attribute name alongside the value: a converter that sees only the number cannot be right about both.
$MyInvocation.InvocationName before running its self-test. Dot-source it with . .\Convert-ADTimestamp.ps1 and the self-test stays quiet.
Build the sample export
The next two sections work on an export rather than on a live domain, so the numbers on this page are the numbers on your screen. This script writes it. Every file time in the file is produced from a fixed UTC date rather than typed in, and the five sentinel rows are the values a real export contains.
Save this as New-TimestampFixture.ps1 in C:\timelab and run it with .\New-TimestampFixture.ps1.
# New-TimestampFixture.ps1
# Writes the sample export the report script reads. Every file time in it is
# produced from a fixed UTC date rather than typed in, so the file is consistent
# with itself, and the five sentinel rows are the values a real export contains.
$outFile = Join-Path $PSScriptRoot 'ad-timestamps.csv'
function New-Row {
param([string]$AccountName, [string]$Attribute, [string]$UtcDate, [int64]$Literal = -1)
$raw = if ($UtcDate) {
[datetime]::SpecifyKind([datetime]$UtcDate, 'Utc').ToFileTimeUtc()
} else { $Literal }
[pscustomobject]@{ AccountName = $AccountName; Attribute = $Attribute; RawValue = $raw }
}
$rows = @(
New-Row 'jdoe' 'lastLogonTimestamp' '2026-03-02T07:41:00'
New-Row 'jdoe' 'pwdLastSet' '2025-11-18T09:02:00'
New-Row 'jdoe' 'accountExpires' '' 9223372036854775807
New-Row 'mhughes' 'lastLogonTimestamp' '2025-08-14T06:15:00'
New-Row 'mhughes' 'accountExpires' '2026-06-30T00:00:00'
New-Row 'svc-backup' 'lastLogonTimestamp' '2026-03-09T23:05:00'
New-Row 'svc-backup' 'pwdLastSet' '' 0
New-Row 'contractor7' 'lastLogonTimestamp' '' 0
New-Row 'contractor7' 'accountExpires' '' 0
New-Row 'SRV-PROD-01' 'lastLogon' '' 0
New-Row 'SRV-PROD-01' 'lastLogonTimestamp' '2026-03-10T04:20:00'
New-Row 'kiosk01' 'lastLogonTimestamp' '2024-12-31T18:44:00'
)
$rows | Export-Csv -Path $outFile -NoTypeInformation
'{0,-30}{1}' -f 'File written', (Split-Path $outFile -Leaf)
'{0,-30}{1}' -f 'Rows written', $rows.Length
'{0,-30}{1}' -f 'Distinct accounts', ($rows.AccountName | Sort-Object -Unique).Length
# Wrapped in @() so the count is the number of matches even when there is one.
'{0,-30}{1}' -f 'Rows that are sentinels', @($rows | Where-Object { $_.RawValue -eq 0 -or $_.RawValue -eq 9223372036854775807 }).Length
''
'{0,-14}{1,-22}{2}' -f 'Account', 'Attribute', 'Raw value'
'{0,-14}{1,-22}{2}' -f '-------', '---------', '---------'
foreach ($r in $rows) { '{0,-14}{1,-22}{2}' -f $r.AccountName, $r.Attribute, $r.RawValue }
You should see four summary lines and then the twelve rows it wrote, covering six accounts. One raw value is the Int64 maximum you have already met and four more are zero, which is the five the summary counts.
File written ad-timestamps.csv
Rows written 12
Distinct accounts 6
Rows that are sentinels 5
Account Attribute Raw value
------- --------- ---------
jdoe lastLogonTimestamp 134169108600000000
jdoe pwdLastSet 134079301200000000
jdoe accountExpires 9223372036854775807
mhughes lastLogonTimestamp 133996257000000000
mhughes accountExpires 134272512000000000
svc-backup lastLogonTimestamp 134175711000000000
svc-backup pwdLastSet 0
contractor7 lastLogonTimestamp 0
contractor7 accountExpires 0
SRV-PROD-01 lastLogon 0
SRV-PROD-01 lastLogonTimestamp 134175900000000000
kiosk01 lastLogonTimestamp 133801442400000000
The file itself is ordinary CSV, and this is what the report reads.
"AccountName","Attribute","RawValue"
"jdoe","lastLogonTimestamp","134169108600000000"
"jdoe","pwdLastSet","134079301200000000"
"jdoe","accountExpires","9223372036854775807"
"mhughes","lastLogonTimestamp","133996257000000000"
"mhughes","accountExpires","134272512000000000"
"svc-backup","lastLogonTimestamp","134175711000000000"
"svc-backup","pwdLastSet","0"
"contractor7","lastLogonTimestamp","0"
"contractor7","accountExpires","0"
"SRV-PROD-01","lastLogon","0"
"SRV-PROD-01","lastLogonTimestamp","134175900000000000"
"kiosk01","lastLogonTimestamp","133801442400000000"
AccountName rather than Name on purpose. PowerShell gives every array and every scalar a Count of its own, so a property with that name is ambiguous in a pipeline, and the same caution is worth applying to Name and Length. A distinct column name costs nothing and removes the question.
The same report, counted twice
Now the point of all of it. This script reports on the exported lastLogonTimestamp values twice: once the way a quick one-liner does it, and once asking what each value means first. The reference date is a parameter with a fixed default, so the age numbers do not change between runs.
Run New-TimestampFixture.ps1 first. This script reads the CSV that one writes, and dot-sources Convert-ADTimestamp.ps1 from the same folder.
Save this as Get-TimestampReport.ps1 in C:\timelab and run it with .\Get-TimestampReport.ps1.
# Get-TimestampReport.ps1
# Reads the sample export and reports on it twice: once the way a one-line script
# usually does it, and once with the sentinel values recognised. The reference
# date is a parameter with a fixed default so the numbers do not move between runs.
# Run New-TimestampFixture.ps1 first - this script reads the file it writes.
param(
[datetime]$AsOf = '2026-03-10T00:00:00',
[int]$StaleDays = 90
)
. (Join-Path $PSScriptRoot 'Convert-ADTimestamp.ps1')
$csvPath = Join-Path $PSScriptRoot 'ad-timestamps.csv'
$rows = Import-Csv -Path $csvPath
$asOfUtc = [datetime]::SpecifyKind($AsOf, 'Utc')
'{0,-30}{1}' -f 'Reference date', $asOfUtc.ToString('yyyy-MM-dd')
'{0,-30}{1}' -f 'Stale after days', $StaleDays
'{0,-30}{1}' -f 'Rows read', @($rows).Length
''
# The naive version: convert everything and compare. Nothing errors, so nothing
# says that one of these rows was never a time at all.
$naiveStale = 0
foreach ($r in $rows | Where-Object Attribute -eq 'lastLogonTimestamp') {
$raw = [int64]$r.RawValue
$age = ($asOfUtc - [datetime]::FromFileTimeUtc($raw)).TotalDays
if ($age -gt $StaleDays) { $naiveStale++ }
}
# The careful version: ask what the value means before treating it as a date.
$careful = foreach ($r in $rows | Where-Object Attribute -eq 'lastLogonTimestamp') {
$c = Convert-ADTimestamp -RawValue ([int64]$r.RawValue) -Attribute $r.Attribute
[pscustomobject]@{
AccountName = $r.AccountName
IsTime = $c.IsTime
AgeDays = if ($c.IsTime) { [int]($asOfUtc - $c.UtcTime).TotalDays } else { $null }
Meaning = $c.Meaning
}
}
'{0,-14}{1,-12}{2}' -f 'Account', 'Age in days', 'Status'
'{0,-14}{1,-12}{2}' -f '-------', '-----------', '------'
foreach ($c in $careful) {
$age = if ($null -ne $c.AgeDays) { $c.AgeDays } else { '-' }
$status = if (-not $c.IsTime) { 'not a time' }
elseif ($c.AgeDays -gt $StaleDays) { 'stale' }
else { 'active' }
'{0,-14}{1,-12}{2}' -f $c.AccountName, $age, $status
}
''
$carefulStale = @($careful | Where-Object { $_.IsTime -and $_.AgeDays -gt $StaleDays }).Length
$notTimes = @($careful | Where-Object { -not $_.IsTime }).Length
'{0,-34}{1}' -f 'Stale, counted naively', $naiveStale
'{0,-34}{1}' -f 'Stale, sentinels excluded', $carefulStale
'{0,-34}{1}' -f 'Rows that were not times', $notTimes
'{0,-34}{1}' -f 'Accounts the naive count invents', ($naiveStale - $carefulStale)
''
# What the naive version believed about the row it should have skipped.
$zeroRow = $rows | Where-Object { $_.Attribute -eq 'lastLogonTimestamp' -and [int64]$_.RawValue -eq 0 } | Select-Object -First 1
$zeroAge = ($asOfUtc - [datetime]::FromFileTimeUtc(0)).TotalDays
'{0,-34}{1}' -f 'Row with a zero value', $zeroRow.AccountName
'{0,-34}{1}' -f 'Age the naive version gave it', [int64]$zeroAge
'{0,-34}{1}' -f 'In years', [int64]($zeroAge / 365.25)
You should see a six-row table of accounts with an age and a status, then the two counts side by side, then what the naive version believed about the row it should have skipped.
Reference date 2026-03-10
Stale after days 90
Rows read 12
Account Age in days Status
------- ----------- ------
jdoe 8 active
mhughes 208 stale
svc-backup 0 active
contractor7 - not a time
SRV-PROD-01 0 active
kiosk01 433 stale
Stale, counted naively 3
Stale, sentinels excluded 2
Rows that were not times 1
Accounts the naive count invents 1
Row with a zero value contractor7
Age the naive version gave it 155296
In years 425
The fix in the script is three lines long and the pattern is always the same: decide whether the value is a time before doing arithmetic on it, and keep the ones that are not in their own bucket rather than dropping them. An account with no logon timestamp is interesting, but it is a different finding from an account that stopped being used.
lastLogon and lastLogonTimestamp are different attributes
Both hold a file time. They do not hold the same file time, and the difference is one bit in the schema. The System-Flags page lists what each bit means when it is applied to an attribute: value 1 means “the attribute will not be replicated” and value 2 means “the attribute will be replicated to the global catalog”.
Save this as Show-SystemFlags.ps1 in C:\timelab and run it with .\Show-SystemFlags.ps1.
# Show-SystemFlags.ps1
# The schema pages for lastLogon and lastLogonTimestamp differ by one bit in
# System-Flags. Decoding that bit is the whole difference between an attribute
# that stays on one domain controller and one that reaches all of them.
# Values taken from the two schema reference pages, every implementation from
# Windows 2000 Server through Windows Server 2012 inclusive.
$attributes = @(
[pscustomobject]@{ Attribute = 'lastLogon'; SystemFlags = 0x00000011 }
[pscustomobject]@{ Attribute = 'lastLogonTimestamp'; SystemFlags = 0x00000010 }
[pscustomobject]@{ Attribute = 'pwdLastSet'; SystemFlags = 0x00000010 }
[pscustomobject]@{ Attribute = 'accountExpires'; SystemFlags = 0x00000010 }
)
# The bits that apply to an attribute, as the System-Flags page lists them.
# An array is used rather than a hashtable with integer keys, because indexing an
# ordered dictionary with an integer selects by position and not by key.
$flagBits = @(
[pscustomobject]@{ Bit = 0x00000001; Meaning = 'will not be replicated' }
[pscustomobject]@{ Bit = 0x00000002; Meaning = 'replicated to the global catalog' }
[pscustomobject]@{ Bit = 0x00000004; Meaning = 'is constructed' }
[pscustomobject]@{ Bit = 0x00000010; Meaning = 'category 1 object' }
)
'{0,-22}{1,-14}{2}' -f 'Attribute', 'System-Flags', 'Bits set'
'{0,-22}{1,-14}{2}' -f '---------', '------------', '--------'
foreach ($a in $attributes) {
$set = foreach ($f in $flagBits) {
if ($a.SystemFlags -band $f.Bit) { $f.Meaning }
}
'{0,-22}{1,-14}{2}' -f $a.Attribute, ('0x{0:X8}' -f $a.SystemFlags), ($set -join ', ')
}
''
$ll = ($attributes | Where-Object Attribute -eq 'lastLogon').SystemFlags
$llt = ($attributes | Where-Object Attribute -eq 'lastLogonTimestamp').SystemFlags
$diff = $ll -bxor $llt
'{0,-44}{1}' -f 'Difference between the two values', ('0x{0:X8}' -f $diff)
'{0,-44}{1}' -f 'That bit means', ($flagBits | Where-Object Bit -eq $diff).Meaning
'{0,-44}{1}' -f 'lastLogon replicates', (-not [bool]($ll -band 0x00000001))
'{0,-44}{1}' -f 'lastLogonTimestamp replicates', (-not [bool]($llt -band 0x00000001))
'{0,-44}{1}' -f 'Global catalog bit set on either one', [bool](($ll -bor $llt) -band 0x00000002)
The values in the script are copied from the schema reference pages, where they are the same for every implementation from Windows 2000 Server through Windows Server 2012. You should see four attributes decoded, then the exclusive-or of the two logon attributes isolating a single bit.
Attribute System-Flags Bits set
--------- ------------ --------
lastLogon 0x00000011 will not be replicated, category 1 object
lastLogonTimestamp 0x00000010 category 1 object
pwdLastSet 0x00000010 category 1 object
accountExpires 0x00000010 category 1 object
Difference between the two values 0x00000001
That bit means will not be replicated
lastLogon replicates False
lastLogonTimestamp replicates True
Global catalog bit set on either one False
| Attribute | Replicates | What it is good for |
|---|---|---|
lastLogon | no, by flag | the last logon recorded by the one domain controller you asked. Because the flag says the attribute is not replicated, another controller can hold a different value for the same account |
lastLogonTimestamp | yes | a domain-wide answer that is deliberately imprecise |
lastLogonTimestamp records “In Global Catalog: True” in its implementation tables from Windows Server 2008 onwards, where lastLogon records False in all of them. Global catalog membership is recorded in those tables rather than in the flag, so read both parts of the page before concluding anything about an attribute.
If you are chasing a replication question rather than a timestamp question, the repadmin guide covers checking and forcing replication between controllers.
Why two stale account reports disagree
The imprecision in lastLogonTimestamp is deliberate and documented. The attribute is only rewritten when the stored value is older than the current time minus msDS-LogonTimeSyncInterval, an attribute described as controlling “the granularity, in days, with which the last logon time for a user or computer, recorded in the lastLogonTimestamp attribute, is replicated to all DCs in a domain”.
The same page gives one figure, and it applies to a single event rather than to every write: “The initial update after the raise of the domain functional level is calculated as 14 days minus random percentage of 5 days.” Two reports taken a day apart can therefore disagree about an account without either of them being wrong.
| What you read | What it is |
|---|---|
| A timestamp older than your cutoff | evidence the account was idle at some point inside the granularity window, not evidence of the last authentication |
| A timestamp a few days old | the account authenticated at some point since the attribute was last written, which can be today or as long ago as the sync interval in force in that domain |
| Two reports that disagree slightly | expected: the write is deliberately spread out, and the first one after a functional level raise is randomised |
msDS-LogonTimeSyncInterval is set on the domain object, and the schema page for it documents no default. A cutoff of the same order as the granularity is not a measurement. A cutoff of 90 or 180 days is far enough outside the window that the granularity stops mattering.
The same command returns two different numbers
Windows PowerShell 5.1 and PowerShell 7 ship the same cmdlet with the same switch, and their documentation describes its result differently. The table of format specifiers in each version has a row for %s, and the rows do not match.
| Version | Documented meaning of %s | Example in the table |
|---|---|---|
| Windows PowerShell 5.1 | Seconds elapsed since January 1, 1970 00:00:00 (converted to local time) | 1150451174.95705 |
| PowerShell 7 | Seconds elapsed since January 1, 1970 00:00:00 (UTC) | 1150451174 |
The 5.1 page does not treat that as a difference of opinion. It says the behaviour is wrong: “Get-Date -UFormat %s is incorrect in two respects”, listing “The return value is based on local time instead of UTC time” and “The string representation of the seconds value has a fractional part. The output is culture-sensitive with respect to the decimal mark.” It closes with “These behaviors are fixed in PowerShell 6.1 and higher.”
Get-Date -UFormat %s at all. Running it would print one of the two answers depending on which shell happened to be open, which proves nothing and cannot be reproduced. Instead both documented rules are modelled against a fixed instant and a named zone, so the difference between them is visible from any shell on any machine. The instant and the zone are chosen; both rules come from the documentation.
Save this as Show-UnixSeconds.ps1 in C:\timelab and run it with .\Show-UnixSeconds.ps1.
# Show-UnixSeconds.ps1
# What a Unix second count is worth on Windows, and the two documented spellings
# of Get-Date -UFormat %s. Windows PowerShell 5.1 documents %s as local-time
# based with a fractional part; PowerShell 6.1 and later document it as UTC.
# Both rules are modelled here so the difference is visible from one shell.
$tz = [TimeZoneInfo]::FindSystemTimeZoneById('Pacific Standard Time')
$instant = [datetimeoffset]::new(2026, 3, 10, 15, 30, 0, [TimeSpan]::Zero) # a fixed UTC instant
# The PowerShell 6.1 and later rule: seconds since the epoch, UTC, integer.
$modern = $instant.ToUnixTimeSeconds()
# The Windows PowerShell 5.1 rule: the same clock reading, but counted from the
# epoch as if the local wall clock were UTC, and carrying a fractional part.
$wallInZone = [TimeZoneInfo]::ConvertTime($instant, $tz).DateTime
$legacy = [datetimeoffset]::new([datetime]::SpecifyKind($wallInZone, 'Utc')).ToUnixTimeSeconds()
'{0,-34}{1}' -f 'Fixed UTC instant', $instant.UtcDateTime.ToString('yyyy-MM-dd HH:mm:ss')
'{0,-34}{1}' -f 'Same instant in that zone', $wallInZone.ToString('yyyy-MM-dd HH:mm:ss')
'{0,-34}{1}' -f 'Zone offset in force', $tz.GetUtcOffset($instant.UtcDateTime)
''
'{0,-34}{1}' -f 'UTC rule (6.1 and later)', $modern
'{0,-34}{1}' -f 'Local rule (5.1, documented)', $legacy
'{0,-34}{1}' -f 'Difference in seconds', ($legacy - $modern)
'{0,-34}{1}' -f 'Difference in hours', (($legacy - $modern) / 3600)
'{0,-34}{1}' -f 'Difference equals the offset', ([TimeSpan]::FromSeconds($legacy - $modern) -eq $tz.GetUtcOffset($instant.UtcDateTime))
''
# What each number converts back to, read as a plain Unix timestamp.
'{0,-34}{1}' -f 'UTC rule read back', [datetimeoffset]::FromUnixTimeSeconds($modern).UtcDateTime.ToString('yyyy-MM-dd HH:mm:ss')
'{0,-34}{1}' -f 'Local rule read back', [datetimeoffset]::FromUnixTimeSeconds($legacy).UtcDateTime.ToString('yyyy-MM-dd HH:mm:ss')
''
# Thirteen digits is milliseconds, and feeding it to the seconds overload is the
# most common mistake with an exported timestamp.
$ms = $instant.ToUnixTimeMilliseconds()
'{0,-34}{1}' -f 'Milliseconds value', $ms
'{0,-34}{1}' -f 'Digits in it', $ms.ToString().Length
'{0,-34}{1}' -f 'Read as seconds by mistake', $(try { [datetimeoffset]::FromUnixTimeSeconds($ms).UtcDateTime.ToString('yyyy') } catch { $_.Exception.InnerException.GetType().Name })
'{0,-34}{1}' -f 'Read as milliseconds', [datetimeoffset]::FromUnixTimeMilliseconds($ms).UtcDateTime.ToString('yyyy-MM-dd HH:mm:ss')
''
# The documented limits of the seconds overload, confirmed one value either side.
$lowest = -62135596800
$highest = 253402300799
'{0,-34}{1}' -f 'Lowest accepted', [datetimeoffset]::FromUnixTimeSeconds($lowest).UtcDateTime.ToString('yyyy-MM-dd')
'{0,-34}{1}' -f 'Highest accepted', [datetimeoffset]::FromUnixTimeSeconds($highest).UtcDateTime.ToString('yyyy-MM-dd')
'{0,-34}{1}' -f 'One below the lowest throws', $(try { [void][datetimeoffset]::FromUnixTimeSeconds($lowest - 1); $false } catch { $true })
'{0,-34}{1}' -f 'One above the highest throws', $(try { [void][datetimeoffset]::FromUnixTimeSeconds($highest + 1); $false } catch { $true })
You should see the fixed instant in two zones, then the two rules side by side with the difference between them, then each number read back as a plain Unix timestamp, then the milliseconds trap, then the two documented range limits confirmed one value either side.
Fixed UTC instant 2026-03-10 15:30:00
Same instant in that zone 2026-03-10 08:30:00
Zone offset in force -07:00:00
UTC rule (6.1 and later) 1773156600
Local rule (5.1, documented) 1773131400
Difference in seconds -25200
Difference in hours -7
Difference equals the offset True
UTC rule read back 2026-03-10 15:30:00
Local rule read back 2026-03-10 08:30:00
Milliseconds value 1773156600000
Digits in it 13
Read as seconds by mistake ArgumentOutOfRangeException
Read as milliseconds 2026-03-10 15:30:00
Lowest accepted 0001-01-01
Highest accepted 9999-12-31
One below the lowest throws True
One above the highest throws True
Get-Date -UFormat %s writes a number that is wrong by the machine offset, and the records are accepted by everything downstream because the value is a valid timestamp. Move the same script to PowerShell 7 and the numbers change by that offset from that day forward, so one dataset contains two conventions and nothing marks the boundary.
[datetimeoffset]::new([datetime]::UtcNow).ToUnixTimeSeconds() returns the same integer on both versions and does not depend on the machine time zone.
Reading a Unix timestamp back, and the two limits
In the other direction PowerShell 7 has a parameter for it. Get-Date gained -UnixTimeSeconds in PowerShell 7.1, with UnixTime as an alias, and the parameter takes an Int64. Windows PowerShell 5.1 has no such parameter, so a script that has to run on both uses the .NET call instead.
| What you have | PowerShell 7.1 and later | Works on 5.1 as well |
|---|---|---|
| Seconds | Get-Date -UnixTimeSeconds 1773156600 | [datetimeoffset]::FromUnixTimeSeconds(1773156600).UtcDateTime |
| Milliseconds | no parameter for it | [datetimeoffset]::FromUnixTimeMilliseconds(1773156600000).UtcDateTime |
| A file time | no parameter for it | [datetime]::FromFileTimeUtc(134176302000000000) |
Two details from the output in the previous section are worth keeping. A thirteen-digit value is milliseconds, and handing it to the seconds overload throws rather than returning an absurd date, because the documented ceiling for FromUnixTimeSeconds is 253402300799, the last second of 9999. The floor is -62135596800, which is the start of the year 1, so a negative Unix timestamp is perfectly legal and means a date before 1970.
There is one more difference between the two columns of that table, and it is easy to trip over when comparing their output. The cmdlet hands back a value whose Kind is Local, while the .NET call hands back a UTC instant because .UtcDateTime was asked for. Both describe the same point in time, rendered on two different clocks.
# Same instant, two renderings. Compare Kind before deciding that one of
# them is wrong: on a machine away from UTC the two print different days.
(Get-Date -UnixTimeSeconds 1577836800).Kind
Get-Date -UnixTimeSeconds 1577836800 -Format 'yyyy-MM-dd HH:mm:ss'
[datetimeoffset]::FromUnixTimeSeconds(1577836800).UtcDateTime.ToString('yyyy-MM-dd HH:mm:ss')
The first line prints Local on any machine. The second and third print the same text only when the machine is set to UTC: measured on a machine in the Pacific zone, the cmdlet prints 2019-12-31 16:00:00 for that number while the .NET call prints 2020-01-01 00:00:00.
Timestamps that arrive as text
Most timestamps reach a script as text: a CSV column, a JSON field, a log line, an argument someone typed. At that moment two decisions are made for you by the current culture, and neither of them reports an error.
Save this as Show-CultureParsing.ps1 in C:\timelab and run it with .\Show-CultureParsing.ps1.
# Show-CultureParsing.ps1
# Timestamps that arrive as text. A culture decides where the decimal mark is and
# which number is the month, and neither mistake reports an error.
$invariant = [System.Globalization.CultureInfo]::InvariantCulture
$enUS = [System.Globalization.CultureInfo]::GetCultureInfo('en-US')
$deDE = [System.Globalization.CultureInfo]::GetCultureInfo('de-DE')
# 1. A fractional epoch value, which is what Windows PowerShell 5.1 documents
# -UFormat %s as producing. The decimal mark follows the culture.
$fractional = 1150451174.95705
$cultures = @(
[pscustomobject]@{ Label = 'invariant'; Culture = $invariant }
[pscustomobject]@{ Label = 'en-US'; Culture = $enUS }
[pscustomobject]@{ Label = 'de-DE'; Culture = $deDE }
)
'{0,-16}{1}' -f 'Culture', 'Fractional seconds as text'
'{0,-16}{1}' -f '-------', '--------------------------'
foreach ($c in $cultures) {
'{0,-16}{1}' -f $c.Label, $fractional.ToString($c.Culture)
}
''
# Casting that text back to a number only works where the mark matches.
$withComma = $fractional.ToString($deDE)
'{0,-40}{1}' -f 'Comma form parses under de-DE', [double]::TryParse($withComma, [System.Globalization.NumberStyles]::Float, $deDE, [ref]$null)
'{0,-40}{1}' -f 'Comma form parses as invariant', [double]::TryParse($withComma, [System.Globalization.NumberStyles]::Float, $invariant, [ref]$null)
# A cast is not a truncation: PowerShell rounds, so the whole second it returns
# is later than the instant the fractional value described.
'{0,-40}{1}' -f 'Cast to int64', [int64]$fractional
'{0,-40}{1}' -f 'Floor of the same value', [int64][math]::Floor($fractional)
'{0,-40}{1}' -f 'Cast and floor agree', ([int64]$fractional -eq [int64][math]::Floor($fractional))
''
# 2. The same nine characters, two cultures, two different days and no error.
$ambiguous = '03/10/2026'
'{0,-16}{1,-14}{2}' -f 'Culture', 'Parsed day', 'ISO form'
'{0,-16}{1,-14}{2}' -f '-------', '----------', '--------'
foreach ($c in $cultures) {
$d = [datetime]::Parse($ambiguous, $c.Culture)
'{0,-16}{1,-14}{2}' -f $c.Label, $d.DayOfWeek, $d.ToString('yyyy-MM-dd')
}
''
# 3. The reliable way to read a timestamp whose format you were told: an exact
# pattern, the invariant culture, and a rule for what the text means.
$text = '2026-03-10 15:30:00'
$exact = [datetime]::ParseExact($text, 'yyyy-MM-dd HH:mm:ss', $invariant,
[System.Globalization.DateTimeStyles]::AssumeUniversal -bor
[System.Globalization.DateTimeStyles]::AdjustToUniversal)
'{0,-40}{1}' -f 'Text read with an exact pattern', $exact.ToString('yyyy-MM-dd HH:mm:ss')
'{0,-40}{1}' -f 'Kind after AssumeUniversal', $exact.Kind
'{0,-40}{1}' -f 'As a file time', $exact.ToFileTimeUtc()
''
# 4. Round-trip format. The o specifier records the offset, so the value survives
# being written to a file and read back on a machine in another zone.
$round = [datetimeoffset]::new($exact).ToString('o', $invariant)
$back = [datetimeoffset]::ParseExact($round, 'o', $invariant)
'{0,-40}{1}' -f 'Round-trip text', $round
'{0,-40}{1}' -f 'Parsed back to the same instant', ($back.UtcDateTime -eq $exact)
'{0,-40}{1}' -f 'Offset recorded in the text', $back.Offset
''
# 5. What kind the value ends up with, which is the thing that decides how every
# conversion after this point behaves. None of these four lines names a zone.
$samples = @(
[pscustomobject]@{ Expression = "[datetime]'2026-03-10T08:30:00'"
Kind = ([datetime]'2026-03-10T08:30:00').Kind }
[pscustomobject]@{ Expression = "[datetime]::Parse('2026-03-10T08:30:00')"
Kind = ([datetime]::Parse('2026-03-10T08:30:00')).Kind }
[pscustomobject]@{ Expression = "[datetime]'2026-03-10T08:30:00Z'"
Kind = ([datetime]'2026-03-10T08:30:00Z').Kind }
[pscustomobject]@{ Expression = 'ParseExact with AssumeUniversal'
Kind = $exact.Kind }
)
'{0,-44}{1}' -f 'How the text was read', 'Kind of the result'
'{0,-44}{1}' -f '---------------------', '------------------'
foreach ($s in $samples) { '{0,-44}{1}' -f $s.Expression, $s.Kind }
Five parts, and the second table is the dangerous one. The same nine characters parse to two different days under two cultures, with no error and no warning. Compare the parsed day of the week against the ISO form in the last column, then read the last table, which is the kind every one of those readings ends up with.
Culture Fractional seconds as text
------- --------------------------
invariant 1150451174.95705
en-US 1150451174.95705
de-DE 1150451174,95705
Comma form parses under de-DE True
Comma form parses as invariant False
Cast to int64 1150451175
Floor of the same value 1150451174
Cast and floor agree False
Culture Parsed day ISO form
------- ---------- --------
invariant Tuesday 2026-03-10
en-US Tuesday 2026-03-10
de-DE Saturday 2026-10-03
Text read with an exact pattern 2026-03-10 15:30:00
Kind after AssumeUniversal Utc
As a file time 134176302000000000
Round-trip text 2026-03-10T15:30:00.0000000+00:00
Parsed back to the same instant True
Offset recorded in the text 00:00:00
How the text was read Kind of the result
--------------------- ------------------
[datetime]'2026-03-10T08:30:00' Unspecified
[datetime]::Parse('2026-03-10T08:30:00') Unspecified
[datetime]'2026-03-10T08:30:00Z' Local
ParseExact with AssumeUniversal Utc
[datetime]::Parse($text) and no culture. On an English build 03/10/2026 is 10 March. On a German build the same text is 3 October. A scheduled job that runs on one server and a desktop script that runs on another will disagree about seven months and both will look right to their own author.
Z does not produce a UTC value. Reading [datetime]'2026-03-10T08:30:00Z' honours the Z by converting to the local time of the machine, and the result has Kind set to Local, not Utc. The last table in that output shows all four readings: two come out Unspecified, one comes out Local, and only the explicit ParseExact with AssumeUniversal comes out Utc.
[int64]1150451174.95705 returns 1150451175 because PowerShell rounds, so a fractional epoch value cast to a whole number can name a second that had not happened yet. Use [math]::Floor() when you mean to drop the fraction.
The last two lines of the script are the shape to copy for anything that leaves your script and comes back. The round-trip specifier o writes the offset into the text, so the value survives a trip through a file and a machine in another country, and ParseExact with the invariant culture reads it back without consulting anything local. If the timestamp is going into a JSON document, the ConvertTo-Json guide covers what the serialiser does with a DateTime on the way out.
Daylight saving, and the habit of subtracting a fixed number
A zone identifier is not an offset. Microsoft warns about this in the file times documentation, where FileTimeToLocalFileTime, the Win32 function that converts a file time to local time, “uses the current settings for the time zone and daylight saving time. Therefore, if it is daylight saving time, it takes daylight saving time into account, even if the file time you are converting is in standard time.”
Save this as Show-DstConversion.ps1 in C:\timelab and run it with .\Show-DstConversion.ps1.
# Show-DstConversion.ps1
# A fixed offset is not a time zone. The same named zone answers with two
# different offsets a week apart, and the transition is not a whole hour wide.
$tz = [TimeZoneInfo]::FindSystemTimeZoneById('Pacific Standard Time')
'{0,-30}{1}' -f 'Zone display name', $tz.StandardName
'{0,-30}{1}' -f 'Base offset', $tz.BaseUtcOffset
'{0,-30}{1}' -f 'Observes daylight saving', $tz.SupportsDaylightSavingTime
''
# Four file times across the March 2026 transition, converted through the zone.
$samples = @(
[datetime]::SpecifyKind([datetime]'2026-03-07T20:00:00', 'Utc')
[datetime]::SpecifyKind([datetime]'2026-03-08T09:59:00', 'Utc')
[datetime]::SpecifyKind([datetime]'2026-03-08T10:00:00', 'Utc')
[datetime]::SpecifyKind([datetime]'2026-03-14T20:00:00', 'Utc')
)
'{0,-22}{1,-22}{2,-12}{3}' -f 'File time (UTC)', 'Local in that zone', 'Offset', 'Daylight'
'{0,-22}{1,-22}{2,-12}{3}' -f '---------------', '------------------', '------', '--------'
foreach ($u in $samples) {
$local = [TimeZoneInfo]::ConvertTimeFromUtc($u, $tz)
$offset = $tz.GetUtcOffset($u)
'{0,-22}{1,-22}{2,-12}{3}' -f $u.ToString('MM-dd HH:mm'), $local.ToString('MM-dd HH:mm'), $offset, $tz.IsDaylightSavingTime($u)
}
''
# Subtracting a fixed eight hours is right for one of those readings and wrong
# for the others, which is the habit that puts an event an hour out of place.
$fixed = [TimeSpan]::FromHours(-8)
$rightCount = 0
foreach ($u in $samples) { if ($tz.GetUtcOffset($u) -eq $fixed) { $rightCount++ } }
'{0,-40}{1}' -f 'Readings where minus eight is correct', $rightCount
'{0,-40}{1}' -f 'Readings checked', $samples.Length
''
# The gap in local wall clock across the transition, and the hour that does not
# exist on that date in that zone.
$before = [TimeZoneInfo]::ConvertTimeFromUtc($samples[1], $tz)
$after = [TimeZoneInfo]::ConvertTimeFromUtc($samples[2], $tz)
'{0,-40}{1}' -f 'One minute of real time apart', (($samples[2] - $samples[1]).TotalMinutes -eq 1)
'{0,-40}{1}' -f 'Local clocks shown', ($before.ToString('HH:mm') + ' then ' + $after.ToString('HH:mm'))
'{0,-40}{1}' -f 'Local wall clock jumped by', ($after - $before)
'{0,-40}{1}' -f 'Is 02:30 invalid locally that day', $tz.IsInvalidTime([datetime]::SpecifyKind([datetime]'2026-03-08T02:30:00', 'Unspecified'))
Four instants across the March 2026 transition in one zone. Read the offset column: two of the four are at one offset and two at another, and the zone identifier says Standard for all of them. The last block is the interesting one: two instants one minute apart, with local clocks an hour and a minute apart.
Zone display name Pacific Standard Time
Base offset -08:00:00
Observes daylight saving True
File time (UTC) Local in that zone Offset Daylight
--------------- ------------------ ------ --------
03-07 20:00 03-07 12:00 -08:00:00 False
03-08 09:59 03-08 01:59 -08:00:00 False
03-08 10:00 03-08 03:00 -07:00:00 True
03-14 20:00 03-14 13:00 -07:00:00 True
Readings where minus eight is correct 2
Readings checked 4
One minute of real time apart True
Local clocks shown 01:59 then 03:00
Local wall clock jumped by 01:01:00
Is 02:30 invalid locally that day True
W. Europe Standard Time is not, because the European change happens later in the month. For a few weeks each spring and autumn the offset between two offices is not the offset you memorised.
[TimeZoneInfo]::ConvertTimeFromUtc($utc, $tz), which asks the zone what the offset was at that instant.
The invalid local time at the end of that output is the other half of the problem. Between 02:00 and 03:00 on a spring transition date there is no local time in that zone, so a schedule written for 02:30 has no instant to run at. If domain time itself is the question rather than converting a value, the w32tm guide covers diagnosing synchronisation.
The wrapper to keep
Everything above collapses into one script. Give it a value and the origin it came from, and it prints the same instant in every spelling you are likely to need, optionally in a named zone. It reads nothing from the machine it runs on, so two administrators in two countries get the same answer from the same input.
Save this as Convert-WindowsTimestamp.ps1 in C:\timelab and run it with .\Convert-WindowsTimestamp.ps1 -Value 134176302000000000 -From FileTime.
The two parameters are mandatory, so running the bare file name only makes the script prompt for them. Pass the value and the origin on the command line as shown.
# Convert-WindowsTimestamp.ps1
# One timestamp in, every spelling of it out. Takes a 64-bit value and the origin
# it was counted from, and prints the same instant as a UTC string, an ISO 8601
# round-trip string, a file time, Unix seconds and the local time in a named zone.
#
# Usage:
# .\Convert-WindowsTimestamp.ps1 -Value 134176302000000000 -From FileTime
# .\Convert-WindowsTimestamp.ps1 -Value 1773156600 -From UnixSeconds -Zone 'W. Europe Standard Time'
[CmdletBinding()]
param(
[Parameter(Mandatory)][int64]$Value,
[Parameter(Mandatory)][ValidateSet('FileTime','UnixSeconds','UnixMilliseconds','Ticks')]
[string]$From,
[string]$Zone
)
$invariant = [System.Globalization.CultureInfo]::InvariantCulture
# Every branch produces a Utc-kind DateTime, so nothing downstream has to guess.
$utc = switch ($From) {
'FileTime' { [datetime]::FromFileTimeUtc($Value) }
'UnixSeconds' { [datetimeoffset]::FromUnixTimeSeconds($Value).UtcDateTime }
'UnixMilliseconds' { [datetimeoffset]::FromUnixTimeMilliseconds($Value).UtcDateTime }
'Ticks' { [datetime]::SpecifyKind([datetime]::new($Value), 'Utc') }
}
$asOffset = [datetimeoffset]::new($utc)
'{0,-24}{1}' -f 'Input value', $Value
'{0,-24}{1}' -f 'Read as', $From
'{0,-24}{1}' -f 'Kind of the result', $utc.Kind
'{0,-24}{1}' -f 'UTC', $utc.ToString('yyyy-MM-dd HH:mm:ss', $invariant)
'{0,-24}{1}' -f 'ISO 8601 round trip', $asOffset.ToString('o', $invariant)
'{0,-24}{1}' -f 'File time', $utc.ToFileTimeUtc()
'{0,-24}{1}' -f 'Unix seconds', $asOffset.ToUnixTimeSeconds()
'{0,-24}{1}' -f 'Unix milliseconds', $asOffset.ToUnixTimeMilliseconds()
'{0,-24}{1}' -f 'DateTime ticks', $utc.Ticks
# A named zone is converted explicitly. Nothing in this script reads the
# machine's own zone, so two admins in two countries get the same output.
if ($Zone) {
$tz = [TimeZoneInfo]::FindSystemTimeZoneById($Zone)
$inZone = [TimeZoneInfo]::ConvertTimeFromUtc($utc, $tz)
''
'{0,-24}{1}' -f 'Zone requested', $Zone
'{0,-24}{1}' -f 'Local in that zone', $inZone.ToString('yyyy-MM-dd HH:mm:ss', $invariant)
'{0,-24}{1}' -f 'Offset in force', $tz.GetUtcOffset($utc)
'{0,-24}{1}' -f 'Daylight saving then', $tz.IsDaylightSavingTime($utc)
}
Run it with the zone parameter as well to see the second block. You should see nine lines describing the instant, then four more for the zone, with the offset that was in force at that instant rather than the zone base offset.
.\Convert-WindowsTimestamp.ps1 -Value 134176302000000000 -From FileTime -Zone 'W. Europe Standard Time'
Input value 134176302000000000
Read as FileTime
Kind of the result Utc
UTC 2026-03-10 15:30:00
ISO 8601 round trip 2026-03-10T15:30:00.0000000+00:00
File time 134176302000000000
Unix seconds 1773156600
Unix milliseconds 1773156600000
DateTime ticks 639087534000000000
Zone requested W. Europe Standard Time
Local in that zone 2026-03-10 16:30:00
Offset in force 01:00:00
Daylight saving then False
Hidden gems
Three things that are not obvious from the documentation and save real time.
The epoch as a file time is a recognisable number. 116444736000000000 is 1 January 1970 expressed as a file time. If it turns up in an attribute or a report, something converted a zero or an empty value through an epoch conversion. The same applies to any date in 1601: a timestamp in that year is almost never data, it is an unconverted zero.
A DateTimeOffset costs nothing and removes a whole class of bug. It carries the instant together with the offset it was recorded with, two values written in different offsets for the same instant compare as equal, and its subtraction is documented to take the offset into account. Where a value crosses a machine, a file or an API boundary, there is no reason to use a DateTime instead.
Get-Date can format a value without converting it. (Get-Date).ToString('o') gives you the round-trip form with the offset already in it, which is a good default for a log line: it is unambiguous, it sorts as text in the same order as it sorts as time, and anything can parse it.
Where this matters
- Stale account cleanup. An age-sorted report puts the rows whose timestamp was never a time at the top, because an unconverted zero is older than any real logon, so the first names on the list are the ones the report is wrong about.
- Account expiry audits. Two different values mean never expires, so a filter that handles one of them reports half the population backwards.
- Incident correlation. Subtracting a UTC log timestamp from a wall clock one can reverse the order of two events, which points the investigation at the wrong cause.
- Evidence from removable media. Timestamps from a FAT formatted stick were written in the local time of an unknown machine, which is worth stating in the report rather than silently converting.
- Log pipelines that cross versions. A script that stamps records with the Unix seconds switch produces two different conventions either side of a shell upgrade, inside one dataset, with nothing marking the change.
- Scheduled work around a transition date. One local hour does not exist in spring and one happens twice in autumn, so a job pinned to a local wall clock is either skipped or run twice once a year.
Tips and limitations
- A file time is UTC by definition. If a value needs a zone applied to make sense, it was not a file time.
FromFileTimeUtcaccepts zero and rejects a negative value, so a report can be wrong about an empty field without ever seeing an exception.- The highest file time .NET accepts is
2650467743999999999. Anything larger is a flag or a corrupt value, and9223372036854775807is specifically documented as one inaccountExpires. - Set
Kindat the point a value enters your script, not at the point it leaves. By the time it leaves, the information needed to set it correctly is gone. - Neither subtraction nor comparison consults
Kind. ThreeDateTimevalues with the same wall clock and three different kinds all compare as equal, so two values that look like a matched pair can be an offset apart. - None of this needs the RSAT Active Directory module. Reading and converting an exported attribute value is arithmetic, and the conversion can be tested on a machine with no domain at all.
- A zone identifier containing the word Standard does not mean standard time is in force. Ask the zone for the offset at the instant you care about.
- Scripts that must run on both Windows PowerShell 5.1 and PowerShell 7 should avoid
-UFormat %sand-UnixTimeSecondsentirely, and call the .NET methods, which behave identically on both.
Removing the lab
The last script enumerates exactly what the page created, reports whether each file is present, and carries the removal line commented out so that reading the list is not the same as deleting it.
Save this as Remove-TimeLab.ps1 in C:\timelab and run it with .\Remove-TimeLab.ps1.
# Remove-TimeLab.ps1
# Removes everything the page created. Nothing was installed, no module was
# imported from a gallery, no registry key was written and no directory object
# was touched: the whole lab is thirteen scripts plus the sample export they
# produce, all in one folder.
$expected = @(
'Show-TimeOrigins.ps1'
'Show-FileTimeAsymmetry.ps1'
'Show-KindSubtraction.ps1'
'Test-FileTimeSentinels.ps1'
'Convert-ADTimestamp.ps1'
'New-TimestampFixture.ps1'
'Get-TimestampReport.ps1'
'Show-SystemFlags.ps1'
'Show-UnixSeconds.ps1'
'Show-CultureParsing.ps1'
'Show-DstConversion.ps1'
'Convert-WindowsTimestamp.ps1'
'ad-timestamps.csv'
'Remove-TimeLab.ps1'
)
'{0,-34}{1}' -f 'Files this page creates', $expected.Length
'{0,-34}{1}' -f 'Present in this folder', @(Get-ChildItem -Path $PSScriptRoot -File | Where-Object { $expected -contains $_.Name }).Length
'{0,-34}{1}' -f 'Other files in this folder', @(Get-ChildItem -Path $PSScriptRoot -File | Where-Object { $expected -notcontains $_.Name }).Length
''
foreach ($f in $expected) {
$p = Join-Path $PSScriptRoot $f
'{0,-34}{1}' -f $f, (Test-Path -Path $p)
}
''
# Uncomment the next line to delete them. It removes only the files listed above,
# so anything else in the folder is left alone.
# $expected | ForEach-Object { Remove-Item -Path (Join-Path $PSScriptRoot $_) -ErrorAction SilentlyContinue }
'{0,-34}{1}' -f 'Removal line is commented out', $true
You should see the file count, how many of them are present, how many other files are in the folder, then one line per file. Nothing was installed, nothing was registered, no module was downloaded and no directory object was read or written, so deleting the folder leaves the machine as it was.
Files this page creates 14
Present in this folder 14
Other files in this folder 0
Show-TimeOrigins.ps1 True
Show-FileTimeAsymmetry.ps1 True
Show-KindSubtraction.ps1 True
Test-FileTimeSentinels.ps1 True
Convert-ADTimestamp.ps1 True
New-TimestampFixture.ps1 True
Get-TimestampReport.ps1 True
Show-SystemFlags.ps1 True
Show-UnixSeconds.ps1 True
Show-CultureParsing.ps1 True
Show-DstConversion.ps1 True
Convert-WindowsTimestamp.ps1 True
ad-timestamps.csv True
Remove-TimeLab.ps1 True
Removal line is commented out True
Remove-Item line only when the listing above it matches what you expect. It removes the fourteen names in the list and nothing else, so anything of your own in that folder survives, but a folder you reused for something else is worth checking first.
Official documentation
- File Times: the definition of a file time, and what NTFS and FAT each store
- DateTime.ToFileTime: treats an unspecified kind as a local time
- DateTime.ToFileTimeUtc: treats an unspecified kind as a UTC time
- DateTime.FromFileTimeUtc: the accepted range and the exception outside it
- DateTime.ToUniversalTime: the table of results for each kind
- DateTime.ToLocalTime: the same table, with the opposite assumption
- DateTime.Subtract: states that the kind is not considered
- DateTimeOffset.FromUnixTimeSeconds: the two documented range limits
- Get-Date in PowerShell 7: the UnixTimeSeconds parameter and the current specifier table
- Get-Date in Windows PowerShell 5.1: the note on what the seconds specifier gets wrong
- Last-Logon-Timestamp attribute: the update rule and the randomised first write
- Last-Logon attribute: the meaning of a zero value
- Account-Expires attribute: the two values that mean never
- Pwd-Last-Set attribute: what a zero obliges the user to do
- System-Flags attribute: the bit that stops an attribute replicating
- ms-DS-Logon-Time-Sync-Interval attribute: the granularity of the replicated timestamp
Related tools
- Unix timestamp converter: converts epoch seconds or milliseconds to a readable date and back, in the browser, when a single value is all you need.
- JWT decoder: reads the claims in a token, where the issue and expiry times are Unix seconds and the same range rules apply.
Related guides
- Diagnose and fix domain time synchronisation with w32tm: the clock itself rather than the value, for when the timestamps are wrong at the source.
- Read Windows event logs with PowerShell and Get-WinEvent: where most of the timestamps you will convert come from.
- Check certificate expiry and chain status from PowerShell: another place where a date comparison decides a verdict, and where the kind of the value matters.
- Convert objects to and from JSON in PowerShell: what happens to a timestamp on the way into a JSON document and back out of it.
- Query Active Directory from the command line with dsquery and dsget: the inactive switch and why it misses accounts that never logged on.
- Check and force Active Directory replication with repadmin: for when one domain controller holds a different value from another.
Five cheat sheets, one PDF
Subnet masks, PowerShell, Linux commands, HTTP status codes and the ESXi command line - one page each, free to keep. Leave an address and it arrives in a minute.