Querying EVTX with PowerShell: Get-WinEvent, FilterHashtable and XPath
A practical guide to reading .evtx files with PowerShell — Get-WinEvent vs Get-EventLog, the fast FilterHashtable path, XPath filters for EventData fields, FilterXml, and the limitations that trip people up.
PowerShell is on every Windows box, which makes Get-WinEvent the parser you always have. Used well it queries a saved .evtx in milliseconds; used badly it loads a million objects into memory and crawls. This is the practical version: the patterns that are fast and the gotchas that aren't. For a no-install, in-browser alternative, there is also the parser on this site.
Get-WinEvent, not Get-EventLog
Get-EventLog is the legacy cmdlet — it only reads the classic logs (Application/System/Security) and is slow. Get-WinEvent reads any channel and any saved .evtx file, understands the modern schema, and supports server-side filtering. Use it.
Read a saved file:
Get-WinEvent -Path .\Security.evtx -MaxEvents 50
Read a live channel:
Get-WinEvent -LogName Security -MaxEvents 50
The cardinal rule: filter at the source
The single biggest mistake is filtering with Where-Object after retrieval:
# SLOW — deserialises every event, then filters
Get-WinEvent -Path .\Security.evtx | Where-Object { $_.Id -eq 4624 }
Get-WinEvent can filter before materialising objects, via -FilterHashtable (or XPath). That is orders of magnitude faster on big logs:
# FAST — filtering pushed down to the log reader
Get-WinEvent -FilterHashtable @{
Path = '.\Security.evtx'
Id = 4624
StartTime = (Get-Date).AddDays(-1)
}
-FilterHashtable keys you'll use most: Path or LogName, Id, ProviderName, Level, StartTime, EndTime, and Data (matches values, though it's positional and blunt). Combine keys with implicit AND.
Getting at EventData fields
The hashtable can't target a named EventData field like TargetUserName. Two ways to do it.
Property access after a cheap ID filter — readable, fine for moderate volumes:
Get-WinEvent -FilterHashtable @{Path='.\Security.evtx'; Id=4624} |
ForEach-Object {
$x = [xml]$_.ToXml()
[pscustomobject]@{
Time = $_.TimeCreated
User = ($x.Event.EventData.Data | Where-Object Name -eq 'TargetUserName').'#text'
Type = ($x.Event.EventData.Data | Where-Object Name -eq 'LogonType').'#text'
Src = ($x.Event.EventData.Data | Where-Object Name -eq 'IpAddress').'#text'
}
}
XPath, pushed down to the reader — fastest, but with caveats below:
# All 4624s
Get-WinEvent -Path .\Security.evtx `
-FilterXPath "*[System[EventID=4624]]"
# 4624 where TargetUserName = 'alice'
Get-WinEvent -Path .\Security.evtx -FilterXPath @"
*[System[EventID=4624]] and *[EventData[Data[@Name='TargetUserName']='alice']]
"@
This is the same XPath dialect Event Viewer's custom views use — the queries transfer both ways.
XPath limitations that bite
Windows implements a limited XPath 1.0. The trap people hit: string functions are not supported in the event-log filter. So this errors:
# FAILS — contains() / starts-with() are not allowed here
Get-WinEvent -Path .\Security.evtx `
-FilterXPath "*[EventData[Data[contains(.,'admin')]]]"
You get exact-match (=), and/or, and positional/attribute predicates — not contains, starts-with, or other string functions. For substring/regex matching, filter to the ID with XPath/hashtable first, then match in PowerShell on the materialised text, or use the in-browser parser's text filter.
FilterXml for complex queries
For multi-clause queries it's cleaner to build the XML once (export it from a Event Viewer custom view, even) and pass it via -FilterXml:
$q = @"
<QueryList>
<Query Id="0" Path="file://./Security.evtx">
<Select Path="file://./Security.evtx">
*[System[(EventID=4624 or EventID=4625) and TimeCreated[timediff(@SystemTime) <= 86400000]]]
</Select>
</Query>
</QueryList>
"@
Get-WinEvent -FilterXml ([xml]$q)
A few recipes
# Failed logons grouped by source IP
Get-WinEvent -FilterHashtable @{Path='.\Security.evtx'; Id=4625} |
ForEach-Object { ([xml]$_.ToXml()).Event.EventData.Data |
Where-Object Name -eq 'IpAddress' | Select-Object -Exp '#text' } |
Group-Object | Sort-Object Count -Descending
# Scheduled tasks created (4698) with their command
Get-WinEvent -FilterHashtable @{Path='.\Security.evtx'; Id=4698} |
ForEach-Object { ([xml]$_.ToXml()).Event.EventData.Data |
Where-Object Name -eq 'TaskContent' | Select-Object -Exp '#text' }
(The fields these pull are explained in the logon and scheduled-task posts.)
Gotchas worth knowing
-Oldest. For Analytic/Debug and some.evtxfiles you must add-OldestorGet-WinEventerrors.- No-match is a terminating error. A filter that matches nothing throws "No events were found"; wrap in
try/catchor-ErrorAction SilentlyContinuein scripts. - Time zone.
TimeCreatedis rendered in local time by PowerShell, but the underlying value is UTC FILETIME — be explicit when correlating across machines. - Speed. Always filter with
-FilterHashtable/XPath; reserveWhere-Objectfor the small set that survives.
When the query outgrows ad-hoc PowerShell — detection rules across many files — that's the job for Sigma with Chainsaw or Hayabusa.