SharePoint (2003 thru Online): M365 Tenant - SharePoint Online Storage Cleanup

Monday, February 23, 2026

M365 Tenant - SharePoint Online Storage Cleanup

Items in the SharePoint Online Recycle Bin, including both the first and second stages, contribute to the site's storage quota for 93 days. Storage space is only released once items are permanently deleted. 

If there is a retention policy, deleted items may be moved to the Preservation Hold Library, which also consumes storage. 

To free up space right away, delete items from the second-stage Recycle Bin, and then clean up the Preservation Hold Library.

The PowerShell script below is the most effective way to carry out these tasks across all site collections in the tenant.

#____________________________________________________________________________________________________________________________
#==================================================================================================
# OSI - Retention Exception Update + PHL Cleanup + Recycle Bin Cleanup (PnP)
# Single-Shot Mode (≤100 sites) — OPTIMIZED with PnP Batching
#==================================================================================================

#------------------------------ CONFIG -------------------------------------
$PolicyName               = "Document Retention Policy"
$ExceptionsCsvPath        = "E:\Reports\RP_Sites_2.csv"
$SitesCsvPath             = "E:\Reports\Sites_2.csv"

$IPPSSessionUPN           = "admin@spadmins.onmicrosoft.com"
$PnPClientId              = "12a34567-f123-4567-891e-2aaf34567890"

$PHLPageSize              = 2000
$BatchChunkSize           = 100        # ← items per PnP batch (100 is the sweet spot)

$EnableRecycleBinCleanup  = $true
$RecycleBinRowLimit       = 0

#------------------------------ SECTION 1: RETENTION POLICY EXCEPTIONS ---------------------------
Write-Host "=== Updating Retention Policy Exceptions (Single-Shot) ===" -ForegroundColor Cyan

[array]$excludeSites = Import-Csv -Path $ExceptionsCsvPath |
    Select-Object -ExpandProperty URL |
    ForEach-Object { $_.Trim().TrimEnd("/") } |
    Where-Object { $_ } |
    Sort-Object -Unique

Write-Host "Loaded $($excludeSites.Count) exception site URLs from $ExceptionsCsvPath"

if ($excludeSites.Count -gt 100) {
    Write-Host "ERROR: $($excludeSites.Count) sites exceed the 100-site static scoping limit." -ForegroundColor Red
    Write-Host "Use batched mode with deployment wait, or switch to Adaptive Scopes." -ForegroundColor Red
    return
}

Connect-IPPSSession -UserPrincipalName $IPPSSessionUPN

try {
    Set-RetentionCompliancePolicy `
        -Identity $PolicyName `
        -AddSharePointLocationException $excludeSites

    Write-Host "Successfully added all $($excludeSites.Count) exception sites in one call." -ForegroundColor Green
}
catch {
    Write-Host "FAILED to add exceptions: $($_.Exception.Message)" -ForegroundColor Red
}

Write-Host "Retention exception update completed." -ForegroundColor Cyan

#------------------------------ SECTION 2: PHL CLEANUP + RECYCLE BIN CLEANUP ---------------------

Write-Host "`n=== PHL Cleanup + Recycle Bin Cleanup ===" -ForegroundColor Cyan

$sites = Import-Csv -Path $SitesCsvPath
Write-Host "Loaded $($sites.Count) sites from $SitesCsvPath"

$hasClearRecycleCmd = $null -ne (Get-Command Clear-PnPRecycleBinItem -ErrorAction SilentlyContinue)

foreach ($site in $sites) {
    $siteUrl = $site.URL.Trim().TrimEnd("/")
    if (-not $siteUrl) { continue }

    Write-Host "`nProcessing site: $siteUrl" -ForegroundColor Yellow

    try {
        Connect-PnPOnline -Url $siteUrl -Interactive -ClientId $PnPClientId -ErrorAction Stop
    }
    catch {
        Write-Host "Failed to connect to $siteUrl : $($_.Exception.Message)" -ForegroundColor Red
        continue
    }

    # ── PHL Cleanup ──────────────────────────────────────────────────────
    $phl = Get-PnPList -Identity "Preservation Hold Library" -ErrorAction SilentlyContinue
    if (-not $phl) {
        Write-Host "No Preservation Hold Library found at $siteUrl" -ForegroundColor DarkGray
    }
    else {
        Write-Host "Preservation Hold Library found at $siteUrl" -ForegroundColor Green

        $items = @()
        try {
            $items = Get-PnPListItem -List "Preservation Hold Library" -PageSize $PHLPageSize -ScriptBlock {
                param($pagedItems)
                $pagedItems.Context.ExecuteQuery()
            }
        }
        catch {
            Write-Host "Failed to list PHL items at $siteUrl : $($_.Exception.Message)" -ForegroundColor Red
            $items = @()
        }

        if ($items.Count -gt 0) {
            Write-Host "$($items.Count) items found in Preservation Hold Library — deleting in batches of $BatchChunkSize..." -ForegroundColor Cyan

            # ╔══════════════════════════════════════════════════════════════╗
            # ║  OPTIMIZATION: PnP Batching — replaces item-by-item delete  ║
            # ║  Groups up to $BatchChunkSize requests into ONE API call    ║
            # ╚══════════════════════════════════════════════════════════════╝
            $totalDeleted = 0
            for ($i = 0; $i -lt $items.Count; $i += $BatchChunkSize) {

                $batch = New-PnPBatch

                $end = [Math]::Min($i + $BatchChunkSize - 1, $items.Count - 1)
                foreach ($item in $items[$i..$end]) {
                    Remove-PnPListItem -List "Preservation Hold Library" -Identity $item.Id -Batch $batch
                }

                try {
                    Invoke-PnPBatch -Batch $batch -ErrorAction Stop
                    $totalDeleted += ($end - $i + 1)
                    Write-Host "  Batch deleted items $($i+1)$($end+1) of $($items.Count)" -ForegroundColor DarkCyan
                }
                catch {
                    Write-Host "  Batch failed at items $($i+1)$($end+1): $($_.Exception.Message)" -ForegroundColor DarkYellow
                }
            }
            Write-Host "PHL cleanup complete: $totalDeleted / $($items.Count) items deleted" -ForegroundColor Green
        }
        else {
            Write-Host "No items found in Preservation Hold Library" -ForegroundColor DarkGray
        }
    }

    # ── Recycle Bin Cleanup ──────────────────────────────────────────────
    if ($EnableRecycleBinCleanup) {
        if (-not $hasClearRecycleCmd) {
            Write-Host "Clear-PnPRecycleBinItem cmdlet not found. Per PnP docs, it may require PnP.PowerShell Nightly. Skipping recycle bin cleanup." -ForegroundColor DarkYellow
        }
        else {
            try {
                if ($RecycleBinRowLimit -gt 0) {
                    Clear-PnPRecycleBinItem -All -Force -RowLimit $RecycleBinRowLimit
                }
                else {
                    Clear-PnPRecycleBinItem -All -Force
                }
                Write-Host "Recycle bins cleared for: $siteUrl" -ForegroundColor Green
            }
            catch {
                Write-Host "Failed to clear recycle bins for $siteUrl : $($_.Exception.Message)" -ForegroundColor Red
            }
        }
    }
}

Write-Host "`n=== DONE ===" -ForegroundColor Cyan

No comments:

Post a Comment