DEV Community

Cover image for PowerShell Compare-Object: Find Missing and Changed Files Between Two Folders
arnostorg
arnostorg

Posted on

PowerShell Compare-Object: Find Missing and Changed Files Between Two Folders

You copied a folder to a backup drive, a USB stick, or a new laptop. Now you need to know one thing: is the copy actually the same? Not "roughly the same size". The same files, with the same content.

Windows Explorer can't answer that. Compare-Object plus Get-FileHash can, in a short script you can rerun any time.

The one-liner: which file names differ?

Start cheap. Compare just the file names in two folders:

Compare-Object (Get-ChildItem C:\Photos -Name) (Get-ChildItem D:\Backup\Photos -Name)
Enter fullscreen mode Exit fullscreen mode
InputObject        SideIndicator
-----------        -------------
IMG_2041.jpg       =>
IMG_1987.jpg       <=
Enter fullscreen mode Exit fullscreen mode

Reading the SideIndicator column:

  • <= means the item exists only in the first (reference) set, here C:\Photos.
  • => means it exists only in the second (difference) set, the backup.
  • Matching items are hidden. Add -IncludeEqual to see them marked ==.

So IMG_1987.jpg never made it to the backup, and IMG_2041.jpg is only on the backup.

That catches missing files. It does not catch a file that exists on both sides but has different content, like a half-copied video or a document you edited after the backup. For that you need hashes.

The real job: compare names and content, recursively

This script builds a fingerprint for every file (relative path plus SHA256 hash), then compares the two lists:

$left  = (Resolve-Path 'C:\Photos').Path
$right = (Resolve-Path 'D:\Backup\Photos').Path

function Get-FileFingerprint {
    param([string]$Root)
    Get-ChildItem -LiteralPath $Root -Recurse -File | ForEach-Object {
        [pscustomobject]@{
            RelPath = $_.FullName.Substring($Root.Length).TrimStart('\')
            Hash    = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash
        }
    }
}

$a = @(Get-FileFingerprint -Root $left)
$b = @(Get-FileFingerprint -Root $right)

Compare-Object -ReferenceObject $a -DifferenceObject $b -Property RelPath, Hash |
    Sort-Object RelPath
Enter fullscreen mode Exit fullscreen mode

Why each piece matters:

  • RelPath strips the root, so C:\Photos\2025\a.jpg and D:\Backup\Photos\2025\a.jpg both become 2025\a.jpg and can actually match.
  • -Property RelPath, Hash tells Compare-Object an item is only "equal" when both the path and the content hash match.
  • @(...) forces an array even when a folder is empty. More on that below.

Identical files produce no output. What's left is the interesting part:

RelPath                Hash          SideIndicator
-------                ----          -------------
2025\IMG_1987.jpg      7427D152...   <=
2025\trip.mp4          3BB2ABB6...   <=
2025\trip.mp4          73CB3858...   =>
IMG_2041.jpg           A4FB6214...   =>
Enter fullscreen mode Exit fullscreen mode

Notice trip.mp4 shows up twice, once per side with different hashes. That's the signature of a file that exists in both places but changed.

Turn it into a readable report

Two rows per changed file is accurate but annoying. Group by path and label each case:

Compare-Object $a $b -Property RelPath, Hash |
    Group-Object -Property RelPath |
    ForEach-Object {
        $status = if ($_.Count -eq 2) { 'Changed' }
                  elseif ($_.Group[0].SideIndicator -eq '<=') { 'Missing in backup' }
                  else { 'Only in backup' }
        [pscustomobject]@{ Status = $status; File = $_.Name }
    } |
    Sort-Object Status, File
Enter fullscreen mode Exit fullscreen mode
Status             File
------             ----
Changed            2025\trip.mp4
Missing in backup  2025\IMG_1987.jpg
Only in backup     IMG_2041.jpg
Enter fullscreen mode Exit fullscreen mode

Need to send it to someone or keep a record? Pipe the same result to Export-Csv:

... | Export-Csv -Path .\backup-diff.csv -NoTypeInformation
Enter fullscreen mode Exit fullscreen mode

An empty report means the two folders match, file for file and byte for byte.

Gotchas that bite on real folders

1. An empty folder throws a terminating error. If Get-ChildItem finds nothing, it returns $null, and Compare-Object refuses it:

Cannot bind argument to parameter 'ReferenceObject' because it is null.
Enter fullscreen mode Exit fullscreen mode

Wrapping the call in @(...) gives you an empty array instead, and the comparison simply reports every file on the other side as missing. That's why the script above uses $a = @(...).

2. Comparison is case-insensitive by default. Report.docx and report.docx count as the same name, which matches how NTFS behaves. If you're comparing against a Linux share or a Git checkout where case matters, add -CaseSensitive.

3. Hashing big folders is slow, so filter first. Hashing reads every byte. For a 200 GB media folder, compare cheap properties first and only hash what's left:

function Get-QuickFingerprint {
    param([string]$Root)
    Get-ChildItem -LiteralPath $Root -Recurse -File |
        Select-Object @{ n = 'RelPath'; e = { $_.FullName.Substring($Root.Length).TrimStart('\') } }, Length
}

Compare-Object @(Get-QuickFingerprint $left) @(Get-QuickFingerprint $right) -Property RelPath, Length
Enter fullscreen mode Exit fullscreen mode

A different Length proves a file changed. The same length doesn't prove it's identical, so run the hash version when you need certainty.

4. Don't compare timestamps to verify a copy. CreationTime resets when a file is copied, and not every copy tool or sync client preserves LastWriteTime, so a time-based diff can flag thousands of false "changes". Content hashes don't care how the file got there.

5. It's not a line-by-line text diff. Compare-Object (Get-Content a.txt) (Get-Content b.txt) tells you which lines exist on only one side. It won't give line numbers, and a line that merely moved won't be reported. For code, use git diff. For "did this config gain or lose a line?", it's perfect.

Quick reference

Goal Command
Names only Compare-Object (gci A -Name) (gci B -Name)
Show matches too add -IncludeEqual
Compare on chosen properties -Property RelPath, Hash
Case-sensitive names add -CaseSensitive
Content check Get-FileHash -LiteralPath $file -Algorithm SHA256
Save report `\

Practice it before you need it

The time you most need this is right after a backup or migration, which is the worst time to learn the syntax. If you want to get the pipeline habit into your fingers first, practice PowerShell pipelines like Get-ChildItem and Compare-Object in a free browser terminal on CMD Master. No install, and nothing on your real disk gets touched.

Official docs

Related in this series: sort files by size, date, or name with Sort-Object and mirror a folder safely with Robocopy.


Learning Windows command line from scratch? CMD Master's interactive CMD, PowerShell, and Bash lessons are free and run entirely in your browser.

Top comments (0)