SharePointVault ~/scripts/cleanup ⌕ Search 404 pages, error messages, scripts…Ctrl K ★ Star
M↓ README.mdPS Get-FileVersionBloatReport.ps1
sharepoint-vault›scripts›cleanup›Get-FileVersionBloatReport.ps1historyedit on GitHub
● SCRIPTcleanup

Get-FileVersionBloatReport.ps1

Measures how much storage file version history is really consuming, per library and per file.

⬇ downloadview on GitHub ↗242 lines · 9.7 KB
<#
.SYNOPSIS
    Measures how much storage file version history is really consuming, per
    library and per file.

.DESCRIPTION
    Version history is the single most common reason a tenant runs out of
    storage, and it is invisible in every standard report: the library shows
    the size of the current files, the quota counts every version.

    This script sums the size of all versions per file and reports the
    libraries and files where history dwarfs the live content - typically
    large binaries (PSD, MP4, PPTX, Excel with embedded data) in libraries
    that keep unlimited versions.

    READ-ONLY: this script deletes nothing. Use Remove-ExcessFileVersions.ps1
    (same folder) once you have decided what to trim.

    Cost warning: version metadata is read per file. -MinFileSizeMB skips
    small files (which cannot be the problem anyway) and -MaxFilesPerLibrary
    caps the scan; when the cap is hit the script says the library figure is
    a floor rather than pretending it is complete.

    Module note: uses PnP.PowerShell - the official SharePoint Online
    Management Shell cannot reach library content.

.PARAMETER SiteUrl
    One or more full site URLs to scan.

.PARAMETER ClientId
    Client ID of your own Entra ID app registration used by PnP.PowerShell.
    https://pnp.github.io/powershell/articles/registerapplication.html

.PARAMETER MinFileSizeMB
    Ignore files smaller than this. Default 5.

.PARAMETER MaxFilesPerLibrary
    Safety cap per library. Default 2000.

.PARAMETER OutputPath
    CSV file with one row per file. Defaults to a timestamped file.

.PARAMETER SummaryPath
    CSV file with one row per library. Defaults to a timestamped file.

.EXAMPLE
    .\Get-FileVersionBloatReport.ps1 -SiteUrl https://contoso.sharepoint.com/sites/media -ClientId 00000000-0000-0000-0000-000000000000

    Scanning https://contoso.sharepoint.com/sites/media ...
      Documents ...
      Site Assets ...

    Library summary -> .\VersionBloat_Libraries_20260729-143912.csv
    Per-file detail -> .\VersionBloat_Files_20260729-143912.csv
    Version history in scope: 41.7 GB across 318 file(s).

    Ten worst offenders:

    Library   FileName            CurrentMB VersionsMB VersionCount
    -------   --------            --------- ---------- ------------
    Documents Product launch.pptx     84.20    3115.40           37
    Documents Price list.xlsx          6.10     915.00          150

    When a read fails, totals are labelled as a floor rather than an answer:
    INCOMPLETE: 0 site(s) and 12 file(s) could not be read.
    Any total below is a floor, not the answer.

.EXAMPLE
    .\Get-FileVersionBloatReport.ps1 -SiteUrl https://contoso.sharepoint.com/sites/media -ClientId 00000000-0000-0000-0000-000000000000 -MinFileSizeMB 20

.NOTES
    Requires : PnP.PowerShell 2.x or newer (Install-Module PnP.PowerShell)
    Auth     : Interactive (browser) sign-in per site.
    Samples  : scripts/sample-outputs.md - what this prints, from a real run
    Source   : https://github.com/EasyPortal365/sharepoint-vault
#>
#Requires -Modules PnP.PowerShell

[CmdletBinding()]
param(
    [Parameter(Mandatory = $true)]
    [string[]]$SiteUrl,

    [Parameter(Mandatory = $true)]
    [string]$ClientId,

    [ValidateRange(0, 100000)]
    [int]$MinFileSizeMB = 5,

    [ValidateRange(1, [int]::MaxValue)]
    [int]$MaxFilesPerLibrary = 2000,

    [string]$OutputPath  = ".\VersionBloat_Files_$(Get-Date -Format 'yyyyMMdd-HHmmss').csv",

    [string]$SummaryPath = ".\VersionBloat_Libraries_$(Get-Date -Format 'yyyyMMdd-HHmmss').csv"
)

$ErrorActionPreference = 'Stop'
$files     = New-Object System.Collections.Generic.List[object]
$summaries = New-Object System.Collections.Generic.List[object]

# Files and libraries we could not read. A storage report that answers "nothing
# to reclaim" after failing every read is worse than no report at all.
$failedFiles = 0
$failedSites = 0

$minBytes = [int64]$MinFileSizeMB * 1MB

$caml = @"
<View Scope="RecursiveAll">
  <Query>
    <Where><Geq><FieldRef Name="File_x0020_Size" /><Value Type="Number">$minBytes</Value></Geq></Where>
    <OrderBy><FieldRef Name="File_x0020_Size" Ascending="FALSE" /></OrderBy>
  </Query>
  <ViewFields>
    <FieldRef Name="FileLeafRef" />
    <FieldRef Name="FileRef" />
    <FieldRef Name="File_x0020_Size" />
    <FieldRef Name="Modified" />
    <FieldRef Name="_UIVersionString" />
  </ViewFields>
  <RowLimit Paged="TRUE">500</RowLimit>
</View>
"@

foreach ($url in $SiteUrl) {
    Write-Host "Scanning $url ..." -ForegroundColor Cyan

    try {
        Connect-PnPOnline -Url $url -Interactive -ClientId $ClientId

        $libraries = @(Get-PnPList -Includes Hidden, BaseType, ItemCount, EnableVersioning, MajorVersionLimit |
            Where-Object { -not $_.Hidden -and $_.BaseType -eq 'DocumentLibrary' })

        foreach ($lib in $libraries) {
            if ($lib.ItemCount -eq 0) { continue }
            Write-Host ("  {0} ..." -f $lib.Title)

            $items = @(Get-PnPListItem -List $lib -Query $caml -PageSize 500)
            if ($items.Count -eq 0) { continue }

            $truncated = $items.Count -gt $MaxFilesPerLibrary
            if ($truncated) { $items = $items[0..($MaxFilesPerLibrary - 1)] }

            $libCurrent  = [int64]0
            $libVersions = [int64]0
            $scanned     = 0

            foreach ($item in $items) {
                $currentSize = [int64]$item['File_x0020_Size']

                try {
                    # Load File through the item's own context. Reaching for $item.File
                    # directly throws "The object is used in the context different from
                    # the one associated with the object" once anything else has opened
                    # a second PnP connection in the same process.
                    $file = Get-PnPProperty -ClientObject $item -Property File
                    $versions = Get-PnPProperty -ClientObject $file -Property Versions
                }
                catch {
                    # Unreadable version metadata is not "no versions" - skip the file
                    # rather than folding a read failure into a zero.
                    Write-Warning ("{0}: could not read versions for {1} - {2}" -f $lib.Title, $item['FileLeafRef'], $_.Exception.Message)
                    $script:failedFiles++
                    continue
                }

                $versionBytes = [int64]0
                foreach ($v in $versions) { $versionBytes += [int64]$v.Size }

                $scanned++
                $libCurrent  += $currentSize
                $libVersions += $versionBytes

                if ($versionBytes -le 0) { continue }

                $files.Add([pscustomobject]@{
                    SiteUrl        = $url
                    Library        = $lib.Title
                    FileName       = $item['FileLeafRef']
                    FilePath       = $item['FileRef']
                    CurrentMB      = [math]::Round($currentSize / 1MB, 2)
                    VersionsMB     = [math]::Round($versionBytes / 1MB, 2)
                    VersionCount   = $versions.Count
                    TotalMB        = [math]::Round(($currentSize + $versionBytes) / 1MB, 2)
                    OverheadRatio  = if ($currentSize -gt 0) { [math]::Round($versionBytes / $currentSize, 1) } else { '' }
                    CurrentVersion = $item['_UIVersionString']
                    Modified       = $item['Modified']
                })
            }

            $summaries.Add([pscustomobject]@{
                SiteUrl          = $url
                Library          = $lib.Title
                FilesScanned     = $scanned
                CurrentMB        = [math]::Round($libCurrent / 1MB, 1)
                VersionsMB       = [math]::Round($libVersions / 1MB, 1)
                ReclaimableMB    = [math]::Round($libVersions / 1MB, 1)
                VersioningOn     = $lib.EnableVersioning
                VersionLimit     = if ($lib.EnableVersioning -and $lib.MajorVersionLimit -eq 0) { 'unlimited' } else { $lib.MajorVersionLimit }
                Completeness     = if ($truncated) { ("PARTIAL - first {0} files only, figures are a floor" -f $MaxFilesPerLibrary) } else { 'complete' }
            })
        }
    }
    catch {
        Write-Warning "Failed to scan ${url}: $($_.Exception.Message)"
        $failedSites++
    }
}

Write-Host ''
if ($failedSites -gt 0 -or $failedFiles -gt 0) {
    Write-Host ("INCOMPLETE: {0} site(s) and {1} file(s) could not be read." -f $failedSites, $failedFiles) -ForegroundColor Red
    Write-Host 'Any total below is a floor, not the answer.' -ForegroundColor Red
    Write-Host ''
}

if ($summaries.Count -gt 0) {
    $summaries | Sort-Object VersionsMB -Descending | Export-Csv -Path $SummaryPath -NoTypeInformation -Encoding UTF8
    Write-Host ("Library summary -> {0}" -f (Resolve-Path $SummaryPath)) -ForegroundColor Green
}
if ($files.Count -gt 0) {
    $files | Sort-Object VersionsMB -Descending | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
    Write-Host ("Per-file detail -> {0}" -f (Resolve-Path $OutputPath)) -ForegroundColor Green

    $totalGB = [math]::Round((($files | Measure-Object VersionsMB -Sum).Sum) / 1024, 2)
    Write-Host ("Version history in scope: {0} GB across {1} file(s)." -f $totalGB, $files.Count) -ForegroundColor Yellow

    Write-Host ''
    Write-Host 'Ten worst offenders:' -ForegroundColor Cyan
    $files | Sort-Object VersionsMB -Descending | Select-Object -First 10 |
        Format-Table Library, FileName, CurrentMB, VersionsMB, VersionCount -AutoSize
}
elseif ($failedSites -gt 0 -or $failedFiles -gt 0) {
    Write-Host 'Done, but every read failed - this says nothing about version history.' -ForegroundColor Red
}
else {
    Write-Host 'Done. No file version history above the thresholds.' -ForegroundColor Green
}

try { Disconnect-PnPOnline } catch { }
Was this helpful?ask or add your experience on GitHub →
Saved you an hour? A star on GitHub helps other SharePoint people find the vault.★ Star on GitHub
last updated: 2026-07-29historyedit on GitHubview source