Wes Ellis./ a personal notebook
Technology. Stories. Side projects.
A few things worth writing down.
← Back to Script Library

SCRIPT LIBRARY · POWERSHELL

Check Whether Two Files (or Two Folders) Are Really Identical

Compare files, whole folder trees, or a download against its published checksum with Get-FileHash, and get back a clear Match or Different for every file.

AT A GLANCECompare-FileHash.ps1
What it does
Hashes files with Get-FileHash and compares them file-to-file, folder-to-folder (matched by relative path), or against a hash you were given. Files with different sizes are flagged without being hashed.
Requires
  • PowerShell 7+ or Windows PowerShell 5.1
  • No modules
Permissions
Read access to the files. Nothing is changed.
Runs on
Windows, macOS, Linux (PowerShell 7)
Tested
Parse-checked and run against test files and folder trees in PowerShell 7.4, covering matches, content changes, size changes, one-sided files and expected-hash checks

"Are these the same file?" comes up more than you'd think. Did the backup actually copy everything? Is the ISO on the share the one you downloaded, or the one from last year with the same name? Did that config file on server two drift from server one?

Names, sizes and dates can all lie. A hash doesn't. Get-FileHash reads every byte and boils it down to a fingerprint, and if two fingerprints match, the files are the same.

The first version of this post compared two hard-coded paths with MD5 and printed a green or red line. This one takes parameters, defaults to SHA-256, compares whole folders as well as single files, checks downloads against a published hash, and returns objects you can filter and export. (Also: the old post was tagged Microsoft Graph for some reason. It has nothing to do with Graph.)

Compare-FileHash.ps1Download
<#
.SYNOPSIS
    Tells you whether two files, or two whole folders, really contain the same bytes.
.DESCRIPTION
    Uses Get-FileHash to compare a file against another file, a folder against another folder
    (matched by relative path), or a file against a hash you were given, like the SHA-256 on a
    download page. Files with different sizes are reported as different without hashing them.
    Read-only: nothing is changed.
.PARAMETER ReferencePath
    The file or folder you trust.
.PARAMETER DifferencePath
    The file or folder you're checking against it.
.PARAMETER ExpectedHash
    A published hash to check ReferencePath against, instead of a second file.
.PARAMETER Algorithm
    SHA256 (default), SHA384, SHA512, SHA1 or MD5.
.PARAMETER Recurse
    When comparing folders, include subfolders.
.PARAMETER DifferencesOnly
    When comparing folders, leave matching files out of the results.
.EXAMPLE
    .\Compare-FileHash.ps1 -ReferencePath .\setup.iso -ExpectedHash 3F2A...9C1D
.EXAMPLE
    .\Compare-FileHash.ps1 -ReferencePath D:\Photos -DifferencePath \\nas01\Backup\Photos -Recurse -DifferencesOnly
#>
[CmdletBinding(DefaultParameterSetName = 'Compare')]
param(
    [Parameter(Mandatory, Position = 0)][ValidateScript({ Test-Path -LiteralPath $_ })][string]$ReferencePath,
    [Parameter(Mandatory, Position = 1, ParameterSetName = 'Compare')][ValidateScript({ Test-Path -LiteralPath $_ })][string]$DifferencePath,
    [Parameter(Mandatory, ParameterSetName = 'Expected')][ValidatePattern('^[0-9A-Fa-f]{32,128}$')][string]$ExpectedHash,
    [ValidateSet('SHA256', 'SHA384', 'SHA512', 'SHA1', 'MD5')][string]$Algorithm = 'SHA256',
    [switch]$Recurse,
    [switch]$DifferencesOnly
)

function Get-Hash([string]$File) { (Get-FileHash -LiteralPath $File -Algorithm $Algorithm).Hash }

function Compare-OneFile([string]$Name, [IO.FileInfo]$Ref, [IO.FileInfo]$Dif) {
    $out = [ordered]@{ Name = $Name; Status = $null; ReferenceHash = $null; DifferenceHash = $null; Algorithm = $Algorithm }
    if (-not $Dif) { $out.Status = 'OnlyInReference' }
    elseif (-not $Ref) { $out.Status = 'OnlyInDifference' }
    elseif ($Ref.Length -ne $Dif.Length) { $out.Status = 'Different'; $out.ReferenceHash = '(size differs)' }
    else {
        try {
            $out.ReferenceHash  = Get-Hash $Ref.FullName
            $out.DifferenceHash = Get-Hash $Dif.FullName
            $out.Status = if ($out.ReferenceHash -eq $out.DifferenceHash) { 'Match' } else { 'Different' }
        }
        catch {
            $out.Status = "Error: $($_.Exception.Message)"
        }
    }
    [pscustomobject]$out
}

$refItem = Get-Item -LiteralPath $ReferencePath

if ($PSCmdlet.ParameterSetName -eq 'Expected') {
    if ($refItem.PSIsContainer) { throw '-ExpectedHash works on a single file, not a folder.' }
    $actual = Get-Hash $refItem.FullName
    return [pscustomobject]@{
        Name          = $refItem.Name
        Status        = if ($actual -eq $ExpectedHash.Trim()) { 'Match' } else { 'Different' }
        ReferenceHash = $actual
        Expected      = $ExpectedHash.ToUpper()
        Algorithm     = $Algorithm
    }
}

$difItem = Get-Item -LiteralPath $DifferencePath
if ($refItem.PSIsContainer -ne $difItem.PSIsContainer) { throw 'Compare a file with a file, or a folder with a folder.' }

if (-not $refItem.PSIsContainer) {
    return Compare-OneFile $refItem.Name $refItem $difItem
}

# Folders: index both sides by path relative to their root, then walk the union of names.
$index = foreach ($root in $refItem, $difItem) {
    $table = @{}
    foreach ($f in Get-ChildItem -LiteralPath $root.FullName -File -Recurse:$Recurse -Force) {
        $table[$f.FullName.Substring($root.FullName.TrimEnd('\', '/').Length + 1)] = $f
    }
    , $table
}
$refFiles, $difFiles = $index
Write-Verbose "Reference: $($refFiles.Count) files. Difference: $($difFiles.Count) files."

$names = @($refFiles.Keys) + @($difFiles.Keys) | Sort-Object -Unique
foreach ($name in $names) {
    $row = Compare-OneFile $name $refFiles[$name] $difFiles[$name]
    if (-not ($DifferencesOnly -and $row.Status -eq 'Match')) { $row }
}

Parameters

ParameterTypeDefaultWhat it's for
-ReferencePathstring—The file or folder you trust. First positional parameter.
-DifferencePathstring—The file or folder you're checking. Second positional parameter. Must be the same kind of thing as ReferencePath.
-ExpectedHashstring—A hash to check ReferencePath against instead of another file, like the SHA-256 on a vendor's download page. Case doesn't matter.
-AlgorithmstringSHA256SHA256, SHA384, SHA512, SHA1 or MD5. Match whatever the publisher used when you're checking a download.
-Recurseswitch—Include subfolders when comparing folders.
-DifferencesOnlyswitch—Hide files that match, so you only see what's wrong.

Run it

Two files, same name, different servers.

.\Compare-FileHash.ps1 \\app01\c$\App\appsettings.json \\app02\c$\App\appsettings.json

Check a download against the hash on the vendor's site.

.\Compare-FileHash.ps1 .\installer.msi -ExpectedHash 9f2c4e8a1b7d3c6e5f4a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e

Did the backup really get everything?

.\Compare-FileHash.ps1 D:\Photos \\nas01\Backup\Photos -Recurse -DifferencesOnly

Save a report of every difference.

.\Compare-FileHash.ps1 D:\Site \\web02\d$\Site -Recurse -DifferencesOnly | Export-Csv .\site-drift.csv -NoTypeInformation

What you'll see

Example outputvalues are illustrative
Name                    Status           ReferenceHash           DifferenceHash          Algorithm
----                    ------           -------------           --------------          ---------
2025\IMG_0142.jpg       Different        (size differs)                                  SHA256
2025\IMG_0188.jpg       Different        3E1B0C...A94F           7D22F9...0B11           SHA256
2026\IMG_0007.jpg       OnlyInReference                                                  SHA256
Thumbs.db               OnlyInDifference                                                 SHA256

How it works

  1. Pick a mode from the parameters. With -ExpectedHash, it hashes one file and compares. With two paths, it checks that both are files or both are folders.
  2. File against file. Sizes first. If they differ, the files are different and there's no need to read them. If they're the same size, both get hashed and compared.
  3. Folder against folder. Both trees are indexed by path relative to their root, so D:\Photos\2025\a.jpg lines up with \\nas01\Backup\Photos\2025\a.jpg. Then it walks every name that appears on either side. Files on only one side are reported as OnlyInReference or OnlyInDifference.
  4. Handle errors per file. A file that can't be read (locked, access denied) gets an Error: status and the comparison carries on.
  5. Return objects. Every row has the name, status, both hashes and the algorithm, so you can sort, filter, or export them.

Take it further

  • Save a baseline. Run Get-ChildItem -Recurse -File | Get-FileHash on a known-good folder once and export it to CSV. Later, compare against the CSV to spot anything that changed, which is a poor man's file integrity monitor.
  • Check after a migration. If you've just moved data with robocopy in copy mode, run this between the two sides before you delete the originals.
  • Find duplicates. Group Get-FileHash output by Hash and any group with more than one file is a set of duplicates, whatever they're named.

Things that'll trip you up

  • Big folders take a while. Every same-sized pair gets read in full, on both sides. Comparing a few hundred GB across the network is a lunch-break job, not a coffee-break one. The size check up front helps a lot when files really have changed.
  • MD5 and SHA1 are fine for "did it copy right?", not for security. They're broken against someone deliberately forging a match. For checking downloads or anything that matters, stick with the SHA-256 default.
  • Same content, different hash? Check line endings. A text file that went through Git or an FTP transfer in ASCII mode can come back with CRLF swapped for LF. It looks identical in an editor but it's a different file as far as a hash is concerned.
  • Names are matched case-insensitively. That's right for Windows. If you're comparing Linux folders where Report.txt and report.txt are two different files, they'll get mixed up.
  • Only file contents are compared. Timestamps, permissions and alternate data streams aren't part of the hash. Two files can match here and still have different ACLs.