DEV Community

Cover image for PowerShell Get-ChildItem -Filter vs -Include: The Real Difference (and Why -Include Returns Nothing)
arnostorg
arnostorg

Posted on

PowerShell Get-ChildItem -Filter vs -Include: The Real Difference (and Why -Include Returns Nothing)

Get-ChildItem -Path C:\logs -Include *.log looks correct, runs without an error, and returns nothing. Swap -Include for -Filter and the files show up. Same cmdlet, same pattern, different result.

The two parameters run at different stages. -Filter goes to the provider, which skips non-matching files while it enumerates. -Include and -Exclude run inside PowerShell after the provider has already returned items, and only under certain path conditions. Once you know which stage you're working with, the "returns nothing" bug and the slow scripts both make sense.

Every command below was run in PowerShell 7.4.6 on Linux. I point out where Windows or Windows PowerShell 5.1 behaves differently, based on Microsoft's docs, because I couldn't test those here.

Build a small lab

Run this once. It creates a throwaway folder in your temp directory with a few tricky names: a folder whose name contains .log, an uppercase .LOG file, a .log.bak file, and the classic .htm / .html pair.

$root = Join-Path ([IO.Path]::GetTempPath()) 'gci-lab'
Remove-Item $root -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path "$root/logs/archive", "$root/logs/old.log.d" -Force | Out-Null
'a' | Set-Content "$root/logs/app.log"
'b' | Set-Content "$root/logs/web.log"
'c' | Set-Content "$root/logs/notes.txt"
'd' | Set-Content "$root/logs/error.LOG"
'e' | Set-Content "$root/logs/archive/2026-09.log"
'f' | Set-Content "$root/logs/archive/2026-09.log.bak"
'g' | Set-Content "$root/logs/report.html"
'h' | Set-Content "$root/logs/report.htm"
Set-Location $root
Enter fullscreen mode Exit fullscreen mode

I pipe results to ForEach-Object Name to keep the output short. I don't use -Name because it changes how -Include behaves (more on that below).

The "returns nothing" gotcha

Get-ChildItem -Path ./logs -Filter *.log | ForEach-Object Name
Enter fullscreen mode Exit fullscreen mode
app.log
error.LOG
web.log
Enter fullscreen mode Exit fullscreen mode
@(Get-ChildItem -Path ./logs -Include *.log).Count
Enter fullscreen mode Exit fullscreen mode
0
Enter fullscreen mode Exit fullscreen mode

This isn't a bug, and it isn't only a Windows PowerShell 5.1 problem. It happened in pwsh 7.4. The Get-ChildItem docs say that -Include "is effective only when the command includes the contents of an item, such as C:\Windows\*". A plain -Path ./logs refers to the folder itself, not its contents, so nothing passes the include check.

You can fix it in any of three ways:

# 1. Wildcard path: "the contents of logs"
Get-ChildItem -Path ./logs/* -Include *.log | ForEach-Object Name

# 2. Add -Recurse (trailing * becomes optional)
Get-ChildItem -Path ./logs -Recurse -Include *.log | ForEach-Object Name

# 3. -LiteralPath (worked in pwsh 7.4 without * or -Recurse)
Get-ChildItem -LiteralPath ./logs -Include *.log | ForEach-Object Name
Enter fullscreen mode Exit fullscreen mode
app.log
error.LOG
web.log

2026-09.log
app.log
error.LOG
web.log

app.log
error.LOG
web.log
Enter fullscreen mode Exit fullscreen mode

The -Recurse version also goes into archive/. Keep that in mind if you add -Recurse only to make -Include work: you also get every subfolder.

The third fix is something I observed in pwsh 7.4, not documented behavior. On Windows PowerShell 5.1, use -Path ...\* or -Recurse. Those two are the forms the docs describe.

Bonus trap: -Name takes a different code path

"without -Name: " + @(Get-ChildItem -Path ./logs -Include *.log).Count
"with -Name:    " + @(Get-ChildItem -Path ./logs -Include *.log -Name).Count
"LiteralPath -Include *.txt -Name: " + @(Get-ChildItem -LiteralPath ./logs -Include *.txt -Name).Count
Enter fullscreen mode Exit fullscreen mode
without -Name: 0
with -Name:    3
LiteralPath -Include *.txt -Name: 8
Enter fullscreen mode Exit fullscreen mode

With -Name added, the "returns nothing" command returned 3 files. The -LiteralPath command that correctly returned only notes.txt returned all 8 items once -Name was added, so the include pattern was ignored. If you test an -Include command with -Name and then remove -Name in your script, you may get different results. Filter on objects first and select Name at the end.

What -Filter can and can't do

The docs describe it this way: the filter string is passed to the .NET file-enumeration API, it accepts one string, and only * and ? work as wildcards.

You can't pass more than one pattern:

Get-ChildItem -Path ./logs -Filter *.log, *.txt
Enter fullscreen mode Exit fullscreen mode
Cannot convert 'System.Object[]' to the type 'System.String' required by parameter 'Filter'. Specified method is not supported.
Enter fullscreen mode Exit fullscreen mode

Bracket ranges also don't work. You don't get an error, just no results:

@(Get-ChildItem -Path ./logs -Filter '[aw]*.log').Count
Get-ChildItem -Path ./logs/* -Include '[aw]*.log' | ForEach-Object Name
Enter fullscreen mode Exit fullscreen mode
0
app.log
web.log
Enter fullscreen mode Exit fullscreen mode

-Include uses PowerShell wildcards (about_Wildcards), so [aw] matches names starting with a or w. -Filter didn't treat [aw] as a character range and found nothing.

Also note that -Filter *.log returned error.LOG on Linux, where the file system is case-sensitive. I didn't expect that.

Windows-only quirks

On Windows, -Filter uses the operating system's file name matching rules, and the Directory.GetFiles docs list two old quirks:

  • Three-character extensions match longer extensions. On .NET Framework (the runtime under Windows PowerShell 5.1), *.xls also returns book.xlsx, so *.htm also returns .html. The docs label this ".NET Framework only". On .NET 5+ it returns only the exact extension. In my pwsh 7.4 lab, -Filter *.htm returned only report.htm.
  • 8.3 short names are matched too. A pattern like *1*.txt can return longfilename.txt because its short name is LONGFI~1.TXT.

If a 5.1 script that uses -Filter *.htm deletes or moves files, run it on a test folder first. It may also catch .html files.

-Exclude: two behaviors that catch people

Unlike -Include, -Exclude works on a plain folder path:

Get-ChildItem -Path ./logs -Exclude *.log | ForEach-Object Name
Enter fullscreen mode Exit fullscreen mode
archive
old.log.d
notes.txt
report.htm
report.html
Enter fullscreen mode Exit fullscreen mode

But it only compares item names. It doesn't skip a subfolder during recursion:

Get-ChildItem -Path ./logs -Recurse -File -Exclude archive | ForEach-Object Name
Enter fullscreen mode Exit fullscreen mode
2026-09.log
2026-09.log.bak
app.log
error.LOG
notes.txt
report.htm
report.html
web.log
Enter fullscreen mode Exit fullscreen mode

The archive folder itself was excluded, but both files inside it were still returned. To skip a subfolder, filter on the path: Where-Object FullName -notlike '*/archive/*' (use \archive\ on Windows).

The second one: the docs say exclusions are applied after inclusions, and neither one cares whether an item is a file or a folder:

Get-ChildItem -Path ./logs -Recurse -Include *.log* -Exclude *.bak | Select-Object Mode, Name
Enter fullscreen mode Exit fullscreen mode
Mode  Name
----  ----
----- 2026-09.log
d---- old.log.d
----- app.log
----- error.LOG
----- web.log
Enter fullscreen mode Exit fullscreen mode

The folder old.log.d matches *.log*, so it's included. Adding -File removes it:

Get-ChildItem -Path ./logs -Recurse -File -Include *.log* -Exclude *.bak | ForEach-Object Name
Enter fullscreen mode Exit fullscreen mode
2026-09.log
app.log
error.LOG
web.log
Enter fullscreen mode Exit fullscreen mode

-File and -Directory are FileSystem-provider switches. Use one whenever your pattern could match both files and folders. -Filter has the same problem: -Filter *.log* -Recurse also returns old.log.d.

How much faster is -Filter?

The Measure-Command docs show -Filter being much faster on C:\Windows. Here is the same comparison on a generated tree with 20,000 files, so you can rerun it yourself:

$root = Join-Path ([IO.Path]::GetTempPath()) 'gci-perf'
Remove-Item $root -Recurse -Force -ErrorAction SilentlyContinue
$ext = '.log', '.txt', '.json', '.csv', '.tmp'
foreach ($d in 1..200) {
    $dir = New-Item -ItemType Directory -Path (Join-Path $root "app$d") -Force
    foreach ($f in 1..100) {
        [IO.File]::WriteAllText((Join-Path $dir "file$f$($ext[$f % 5])"), 'x')
    }
}
"Files created: " + (Get-ChildItem $root -Recurse -File).Count

# warm-up so the first run doesn't pay the cold cache cost
$null = Get-ChildItem $root -Recurse -File

$tests = [ordered]@{
    'Filter *.log'             = { Get-ChildItem -Path $root -Recurse -File -Filter *.log }
    'Include *.log'            = { Get-ChildItem -Path $root -Recurse -File -Include *.log }
    'Where-Object Extension'   = { Get-ChildItem -Path $root -Recurse -File | Where-Object Extension -eq '.log' }
    'Include *.log, *.csv'     = { Get-ChildItem -Path $root -Recurse -File -Include *.log, *.csv }
    'Filter twice (log + csv)' = { '*.log', '*.csv' | ForEach-Object { Get-ChildItem -Path $root -Recurse -File -Filter $_ } }
}

foreach ($name in $tests.Keys) {
    $runs = 1..5 | ForEach-Object { (Measure-Command $tests[$name]).TotalMilliseconds }
    [pscustomobject]@{
        Test     = $name
        Matches  = @(& $tests[$name]).Count
        MedianMs = [int]($runs | Sort-Object)[2]
    }
}
Enter fullscreen mode Exit fullscreen mode
Files created: 20000

Test                     Matches MedianMs
----                     ------- --------
Filter *.log                4000       50
Include *.log               4000      428
Where-Object Extension      4000      219
Include *.log, *.csv        8000      487
Filter twice (log + csv)    8000       97
Enter fullscreen mode Exit fullscreen mode

All runs were on the same 8-core Linux box with a warm cache, median of 5. Some results surprised me:

  • -Filter was about 8× faster than -Include for the same 4,000 files.
  • -Include was slower than Get-ChildItem | Where-Object. You might expect a built-in parameter to beat a pipeline filter. Here it took twice as long.
  • Running -Filter twice beat one -Include with two patterns (97 ms vs 487 ms), even though it walks the tree twice.

Expect different absolute numbers on Windows, network shares, and cold caches, but the ranking should be the same, because -Filter skips non-matching files before PowerShell creates objects for them.

The pattern I use now

One name pattern → -Filter, plus -File or -Directory:

Get-ChildItem -Path ./logs -Recurse -File -Filter *.log
Enter fullscreen mode Exit fullscreen mode

Several patterns or [a-z] ranges → -Include with -Recurse or a \* path. When speed matters, narrow first with -Filter and refine with -Include. The two can be combined:

Get-ChildItem -Path ./logs -Recurse -File -Filter *.log -Include 'a*', 'w*' | ForEach-Object Name
Enter fullscreen mode Exit fullscreen mode
app.log
web.log
Enter fullscreen mode Exit fullscreen mode

Anything based on size, age, or path → Where-Object after a -Filter:

(Get-Item ./logs/app.log).LastWriteTime = (Get-Date).AddDays(-40)
Set-Content ./logs/web.log -Value ('x' * 2048)

$cutoff = (Get-Date).AddDays(-30)
Get-ChildItem -Path ./logs -Recurse -File -Filter *.log |
    Where-Object { $_.Length -gt 1KB -or $_.LastWriteTime -lt $cutoff } |
    Select-Object Name, Length, @{ n = 'AgeDays'; e = { ((Get-Date) - $_.LastWriteTime).Days } }
Enter fullscreen mode Exit fullscreen mode
Name    Length AgeDays
----    ------ -------
app.log      2      40
web.log   2049       0
Enter fullscreen mode Exit fullscreen mode

If you want to try these commands without setting up a lab folder, you can practice PowerShell file commands in a free browser terminal on CMD Master. It also covers CMD and Bash.

Quick reference

You want Use Watch out for
One pattern, fast -Filter *.log Single string, * and ? only; on 5.1, *.htm also matches .html
Several patterns -Include *.log, *.csv Needs -Recurse or path\*, otherwise 0 results
Remove matches -Exclude *.bak Compares names only, still descends into excluded folders
Files only / folders only -File / -Directory *.log* also matches a folder named old.log.d
Size, date, path logic Where-Object { ... } after the pipe Narrow with -Filter first
Names only ForEach-Object Name after the pipe -Name changes how -Include behaves

Have you run into any of these, especially the -Name difference, on Windows PowerShell 5.1? I'd like to compare results.

Top comments (0)