A finance manager once asked me to hand over a full copy of a document library before a site was archived. It had about 9,000 files in nested folders. The browser’s Download button zipped for a while, then failed, and the partial zip was missing whole folders.
That’s when PowerShell earns its keep. Below are the PnP PowerShell scripts I use to download every file from a SharePoint Online library, keep the folder structure, filter by type, size, date, user or column, survive throttling and prove nothing was skipped.
Quick answer: Connect with Connect-PnPOnline -Url <site> -ClientId <your-app-id> -Interactive, read every file with Get-PnPListItem -List "Documents" -PageSize 2000, keep the items where FileSystemObjectType is File, and save each one with Get-PnPFile -Url $Item.FieldValues.FileRef -Path <local folder> -Filename $Item.FieldValues.FileLeafRef -AsFile -Force.
Before you start: PowerShell 7, PnP PowerShell and an Entra ID app
Why not just use the browser? Its multi-file download builds a zip, and Microsoft caps that at 10,000 files, with compressed files such as .zip or .mp4 limited to 10 GB each and 20 GB overall. A script has no such cap, can resume, and leaves a log.
Current PnP PowerShell needs PowerShell 7 (version 3.x asks for 7.4 or later), so it won’t load in Windows PowerShell 5.1. Check your version, then install the module. My guide to installing PnP PowerShell for SharePoint Online covers the other install options.
$PSVersionTable.PSVersion
Install-Module -Name PnP.PowerShell -Scope CurrentUser -Force
Get-Module PnP.PowerShell -ListAvailable | Select-Object Name, Version
You also need at least Read access to the library. The bigger hurdle is the sign-in: since September 9, 2024, Connect-PnPOnline requires a -ClientId from an Entra ID app registered in your own tenant. Register one once and reuse its ID everywhere:
Register-PnPEntraIDAppForInteractiveLogin -ApplicationName "PnP Rocks" -Tenant contoso.onmicrosoft.com -Interactive
Note: Register-PnPEntraIDAppForInteractiveLogin has no -Interactive parameter, so this line stops with “A parameter cannot be found that matches parameter name ‘Interactive'”. Drop -Interactive (the browser sign-in is the default) or swap it for -DeviceLogin. Creating the app usually needs an Application Administrator, and granting consent a Global Administrator.
Now connect to the site and confirm the session works. If anything here fails, my walkthrough on connecting to SharePoint Online using PowerShell explains every sign-in method and error.
$SiteURL = "https://contoso.sharepoint.com/sites/Finance"
$ClientId = "11111111-2222-3333-4444-555555555555"
Connect-PnPOnline -Url $SiteURL -Interactive -ClientId $ClientId
Get-PnPWeb | Select-Object Title, Url
For a scheduled job nobody watches, use certificate-based app-only authentication instead. The app needs a SharePoint application permission such as Sites.Read.All (or Sites.Selected for one site) with admin consent:
Connect-PnPOnline -Url $SiteURL `
-ClientId "11111111-2222-3333-4444-555555555555" `
-Tenant "contoso.onmicrosoft.com" `
-CertificatePath "C:\Certs\PnPCert.pfx" `
-CertificatePassword (ConvertTo-SecureString -String "YourPfxPassword" -AsPlainText -Force)
Note: this works, but it keeps the .pfx password in plain text inside the script. For real jobs, load it with Read-Host -AsSecureString, a secret store, or use -Thumbprint with a certificate installed in the Windows certificate store.
Last check: confirm the library’s exact name. The display name and URL often differ; a library called Documents lives at /Shared Documents. This lists every library (base template 101), and I go further in getting all lists and libraries from a site.
Get-PnPList | Where-Object { $_.BaseTemplate -eq 101 } |
Select-Object Title, DefaultViewUrl, ItemCount
Download all files from a SharePoint document library (folders kept)
This is the core script. It reads every item in the library, skips folders, rebuilds each file’s folder path under your local download folder and saves the file there with Get-PnPFile:
# ---------- CONFIGURATION ----------
$SiteURL = "https://contoso.sharepoint.com/sites/Finance"
$ClientId = "11111111-2222-3333-4444-555555555555"
$LibraryName = "Documents"
$DownloadPath = "C:\SPDownloads\Finance"
# -----------------------------------
Connect-PnPOnline -Url $SiteURL -Interactive -ClientId $ClientId
# Make sure the destination folder exists
if (-not (Test-Path $DownloadPath)) {
New-Item -Path $DownloadPath -ItemType Directory -Force | Out-Null
}
# Get the library and its server-relative URL
$List = Get-PnPList -Identity $LibraryName -Includes RootFolder
$LibraryUrl = $List.RootFolder.ServerRelativeUrl
# Get all FILES only (FSObjType 0), page by page for large libraries
$Items = Get-PnPListItem -List $LibraryName -PageSize 2000 |
Where-Object { $_.FileSystemObjectType -eq "File" }
Write-Host "Found $($Items.Count) files. Starting download..." -ForegroundColor Cyan
$Counter = 0
foreach ($Item in $Items) {
$Counter++
$FileUrl = $Item.FieldValues.FileRef
# Recreate the SharePoint folder structure locally
$RelativePath = $FileUrl.Replace($LibraryUrl, "").Replace("/", "\")
$LocalFile = Join-Path $DownloadPath $RelativePath.TrimStart("\")
$LocalFolder = Split-Path $LocalFile -Parent
if (-not (Test-Path $LocalFolder)) {
New-Item -Path $LocalFolder -ItemType Directory -Force | Out-Null
}
Write-Progress -Activity "Downloading files" `
-Status "$Counter of $($Items.Count): $($Item.FieldValues.FileLeafRef)" `
-PercentComplete (($Counter / $Items.Count) * 100)
try {
Get-PnPFile -Url $FileUrl -Path $LocalFolder `
-FileName $Item.FieldValues.FileLeafRef -AsFile -Force
}
catch {
Write-Host "Failed: $FileUrl - $($_.Exception.Message)" -ForegroundColor Red
}
}
Write-Host "Download complete. Files saved to $DownloadPath" -ForegroundColor Green
Disconnect-PnPOnline
Here’s what each part does:
- -PageSize 2000 makes Get-PnPListItem read the library in pages, which is how you read past the 5,000-item list view threshold. The same idea applies when adding more than 5,000 items with PnP PowerShell.
- FileSystemObjectType -eq “File” drops folder items so only documents get downloaded.
- FileRef is the file’s server-relative URL, such as /sites/Finance/Shared Documents/Reports/Budget.xlsx. FileLeafRef is just the file name.
- Removing the library’s root URL from FileRef leaves the relative path, which becomes the local subfolder.
- -AsFile -Force writes the file to disk and overwrites an older local copy.
Run it on a small library first. On a big one, check the total size before you start; my post on getting the SharePoint document library size shows how. Here’s the script finishing against my test library:

The script checked out fine against the current Get-PnPFile docs. One small thing: the cmdlet’s parameter is spelled -Filename; PowerShell ignores case, so -FileName works too.
Download every file into one folder (flat download)
Sometimes people want every file in a single folder, for a vendor or an import tool. The catch is duplicate names: Report.pdf in two subfolders would overwrite each other. This version renames duplicates (Report_1.pdf), retries failed downloads and writes a CSV that maps each saved name back to its SharePoint path.
If you keep hitting duplicates, the library may need a cleaner SharePoint folder structure.
<#
=====================================================================
Script : Download all files from a SharePoint library (FLAT)
Purpose : Downloads every file from a document library, including
all subfolders, into ONE local folder without recreating
the SharePoint folder structure.
Requires: PowerShell 7+ and the PnP.PowerShell module
=====================================================================
#>
# ------------------------- CONFIGURATION -------------------------
$SiteURL = "https://contoso.sharepoint.com/sites/Finance"
$ClientId = "11111111-2222-3333-4444-555555555555"
$LibraryName = "Documents" # Library display name
$DownloadPath = "C:\SPDownloads\Finance_Flat" # Local flat folder
$MappingFile = "C:\SPDownloads\FlatDownload_Map_$(Get-Date -f 'yyyyMMdd_HHmmss').csv"
$MaxRetries = 3
$SkipExisting = $false # $true = skip files already downloaded
# -----------------------------------------------------------------
# ---------- STEP 1: Connect to SharePoint ----------
try {
Connect-PnPOnline -Url $SiteURL -Interactive -ClientId $ClientId -ErrorAction Stop
Write-Host "Connected to $SiteURL" -ForegroundColor Green
}
catch {
Write-Host "Connection failed: $($_.Exception.Message)" -ForegroundColor Red
return
}
# ---------- STEP 2: Prepare the local download folder ----------
if (-not (Test-Path $DownloadPath)) {
New-Item -Path $DownloadPath -ItemType Directory -Force | Out-Null
Write-Host "Created folder: $DownloadPath" -ForegroundColor Gray
}
# ---------- STEP 3: Get every file in the library ----------
Write-Host "Reading library '$LibraryName'..." -ForegroundColor Cyan
$Items = Get-PnPListItem -List $LibraryName -PageSize 2000 `
-Fields "FileRef", "FileLeafRef", "File_x0020_Size", "Modified" |
Where-Object { $_.FileSystemObjectType -eq "File" }
$Total = @($Items).Count
if ($Total -eq 0) {
Write-Host "No files found in '$LibraryName'. Nothing to download." -ForegroundColor Yellow
Disconnect-PnPOnline
return
}
Write-Host "Found $Total files. Starting flat download...`n" -ForegroundColor Cyan
# ---------- STEP 4: Download loop with duplicate-name handling ----------
$Downloaded = 0
$Skipped = 0
$Failed = 0
$Renamed = 0
$Counter = 0
$Mapping = @()
foreach ($Item in $Items) {
$Counter++
$SourceUrl = $Item.FieldValues.FileRef
$OriginalName = $Item.FieldValues.FileLeafRef
Write-Progress -Activity "Flat download from '$LibraryName'" `
-Status "$Counter of $Total : $OriginalName" `
-PercentComplete (($Counter / $Total) * 100)
# --- Strip characters Windows does not allow in file names ---
$SafeName = $OriginalName -replace '[\\/:*?"<>|]', '_'
$TargetName = $SafeName
$TargetPath = Join-Path $DownloadPath $TargetName
# --- Optionally skip if the exact file already exists ---
if ($SkipExisting -and (Test-Path $TargetPath)) {
Write-Host "[$Counter/$Total] Skipped (exists): $TargetName" -ForegroundColor DarkGray
$Skipped++
continue
}
# --- Resolve duplicate names: File.pdf -> File_1.pdf -> File_2.pdf ---
$Suffix = 1
while (Test-Path $TargetPath) {
$BaseName = [System.IO.Path]::GetFileNameWithoutExtension($SafeName)
$Extension = [System.IO.Path]::GetExtension($SafeName)
$TargetName = "$($BaseName)_$Suffix$Extension"
$TargetPath = Join-Path $DownloadPath $TargetName
$Suffix++
}
if ($TargetName -ne $SafeName) { $Renamed++ }
# --- Download with retry + exponential back-off for throttling ---
$Attempt = 0
$Success = $false
while (-not $Success -and $Attempt -lt $MaxRetries) {
$Attempt++
try {
Get-PnPFile -Url $SourceUrl `
-Path $DownloadPath `
-FileName $TargetName `
-AsFile -Force -ErrorAction Stop
$Success = $true
$Downloaded++
Write-Host "[$Counter/$Total] Downloaded: $TargetName" -ForegroundColor Green
$Mapping += [PSCustomObject]@{
OriginalName = $OriginalName
SavedAs = $TargetName
SharePointPath = $SourceUrl
SizeKB = [math]::Round([int64]$Item.FieldValues.File_x0020_Size / 1KB, 2)
Modified = $Item.FieldValues.Modified
Status = "Success"
}
}
catch {
if ($Attempt -ge $MaxRetries) {
$Failed++
Write-Host "[$Counter/$Total] FAILED: $OriginalName - $($_.Exception.Message)" -ForegroundColor Red
$Mapping += [PSCustomObject]@{
OriginalName = $OriginalName
SavedAs = ""
SharePointPath = $SourceUrl
SizeKB = 0
Modified = $Item.FieldValues.Modified
Status = "Failed: $($_.Exception.Message)"
}
}
else {
# Wait 10s, then 20s, then 40s before retrying
Start-Sleep -Seconds ([math]::Pow(2, $Attempt) * 5)
}
}
}
}
Write-Progress -Activity "Flat download" -Completed
# ---------- STEP 5: Export the mapping file ----------
if ($Mapping.Count -gt 0) {
$Mapping | Export-Csv -Path $MappingFile -NoTypeInformation -Encoding UTF8
}
# ---------- STEP 6: Summary ----------
$LocalCount = (Get-ChildItem -Path $DownloadPath -File).Count
Write-Host "`n================ SUMMARY ================" -ForegroundColor Cyan
Write-Host " Library : $LibraryName"
Write-Host " Files in library : $Total"
Write-Host " Downloaded : $Downloaded" -ForegroundColor Green
Write-Host " Renamed (dupes) : $Renamed" -ForegroundColor Yellow
Write-Host " Skipped : $Skipped" -ForegroundColor DarkGray
Write-Host " Failed : $Failed" -ForegroundColor Red
Write-Host " Files on disk : $LocalCount"
Write-Host " Mapping CSV : $MappingFile"
Write-Host "=========================================`n" -ForegroundColor Cyan
Disconnect-PnPOnline
Note: the retry comment says it waits 10, then 20, then 40 seconds. With $MaxRetries = 3, the third failure ends the loop, so it only waits 10 and 20 seconds. Set $MaxRetries = 4 if you want the 40-second wait. The same applies to the other retry scripts below.
Also, with $SkipExisting = $false, a second run saves every file again as Name_1, Name_2, so set it to $true for reruns.
Download only PDF files
The File_x0020_Type field holds each file’s extension without the dot, so filtering on “pdf” is the simplest way to grab only PDFs:
$PDFs = Get-PnPListItem -List $LibraryName -PageSize 2000 |
Where-Object { $_.FieldValues.File_x0020_Type -eq "pdf" }
Write-Host "Found $($PDFs.Count) PDF files."
foreach ($Item in $PDFs) {
Get-PnPFile -Url $Item.FieldValues.FileRef -Path $DownloadPath `
-FileName $Item.FieldValues.FileLeafRef -AsFile -Force
}
On a big library, a CAML query is faster because SharePoint returns only the matching items. If CAML is new to you, the CAML query builder for SharePoint Online helps you write and test queries:
$CAML = @"
<View Scope='RecursiveAll'>
<Query>
<Where>
<Eq>
<FieldRef Name='File_x0020_Type'/>
<Value Type='Text'>pdf</Value>
</Eq>
</Where>
</Query>
</View>
"@
$PDFs = Get-PnPListItem -List $LibraryName -Query $CAML -PageSize 2000
foreach ($Item in $PDFs) {
Get-PnPFile -Url $Item.FieldValues.FileRef -Path $DownloadPath `
-FileName $Item.FieldValues.FileLeafRef -AsFile -Force
}
Scope='RecursiveAll' matters: without it, the query only looks at the library’s root folder. Here’s the complete PDF script with folder structure, retries and a CSV log:
# ------------------------- CONFIGURATION -------------------------
$SiteURL = "https://contoso.sharepoint.com/sites/Finance"
$ClientId = "11111111-2222-3333-4444-555555555555"
$LibraryName = "Documents"
$DownloadPath = "C:\SPDownloads\PDFs"
$KeepStructure = $true # $true = recreate folders, $false = flat
$MaxRetries = 3
$LogFile = "C:\SPDownloads\PDFDownload_$(Get-Date -f 'yyyyMMdd_HHmmss').csv"
# -----------------------------------------------------------------
try {
Connect-PnPOnline -Url $SiteURL -Interactive -ClientId $ClientId -ErrorAction Stop
Write-Host "Connected to $SiteURL" -ForegroundColor Green
}
catch {
Write-Host "Connection failed: $($_.Exception.Message)" -ForegroundColor Red
return
}
if (-not (Test-Path $DownloadPath)) {
New-Item -Path $DownloadPath -ItemType Directory -Force | Out-Null
}
$List = Get-PnPList -Identity $LibraryName -Includes RootFolder
$LibraryUrl = $List.RootFolder.ServerRelativeUrl
$CAML = @"
<View Scope='RecursiveAll'>
<Query>
<Where>
<Eq>
<FieldRef Name='File_x0020_Type'/>
<Value Type='Text'>pdf</Value>
</Eq>
</Where>
</Query>
<ViewFields>
<FieldRef Name='FileRef'/>
<FieldRef Name='FileLeafRef'/>
<FieldRef Name='File_x0020_Size'/>
<FieldRef Name='Modified'/>
</ViewFields>
</View>
"@
Write-Host "Searching for PDF files in '$LibraryName'..." -ForegroundColor Cyan
$PDFs = Get-PnPListItem -List $LibraryName -Query $CAML -PageSize 2000
$Total = @($PDFs).Count
if ($Total -eq 0) {
Write-Host "No PDF files found." -ForegroundColor Yellow
Disconnect-PnPOnline
return
}
$TotalMB = [math]::Round((($PDFs | Measure-Object -Property { [int64]$_.FieldValues.File_x0020_Size } -Sum).Sum / 1MB), 2)
Write-Host "Found $Total PDF files ($TotalMB MB). Starting download...`n" -ForegroundColor Cyan
$Downloaded = 0
$Failed = 0
$Counter = 0
$Log = @()
foreach ($Item in $PDFs) {
$Counter = $Counter + 1
$SourceUrl = $Item.FieldValues.FileRef
$FileName = $Item.FieldValues.FileLeafRef -replace '[\\/:*?"<>|]', '_'
if ($KeepStructure) {
$RelPath = $SourceUrl.Replace($LibraryUrl, "").Replace("/", "\").TrimStart("\")
$TargetFolder = Split-Path (Join-Path $DownloadPath $RelPath) -Parent
}
else {
$TargetFolder = $DownloadPath
$Suffix = 1
while (Test-Path (Join-Path $TargetFolder $FileName)) {
$Base = [System.IO.Path]::GetFileNameWithoutExtension($Item.FieldValues.FileLeafRef)
$FileName = "$($Base)_$Suffix.pdf"
$Suffix = $Suffix + 1
}
}
if (-not (Test-Path $TargetFolder)) {
New-Item -Path $TargetFolder -ItemType Directory -Force | Out-Null
}
Write-Progress -Activity "Downloading PDFs from '$LibraryName'" `
-Status "$Counter of $Total : $FileName" `
-PercentComplete (($Counter / $Total) * 100)
$Attempt = 0
$Success = $false
while (-not $Success -and $Attempt -lt $MaxRetries) {
$Attempt = $Attempt + 1
try {
Get-PnPFile -Url $SourceUrl -Path $TargetFolder -FileName $FileName -AsFile -Force -ErrorAction Stop
$Success = $true
$Downloaded = $Downloaded + 1
$SizeKB = [math]::Round([int64]$Item.FieldValues.File_x0020_Size / 1KB, 2)
Write-Host "[$Counter/$Total] $FileName ($SizeKB KB)" -ForegroundColor Green
$Log += [PSCustomObject]@{
FileName = $FileName
SharePointPath = $SourceUrl
SizeKB = $SizeKB
Modified = $Item.FieldValues.Modified
Status = "Success"
}
}
catch {
if ($Attempt -ge $MaxRetries) {
$Failed = $Failed + 1
Write-Host "[$Counter/$Total] FAILED: $FileName" -ForegroundColor Red
$Log += [PSCustomObject]@{
FileName = $FileName
SharePointPath = $SourceUrl
SizeKB = 0
Modified = $Item.FieldValues.Modified
Status = "Failed: $($_.Exception.Message)"
}
}
else {
Start-Sleep -Seconds ([math]::Pow(2, $Attempt) * 5)
}
}
}
}
Write-Progress -Activity "Downloading PDFs" -Completed
if ($Log.Count -gt 0) {
$Log | Export-Csv -Path $LogFile -NoTypeInformation -Encoding UTF8
}
Write-Host "`n============== SUMMARY ==============" -ForegroundColor Cyan
Write-Host " PDFs found : $Total"
Write-Host " Downloaded : $Downloaded" -ForegroundColor Green
Write-Host " Failed : $Failed" -ForegroundColor Red
Write-Host " Total size : $TotalMB MB"
Write-Host " Saved to : $DownloadPath"
Write-Host " Log file : $LogFile"
Write-Host "=====================================`n" -ForegroundColor Cyan
Disconnect-PnPOnline
Note: in a library with more than 5,000 items, a CAML Where on a column that isn’t indexed can fail with the list view threshold error, even with -PageSize. Add an index to the column first, or use the Where-Object version above. Here’s how to fix the list view threshold error if you see it.
Download Excel files (or any set of file types)
Excel workbooks come in several extensions, so match a list of them. Swap the array for @(“docx”,”doc”) for Word, @(“pptx”,”ppt”) for PowerPoint or @(“jpg”,”jpeg”,”png”,”gif”) for images. Keeping file types in separate libraries is one of the document library best practices that makes these jobs simpler:
$Extensions = @("xlsx", "xls", "xlsm", "csv")
$ExcelFiles = Get-PnPListItem -List $LibraryName -PageSize 2000 |
Where-Object { $Extensions -contains $_.FieldValues.File_x0020_Type }
foreach ($Item in $ExcelFiles) {
Get-PnPFile -Url $Item.FieldValues.FileRef -Path $DownloadPath `
-FileName $Item.FieldValues.FileLeafRef -AsFile -Force
}
Download files larger than 5 MB
When a site is running out of space, the big files are the first suspects. File_x0020_Size stores the size in bytes, so this pulls everything over 5 MB. If storage is the real problem, also read how to manage site storage limits in SharePoint:
$MinSizeMB = 5
$MinBytes = $MinSizeMB * 1MB
$BigFiles = Get-PnPListItem -List $LibraryName -PageSize 2000 |
Where-Object {
$_.FileSystemObjectType -eq "File" -and
[int64]$_.FieldValues.File_x0020_Size -gt $MinBytes
}
Write-Host "Files larger than $MinSizeMB MB: $($BigFiles.Count)"
foreach ($Item in $BigFiles) {
$SizeMB = [math]::Round([int64]$Item.FieldValues.File_x0020_Size / 1MB, 2)
Write-Host "Downloading $($Item.FieldValues.FileLeafRef) ($SizeMB MB)"
Get-PnPFile -Url $Item.FieldValues.FileRef -Path $DownloadPath `
-FileName $Item.FieldValues.FileLeafRef -AsFile -Force
}
If you only want a report of the large files, export them instead of downloading:
$BigFiles | ForEach-Object {
[PSCustomObject]@{
Name = $_.FieldValues.FileLeafRef
SizeMB = [math]::Round([int64]$_.FieldValues.File_x0020_Size / 1MB, 2)
Path = $_.FieldValues.FileRef
}
} | Export-Csv "C:\Reports\LargeFiles.csv" -NoTypeInformation
The complete script below filters on the server with CAML, sorts largest first, shows the total size and asks before it downloads anything:
# ------------------------- CONFIGURATION -------------------------
$SiteURL = "https://contoso.sharepoint.com/sites/Finance"
$ClientId = "11111111-2222-3333-4444-555555555555"
$LibraryName = "Documents"
$DownloadPath = "C:\SPDownloads\LargeFiles"
$MinSizeMB = 5
$KeepStructure = $true
$MaxRetries = 3
$LogFile = "C:\SPDownloads\LargeFiles_$(Get-Date -f 'yyyyMMdd_HHmmss').csv"
# -----------------------------------------------------------------
$MinBytes = $MinSizeMB * 1MB
try {
Connect-PnPOnline -Url $SiteURL -Interactive -ClientId $ClientId -ErrorAction Stop
Write-Host "Connected to $SiteURL" -ForegroundColor Green
}
catch {
Write-Host "Connection failed: $($_.Exception.Message)" -ForegroundColor Red
return
}
if (-not (Test-Path $DownloadPath)) {
New-Item -Path $DownloadPath -ItemType Directory -Force | Out-Null
}
$List = Get-PnPList -Identity $LibraryName -Includes RootFolder
$LibraryUrl = $List.RootFolder.ServerRelativeUrl
$CAML = @"
<View Scope='RecursiveAll'>
<Query>
<Where>
<Gt>
<FieldRef Name='File_x0020_Size'/>
<Value Type='Number'>$MinBytes</Value>
</Gt>
</Where>
<OrderBy>
<FieldRef Name='File_x0020_Size' Ascending='FALSE'/>
</OrderBy>
</Query>
<ViewFields>
<FieldRef Name='FileRef'/>
<FieldRef Name='FileLeafRef'/>
<FieldRef Name='File_x0020_Size'/>
<FieldRef Name='File_x0020_Type'/>
<FieldRef Name='Modified'/>
</ViewFields>
</View>
"@
Write-Host "Searching for files larger than $MinSizeMB MB..." -ForegroundColor Cyan
$BigFiles = Get-PnPListItem -List $LibraryName -Query $CAML -PageSize 2000 |
Where-Object { $_.FileSystemObjectType -eq "File" }
$Total = @($BigFiles).Count
if ($Total -eq 0) {
Write-Host "No files larger than $MinSizeMB MB found." -ForegroundColor Yellow
Disconnect-PnPOnline
return
}
$TotalMB = [math]::Round((($BigFiles | ForEach-Object { [int64]$_.FieldValues.File_x0020_Size } | Measure-Object -Sum).Sum / 1MB), 2)
$TotalGB = [math]::Round($TotalMB / 1024, 2)
Write-Host "Found $Total files larger than $MinSizeMB MB" -ForegroundColor Cyan
Write-Host "Total download size: $TotalMB MB ($TotalGB GB)`n" -ForegroundColor Cyan
$Confirm = Read-Host "Continue with download? (Y/N)"
if ($Confirm -ne "Y") {
Write-Host "Cancelled by user." -ForegroundColor Yellow
Disconnect-PnPOnline
return
}
$Downloaded = 0
$Failed = 0
$Counter = 0
$BytesDone = 0
$Log = @()
$StartTime = Get-Date
foreach ($Item in $BigFiles) {
$Counter = $Counter + 1
$SourceUrl = $Item.FieldValues.FileRef
$FileName = $Item.FieldValues.FileLeafRef -replace '[\\/:*?"<>|]', '_'
$Bytes = [int64]$Item.FieldValues.File_x0020_Size
$SizeMB = [math]::Round($Bytes / 1MB, 2)
if ($KeepStructure) {
$RelPath = $SourceUrl.Replace($LibraryUrl, "").Replace("/", "\").TrimStart("\")
$TargetFolder = Split-Path (Join-Path $DownloadPath $RelPath) -Parent
}
else {
$TargetFolder = $DownloadPath
$Suffix = 1
while (Test-Path (Join-Path $TargetFolder $FileName)) {
$Base = [System.IO.Path]::GetFileNameWithoutExtension($Item.FieldValues.FileLeafRef)
$Ext = [System.IO.Path]::GetExtension($Item.FieldValues.FileLeafRef)
$FileName = "$($Base)_$Suffix$Ext"
$Suffix = $Suffix + 1
}
}
if (-not (Test-Path $TargetFolder)) {
New-Item -Path $TargetFolder -ItemType Directory -Force | Out-Null
}
$PctDone = [math]::Round(($BytesDone / ($TotalMB * 1MB)) * 100, 1)
Write-Progress -Activity "Downloading files over $MinSizeMB MB" `
-Status "$Counter of $Total : $FileName ($SizeMB MB) - $PctDone% of data" `
-PercentComplete (($Counter / $Total) * 100)
$Attempt = 0
$Success = $false
while (-not $Success -and $Attempt -lt $MaxRetries) {
$Attempt = $Attempt + 1
try {
Get-PnPFile -Url $SourceUrl -Path $TargetFolder -FileName $FileName -AsFile -Force -ErrorAction Stop
$Success = $true
$Downloaded = $Downloaded + 1
$BytesDone = $BytesDone + $Bytes
Write-Host "[$Counter/$Total] $FileName ($SizeMB MB)" -ForegroundColor Green
$Log += [PSCustomObject]@{
FileName = $FileName
SizeMB = $SizeMB
FileType = $Item.FieldValues.File_x0020_Type
SharePointPath = $SourceUrl
Modified = $Item.FieldValues.Modified
Status = "Success"
}
}
catch {
if ($Attempt -ge $MaxRetries) {
$Failed = $Failed + 1
Write-Host "[$Counter/$Total] FAILED: $FileName ($SizeMB MB)" -ForegroundColor Red
$Log += [PSCustomObject]@{
FileName = $FileName
SizeMB = $SizeMB
FileType = $Item.FieldValues.File_x0020_Type
SharePointPath = $SourceUrl
Modified = $Item.FieldValues.Modified
Status = "Failed: $($_.Exception.Message)"
}
}
else {
Start-Sleep -Seconds ([math]::Pow(2, $Attempt) * 5)
}
}
}
}
Write-Progress -Activity "Downloading large files" -Completed
if ($Log.Count -gt 0) {
$Log | Export-Csv -Path $LogFile -NoTypeInformation -Encoding UTF8
}
$Elapsed = (Get-Date) - $StartTime
$DownloadedMB = [math]::Round($BytesDone / 1MB, 2)
$SpeedMBps = if ($Elapsed.TotalSeconds -gt 0) { [math]::Round($DownloadedMB / $Elapsed.TotalSeconds, 2) } else { 0 }
Write-Host "`n================ SUMMARY ================" -ForegroundColor Cyan
Write-Host " Size threshold : $MinSizeMB MB"
Write-Host " Files found : $Total"
Write-Host " Downloaded : $Downloaded" -ForegroundColor Green
Write-Host " Failed : $Failed" -ForegroundColor Red
Write-Host " Data pulled : $DownloadedMB MB"
Write-Host " Elapsed : $($Elapsed.ToString('hh\:mm\:ss'))"
Write-Host " Avg speed : $SpeedMBps MB/s"
Write-Host " Saved to : $DownloadPath"
Write-Host " Log file : $LogFile"
Write-Host "=========================================`n" -ForegroundColor Cyan
Disconnect-PnPOnline
Filtering on the server means SharePoint sends back only the matching files instead of metadata for the whole library. The same threshold caveat applies here: the size filter and the sort work best on indexed columns in SharePoint. The Where-Object line stays as a safety filter for folders. A few CAML variations:
Files between 5 MB and 100 MB:
$MinBytes = 5MB
$MaxBytes = 100MB
$CAML = @"
<View Scope='RecursiveAll'>
<Query>
<Where>
<And>
<Gt><FieldRef Name='File_x0020_Size'/><Value Type='Number'>$MinBytes</Value></Gt>
<Lt><FieldRef Name='File_x0020_Size'/><Value Type='Number'>$MaxBytes</Value></Lt>
</And>
</Where>
</Query>
</View>
"@
Large files of one type (MP4 videos here):
$MinBytes = 5MB
$CAML = @"
<View Scope='RecursiveAll'>
<Query>
<Where>
<And>
<Gt><FieldRef Name='File_x0020_Size'/><Value Type='Number'>$MinBytes</Value></Gt>
<Eq><FieldRef Name='File_x0020_Type'/><Value Type='Text'>mp4</Value></Eq>
</And>
</Where>
</Query>
</View>
"@
Large files not modified in over a year:
$MinBytes = 5MB
$CutOff = (Get-Date).AddYears(-1).ToString("yyyy-MM-ddTHH:mm:ssZ")
$CAML = @"
<View Scope='RecursiveAll'>
<Query>
<Where>
<And>
<Gt><FieldRef Name='File_x0020_Size'/><Value Type='Number'>$MinBytes</Value></Gt>
<Leq><FieldRef Name='Modified'/><Value Type='DateTime' IncludeTimeValue='TRUE'>$CutOff</Value></Leq>
</And>
</Where>
</Query>
</View>
"@
Note: the cutoff string adds a Z (UTC) to your local time, so it’s off by your UTC offset. That doesn’t matter for a one-year cutoff. If you need hour precision, use (Get-Date).AddYears(-1).ToUniversalTime().
Download files from one folder (with or without subfolders)
If you only need one folder, Get-PnPFolderItem is faster because it doesn’t scan the whole library. Check the path first with my guide to checking if a folder or file exists. This downloads the files in one folder only:
$FolderUrl = "/sites/Finance/Shared Documents/2026/Q1 Reports"
$DownloadPath = "C:\SPDownloads\Q1Reports"
New-Item -Path $DownloadPath -ItemType Directory -Force | Out-Null
$Files = Get-PnPFolderItem -FolderSiteRelativeUrl "Shared Documents/2026/Q1 Reports" -ItemType File
foreach ($File in $Files) {
Get-PnPFile -Url $File.ServerRelativeUrl -Path $DownloadPath `
-FileName $File.Name -AsFile -Force
}
The complete version adds an optional subfolder switch, sizes, retries and a log:
# ------------------------- CONFIGURATION -------------------------
$SiteURL = "https://contoso.sharepoint.com/sites/Finance"
$ClientId = "11111111-2222-3333-4444-555555555555"
$FolderUrl = "/sites/Finance/Shared Documents/2026/Q1 Reports"
$SiteRelative = "Shared Documents/2026/Q1 Reports"
$DownloadPath = "C:\SPDownloads\Q1Reports"
$IncludeSubfolders = $false
$MaxRetries = 3
$LogFile = "C:\SPDownloads\FolderDownload_$(Get-Date -f 'yyyyMMdd_HHmmss').csv"
# -----------------------------------------------------------------
try {
Connect-PnPOnline -Url $SiteURL -Interactive -ClientId $ClientId -ErrorAction Stop
Write-Host "Connected to $SiteURL" -ForegroundColor Green
}
catch {
Write-Host "Connection failed: $($_.Exception.Message)" -ForegroundColor Red
return
}
if (-not (Test-Path $DownloadPath)) {
New-Item -Path $DownloadPath -ItemType Directory -Force | Out-Null
}
function Get-FilesRecursive {
param (
[string]$RelativeUrl,
[string]$LocalPath
)
if (-not (Test-Path $LocalPath)) {
New-Item -Path $LocalPath -ItemType Directory -Force | Out-Null
}
$Items = Get-PnPFolderItem -FolderSiteRelativeUrl $RelativeUrl -ItemType File
foreach ($Item in $Items) {
[PSCustomObject]@{
Name = $Item.Name
Url = $Item.ServerRelativeUrl
Length = $Item.Length
LocalPath = $LocalPath
}
}
if ($IncludeSubfolders) {
$SubFolders = Get-PnPFolderItem -FolderSiteRelativeUrl $RelativeUrl -ItemType Folder |
Where-Object { $_.Name -ne "Forms" }
foreach ($Sub in $SubFolders) {
Get-FilesRecursive -RelativeUrl "$RelativeUrl/$($Sub.Name)" -LocalPath (Join-Path $LocalPath $Sub.Name)
}
}
}
Write-Host "Reading folder '$FolderUrl'..." -ForegroundColor Cyan
$Files = @(Get-FilesRecursive -RelativeUrl $SiteRelative -LocalPath $DownloadPath)
$Total = $Files.Count
if ($Total -eq 0) {
Write-Host "No files found in the specified folder." -ForegroundColor Yellow
Disconnect-PnPOnline
return
}
$TotalMB = [math]::Round((($Files | Measure-Object -Property Length -Sum).Sum / 1MB), 2)
Write-Host "Found $Total files ($TotalMB MB). Starting download...`n" -ForegroundColor Cyan
$Downloaded = 0
$Failed = 0
$Counter = 0
$Log = @()
foreach ($File in $Files) {
$Counter = $Counter + 1
$FileName = $File.Name -replace '[\\/:*?"<>|]', '_'
$SizeMB = [math]::Round($File.Length / 1MB, 2)
Write-Progress -Activity "Downloading from '$SiteRelative'" `
-Status "$Counter of $Total : $FileName" `
-PercentComplete (($Counter / $Total) * 100)
$Attempt = 0
$Success = $false
while (-not $Success -and $Attempt -lt $MaxRetries) {
$Attempt = $Attempt + 1
try {
Get-PnPFile -Url $File.Url -Path $File.LocalPath -FileName $FileName -AsFile -Force -ErrorAction Stop
$Success = $true
$Downloaded = $Downloaded + 1
Write-Host "[$Counter/$Total] $FileName ($SizeMB MB)" -ForegroundColor Green
$Log += [PSCustomObject]@{
FileName = $FileName
SizeMB = $SizeMB
SharePointPath = $File.Url
LocalFolder = $File.LocalPath
Status = "Success"
}
}
catch {
if ($Attempt -ge $MaxRetries) {
$Failed = $Failed + 1
Write-Host "[$Counter/$Total] FAILED: $FileName" -ForegroundColor Red
$Log += [PSCustomObject]@{
FileName = $FileName
SizeMB = $SizeMB
SharePointPath = $File.Url
LocalFolder = $File.LocalPath
Status = "Failed: $($_.Exception.Message)"
}
}
else {
Start-Sleep -Seconds ([math]::Pow(2, $Attempt) * 5)
}
}
}
}
Write-Progress -Activity "Downloading folder" -Completed
if ($Log.Count -gt 0) {
$Log | Export-Csv -Path $LogFile -NoTypeInformation -Encoding UTF8
}
Write-Host "`n============== SUMMARY ==============" -ForegroundColor Cyan
Write-Host " Source folder : $FolderUrl"
Write-Host " Subfolders : $IncludeSubfolders"
Write-Host " Files found : $Total"
Write-Host " Downloaded : $Downloaded" -ForegroundColor Green
Write-Host " Failed : $Failed" -ForegroundColor Red
Write-Host " Total size : $TotalMB MB"
Write-Host " Saved to : $DownloadPath"
Write-Host " Log file : $LogFile"
Write-Host "=====================================`n" -ForegroundColor Cyan
Disconnect-PnPOnline
And this recursive function downloads a folder with all its subfolders:
$FolderUrl = "/sites/Finance/Shared Documents/2026"
$DownloadPath = "C:\SPDownloads\2026"
function Download-SPFolder {
param($SPFolderUrl, $LocalPath)
if (-not (Test-Path $LocalPath)) {
New-Item -Path $LocalPath -ItemType Directory -Force | Out-Null
}
# Download files in the current folder
Get-PnPFolderItem -FolderSiteRelativeUrl $SPFolderUrl -ItemType File | ForEach-Object {
Get-PnPFile -Url $_.ServerRelativeUrl -Path $LocalPath -FileName $_.Name -AsFile -Force
Write-Host "Downloaded: $($_.Name)"
}
# Recurse into subfolders
Get-PnPFolderItem -FolderSiteRelativeUrl $SPFolderUrl -ItemType Folder | ForEach-Object {
Download-SPFolder -SPFolderUrl "$SPFolderUrl/$($_.Name)" `
-LocalPath (Join-Path $LocalPath $_.Name)
}
}
Download-SPFolder -SPFolderUrl "Shared Documents/2026" -LocalPath $DownloadPath
-FolderSiteRelativeUrl is relative to the site, not the server: use Shared Documents/Reports, not /sites/Finance/Shared Documents/Reports. If a folder holds one project’s files with shared metadata, weigh SharePoint document sets vs folders.
Note: the recursive function doesn’t skip the hidden Forms folder. That’s fine when you start inside a folder, but point it at the library root and it also walks Forms. Add | Where-Object { $_.Name -ne "Forms" } to the folder line, as the complete script does.
Two smaller points: the $FolderUrl variable isn’t used, and current PnP PowerShell also offers a -Recursive switch on Get-PnPFolderItem. Folder permissions can block parts of the tree; see getting SharePoint folder permissions.
Download files older than 30 days
This is my go-to for archiving. It filters on Modified; switch to Created if that fits better. For long-term keeping, pair it with site retention policies in SharePoint rather than relying on local copies:
$DaysOld = 30
$CutOff = (Get-Date).AddDays(-$DaysOld)
$OldFiles = Get-PnPListItem -List $LibraryName -PageSize 2000 |
Where-Object {
$_.FileSystemObjectType -eq "File" -and
[datetime]$_.FieldValues.Modified -lt $CutOff
}
Write-Host "Files older than $DaysOld days: $($OldFiles.Count)"
foreach ($Item in $OldFiles) {
Get-PnPFile -Url $Item.FieldValues.FileRef -Path $DownloadPath `
-FileName $Item.FieldValues.FileLeafRef -AsFile -Force
}
The server-side CAML version is faster on big libraries (index the Modified column if the library is over 5,000 items):
$CutOffString = (Get-Date).AddDays(-30).ToString("yyyy-MM-ddTHH:mm:ssZ")
$CAML = @"
<View Scope='RecursiveAll'>
<Query>
<Where>
<Lt>
<FieldRef Name='Modified'/>
<Value Type='DateTime' IncludeTimeValue='TRUE'>$CutOffString</Value>
</Lt>
</Where>
</Query>
</View>
"@
$OldFiles = Get-PnPListItem -List $LibraryName -Query $CAML -PageSize 2000
Download only files modified in the last 7 days (incremental)
For a nightly sync, pull only what changed. If you’d rather not run scripts at all, you can download SharePoint files with Power Automate instead.
$Since = (Get-Date).AddDays(-7)
$RecentFiles = Get-PnPListItem -List $LibraryName -PageSize 2000 |
Where-Object {
$_.FileSystemObjectType -eq "File" -and
[datetime]$_.FieldValues.Modified -ge $Since
}
foreach ($Item in $RecentFiles) {
Get-PnPFile -Url $Item.FieldValues.FileRef -Path $DownloadPath `
-FileName $Item.FieldValues.FileLeafRef -AsFile -Force
}
Smarter still, skip files whose local copy is already up to date:
foreach ($Item in $Items) {
$LocalFile = Join-Path $DownloadPath $Item.FieldValues.FileLeafRef
$SPModified = [datetime]$Item.FieldValues.Modified
if (Test-Path $LocalFile) {
$LocalModified = (Get-Item $LocalFile).LastWriteTime
if ($LocalModified -ge $SPModified) {
Write-Host "Skipping (unchanged): $($Item.FieldValues.FileLeafRef)" -ForegroundColor DarkGray
continue
}
}
Get-PnPFile -Url $Item.FieldValues.FileRef -Path $DownloadPath `
-FileName $Item.FieldValues.FileLeafRef -AsFile -Force
}
Note: SharePoint returns Modified in UTC, while LastWriteTime is local time, so files changed within your UTC offset can download again. Comparing (Get-Item $LocalFile).LastWriteTimeUtc against $SPModified.ToUniversalTime() removes that gap. The loop also uses $Items from the main script, so run that query first.
Download files created or modified by a specific user
This one comes up during offboarding, when you need everything a departing employee uploaded. Change Author to Editor for files they last modified. Their OneDrive is a separate store; see OneDrive for Business features. Their personal files live elsewhere, so it helps to know when to use OneDrive vs SharePoint:
$TargetUser = "john.doe@contoso.com"
$UserFiles = Get-PnPListItem -List $LibraryName -PageSize 2000 |
Where-Object {
$_.FileSystemObjectType -eq "File" -and
$_.FieldValues.Author.Email -eq $TargetUser
}
foreach ($Item in $UserFiles) {
Get-PnPFile -Url $Item.FieldValues.FileRef -Path $DownloadPath `
-FileName $Item.FieldValues.FileLeafRef -AsFile -Force
}
The complete script resolves the user to an ID and filters on the server:
# ------------------------- CONFIGURATION -------------------------
$SiteURL = "https://contoso.sharepoint.com/sites/Finance"
$ClientId = "11111111-2222-3333-4444-555555555555"
$LibraryName = "Documents"
$DownloadPath = "C:\SPDownloads\UserFiles"
$TargetUser = "john.doe@contoso.com"
$MatchField = "Author" # Author = created by, Editor = last modified by
$KeepStructure = $true
$MaxRetries = 3
$LogFile = "C:\SPDownloads\UserFiles_$(Get-Date -f 'yyyyMMdd_HHmmss').csv"
# -----------------------------------------------------------------
try {
Connect-PnPOnline -Url $SiteURL -Interactive -ClientId $ClientId -ErrorAction Stop
Write-Host "Connected to $SiteURL" -ForegroundColor Green
}
catch {
Write-Host "Connection failed: $($_.Exception.Message)" -ForegroundColor Red
return
}
try {
$User = Get-PnPUser | Where-Object { $_.Email -eq $TargetUser }
if ($null -eq $User) {
$User = New-PnPUser -LoginName $TargetUser -ErrorAction Stop
}
$UserId = $User.Id
Write-Host "Resolved user '$TargetUser' to ID $UserId" -ForegroundColor Gray
}
catch {
Write-Host "Could not resolve user '$TargetUser': $($_.Exception.Message)" -ForegroundColor Red
Disconnect-PnPOnline
return
}
if (-not (Test-Path $DownloadPath)) {
New-Item -Path $DownloadPath -ItemType Directory -Force | Out-Null
}
$List = Get-PnPList -Identity $LibraryName -Includes RootFolder
$LibraryUrl = $List.RootFolder.ServerRelativeUrl
$CAML = @"
<View Scope='RecursiveAll'>
<Query>
<Where>
<Eq>
<FieldRef Name='$MatchField' LookupId='TRUE'/>
<Value Type='Integer'>$UserId</Value>
</Eq>
</Where>
</Query>
<ViewFields>
<FieldRef Name='FileRef'/>
<FieldRef Name='FileLeafRef'/>
<FieldRef Name='File_x0020_Size'/>
<FieldRef Name='Author'/>
<FieldRef Name='Editor'/>
<FieldRef Name='Created'/>
<FieldRef Name='Modified'/>
</ViewFields>
</View>
"@
Write-Host "Searching for files where $MatchField = $TargetUser..." -ForegroundColor Cyan
$UserFiles = Get-PnPListItem -List $LibraryName -Query $CAML -PageSize 2000 |
Where-Object { $_.FileSystemObjectType -eq "File" }
$Total = @($UserFiles).Count
if ($Total -eq 0) {
Write-Host "No files found for user '$TargetUser'." -ForegroundColor Yellow
Disconnect-PnPOnline
return
}
$TotalMB = [math]::Round((($UserFiles | ForEach-Object { [int64]$_.FieldValues.File_x0020_Size } | Measure-Object -Sum).Sum / 1MB), 2)
Write-Host "Found $Total files ($TotalMB MB). Starting download...`n" -ForegroundColor Cyan
$Downloaded = 0
$Failed = 0
$Counter = 0
$Log = @()
foreach ($Item in $UserFiles) {
$Counter = $Counter + 1
$SourceUrl = $Item.FieldValues.FileRef
$FileName = $Item.FieldValues.FileLeafRef -replace '[\\/:*?"<>|]', '_'
$SizeMB = [math]::Round([int64]$Item.FieldValues.File_x0020_Size / 1MB, 2)
if ($KeepStructure) {
$RelPath = $SourceUrl.Replace($LibraryUrl, "").Replace("/", "\").TrimStart("\")
$TargetFolder = Split-Path (Join-Path $DownloadPath $RelPath) -Parent
}
else {
$TargetFolder = $DownloadPath
$Suffix = 1
while (Test-Path (Join-Path $TargetFolder $FileName)) {
$Base = [System.IO.Path]::GetFileNameWithoutExtension($Item.FieldValues.FileLeafRef)
$Ext = [System.IO.Path]::GetExtension($Item.FieldValues.FileLeafRef)
$FileName = "$($Base)_$Suffix$Ext"
$Suffix = $Suffix + 1
}
}
if (-not (Test-Path $TargetFolder)) {
New-Item -Path $TargetFolder -ItemType Directory -Force | Out-Null
}
Write-Progress -Activity "Downloading files for $TargetUser" `
-Status "$Counter of $Total : $FileName" `
-PercentComplete (($Counter / $Total) * 100)
$Attempt = 0
$Success = $false
while (-not $Success -and $Attempt -lt $MaxRetries) {
$Attempt = $Attempt + 1
try {
Get-PnPFile -Url $SourceUrl -Path $TargetFolder -FileName $FileName -AsFile -Force -ErrorAction Stop
$Success = $true
$Downloaded = $Downloaded + 1
Write-Host "[$Counter/$Total] $FileName ($SizeMB MB)" -ForegroundColor Green
$Log += [PSCustomObject]@{
FileName = $FileName
SizeMB = $SizeMB
CreatedBy = $Item.FieldValues.Author.LookupValue
ModifiedBy = $Item.FieldValues.Editor.LookupValue
Created = $Item.FieldValues.Created
Modified = $Item.FieldValues.Modified
SharePointPath = $SourceUrl
Status = "Success"
}
}
catch {
if ($Attempt -ge $MaxRetries) {
$Failed = $Failed + 1
Write-Host "[$Counter/$Total] FAILED: $FileName" -ForegroundColor Red
$Log += [PSCustomObject]@{
FileName = $FileName
SizeMB = $SizeMB
CreatedBy = $Item.FieldValues.Author.LookupValue
ModifiedBy = $Item.FieldValues.Editor.LookupValue
Created = $Item.FieldValues.Created
Modified = $Item.FieldValues.Modified
SharePointPath = $SourceUrl
Status = "Failed: $($_.Exception.Message)"
}
}
else {
Start-Sleep -Seconds ([math]::Pow(2, $Attempt) * 5)
}
}
}
}
Write-Progress -Activity "Downloading user files" -Completed
if ($Log.Count -gt 0) {
$Log | Export-Csv -Path $LogFile -NoTypeInformation -Encoding UTF8
}
Write-Host "`n============== SUMMARY ==============" -ForegroundColor Cyan
Write-Host " User : $TargetUser"
Write-Host " Match field : $MatchField"
Write-Host " Files found : $Total"
Write-Host " Downloaded : $Downloaded" -ForegroundColor Green
Write-Host " Failed : $Failed" -ForegroundColor Red
Write-Host " Total size : $TotalMB MB"
Write-Host " Saved to : $DownloadPath"
Write-Host " Log file : $LogFile"
Write-Host "=====================================`n" -ForegroundColor Cyan
Disconnect-PnPOnline
Note: if the user isn’t in the site’s user list yet, New-PnPUser adds them, which is a write to the site and needs more than Read access. If you only have Read, use the Where-Object version above.
Download files by a metadata column value
Most business libraries have columns such as Department, Status or Project. This downloads only files whose Status column is Approved:
# Download only files where the "Status" choice column equals "Approved"
$Approved = Get-PnPListItem -List $LibraryName -PageSize 2000 |
Where-Object { $_.FieldValues.Status -eq "Approved" }
foreach ($Item in $Approved) {
Get-PnPFile -Url $Item.FieldValues.FileRef -Path $DownloadPath `
-FileName $Item.FieldValues.FileLeafRef -AsFile -Force
}
Scripts need the column’s internal name, not its display name; spaces become _x0020_. Filtering on choice columns in a document library works the same way. This lists them, and my post on getting SharePoint column internal names explains the details:
Get-PnPField -List $LibraryName |
Select-Object Title, InternalName, TypeAsString |
Sort-Object Title
Download files by name pattern
Need every file with Invoice in its name? A -like wildcard does it. Power Automate users can do the same with a filename contains check in Power Automate.
$Pattern = "*Invoice*"
$Matched = Get-PnPListItem -List $LibraryName -PageSize 2000 |
Where-Object {
$_.FileSystemObjectType -eq "File" -and
$_.FieldValues.FileLeafRef -like $Pattern
}
foreach ($Item in $Matched) {
Get-PnPFile -Url $Item.FieldValues.FileRef -Path $DownloadPath `
-FileName $Item.FieldValues.FileLeafRef -AsFile -Force
}
Download a single file with Get-PnPFile
For one document, no loop is needed. Get-PnPFile accepts a server-relative or site-relative URL:
Get-PnPFile -Url "/sites/Finance/Shared Documents/Budget 2026.xlsx" `
-Path "C:\SPDownloads" `
-FileName "Budget 2026.xlsx" -AsFile -Force
To read a text file’s contents without saving it, use -AsString. If users only need a nicer button, you can try customizing the SharePoint Download button with SPFx. For app makers, there’s also a guide to downloading a file from a SharePoint library in Power Apps.
$Content = Get-PnPFile -Url "/sites/Finance/Shared Documents/Notes.txt" -AsString
Write-Host $Content
Zip the downloaded files
For a handover or a monthly backup, compress the folder once the download finishes. Microsoft documents a 2 GB maximum file size for Compress-Archive, and it skips hidden files, so very large folders are better zipped with a dedicated tool. If downloads are blocked by policy, check whether someone set up restrict or block download in SharePoint.
$DownloadPath = "C:\SPDownloads\Finance"
$ZipPath = "C:\SPDownloads\Finance_$(Get-Date -Format 'yyyyMMdd').zip"
# ... run your download loop first ...
Compress-Archive -Path "$DownloadPath\*" -DestinationPath $ZipPath -Force
Write-Host "Archive created: $ZipPath" -ForegroundColor Green
Download files from every document library on a site
To copy a whole site, loop through each visible document library and skip the system ones. Each library goes into its own local folder. To repeat this across many sites, start from my list of getting all SharePoint Online sites with PnP PowerShell.
Before a full-site copy, I check storage and owners with SharePoint Online site details in PowerShell so I know how big the job is.
$ExcludeLists = @("Form Templates", "Site Assets", "Style Library",
"Site Pages", "Preservation Hold Library", "Customized Reports")
$Libraries = Get-PnPList | Where-Object {
$_.BaseTemplate -eq 101 -and $_.Hidden -eq $false -and
$ExcludeLists -notcontains $_.Title
}
foreach ($Lib in $Libraries) {
$LibFolder = Join-Path "C:\SPDownloads" ($Lib.Title -replace '[\\/:*?"<>|]', '_')
New-Item -Path $LibFolder -ItemType Directory -Force | Out-Null
Write-Host "`n=== Library: $($Lib.Title) ===" -ForegroundColor Yellow
Get-PnPListItem -List $Lib.Title -PageSize 2000 |
Where-Object { $_.FileSystemObjectType -eq "File" } |
ForEach-Object {
Get-PnPFile -Url $_.FieldValues.FileRef -Path $LibFolder `
-FileName $_.FieldValues.FileLeafRef -AsFile -Force
}
}
Production script: logging, retries and throttling
The scripts above are fine for one-off jobs. For 20,000 files, SharePoint Online will throttle you with HTTP 429 or 503 responses, so you need retries and a log. More reusable scripts are in my collection of PnP PowerShell scripts for SharePoint developers.
# ---------- CONFIGURATION ----------
$SiteURL = "https://contoso.sharepoint.com/sites/Finance"
$ClientId = "11111111-2222-3333-4444-555555555555"
$LibraryName = "Documents"
$DownloadPath = "C:\SPDownloads\Finance"
$LogFile = "C:\SPDownloads\DownloadLog_$(Get-Date -f 'yyyyMMdd_HHmmss').csv"
$MaxRetries = 3
# -----------------------------------
function Write-Log {
param($File, $Status, $Message)
[PSCustomObject]@{
Timestamp = (Get-Date -Format "yyyy-MM-dd HH:mm:ss")
File = $File
Status = $Status
Message = $Message
} | Export-Csv -Path $LogFile -NoTypeInformation -Append
}
Connect-PnPOnline -Url $SiteURL -Interactive -ClientId $ClientId
$List = Get-PnPList -Identity $LibraryName -Includes RootFolder
$LibraryUrl = $List.RootFolder.ServerRelativeUrl
$Items = Get-PnPListItem -List $LibraryName -PageSize 2000 |
Where-Object { $_.FileSystemObjectType -eq "File" }
$Total = $Items.Count
$Ok = 0; $Fail = 0; $n = 0
foreach ($Item in $Items) {
$n++
$FileUrl = $Item.FieldValues.FileRef
$FileName = $Item.FieldValues.FileLeafRef
$RelPath = $FileUrl.Replace($LibraryUrl, "").Replace("/", "\").TrimStart("\")
$LocalFile = Join-Path $DownloadPath $RelPath
$LocalFolder = Split-Path $LocalFile -Parent
if (-not (Test-Path $LocalFolder)) {
New-Item -Path $LocalFolder -ItemType Directory -Force | Out-Null
}
Write-Progress -Activity "Downloading from $LibraryName" `
-Status "$n / $Total : $FileName" `
-PercentComplete (($n / $Total) * 100)
$Attempt = 0
$Done = $false
while (-not $Done -and $Attempt -lt $MaxRetries) {
$Attempt++
try {
Get-PnPFile -Url $FileUrl -Path $LocalFolder -FileName $FileName -AsFile -Force -ErrorAction Stop
Write-Log -File $FileUrl -Status "Success" -Message "Attempt $Attempt"
$Ok++; $Done = $true
}
catch {
if ($Attempt -ge $MaxRetries) {
Write-Log -File $FileUrl -Status "Failed" -Message $_.Exception.Message
Write-Host "FAILED: $FileName" -ForegroundColor Red
$Fail++
}
else {
# Exponential back-off for throttling (429 / 503)
Start-Sleep -Seconds ([math]::Pow(2, $Attempt) * 5)
}
}
}
}
Write-Host "`n===== SUMMARY =====" -ForegroundColor Cyan
Write-Host "Total files : $Total"
Write-Host "Downloaded : $Ok" -ForegroundColor Green
Write-Host "Failed : $Fail" -ForegroundColor Red
Write-Host "Log file : $LogFile"
Disconnect-PnPOnline
When SharePoint throttles a client, retrying immediately makes it worse. The growing pause (10 seconds, then 20) gives the service room to recover, and the CSV log tells you exactly which files to rerun. As noted earlier, raise $MaxRetries to 4 for a third, 40-second wait.
Fix long file paths (the 260-character limit)
Windows limits classic paths to 260 characters, while SharePoint allows much longer ones, so deep folders fail with a path-too-long error. There are two fixes. The first is to enable long paths in Windows from an elevated window. Flattening deep trees by moving folders in SharePoint Online also helps.
New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" `
-Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force
Microsoft notes that this setting only helps apps that are long-path aware, and a reboot might be needed before every process picks it up. The second fix is a short download root (C:\SP) plus trimming names that are too long:
$MaxPath = 240
if ($LocalFile.Length -gt $MaxPath) {
$Ext = [System.IO.Path]::GetExtension($FileName)
$Base = [System.IO.Path]::GetFileNameWithoutExtension($FileName)
$Allowed = $MaxPath - $LocalFolder.Length - $Ext.Length - 1
if ($Allowed -gt 10) {
$FileName = $Base.Substring(0, $Allowed) + $Ext
$LocalFile = Join-Path $LocalFolder $FileName
}
}
Also replace characters Windows doesn’t allow in file and folder names:
$SafeName = $Name -replace '[\\/:*?"<>|]', '_'
Speed up downloads from large libraries
With 50,000+ files, these habits save hours. Indexes make server-side filters possible, and SharePoint can enable automatic indexing on SharePoint for list views.
- Always use -PageSize. 2,000 is a good balance between round trips and memory.
- Filter on the server with CAML when the column is indexed; Where-Object downloads every item’s metadata first.
- Request only the fields you need (below). -Fields isn’t available together with -Query; use ViewFields in the CAML instead.
- Download to a local SSD, not a synced OneDrive folder, which uploads every file straight back.
- Split by top-level folder so one failure doesn’t restart everything, and run big jobs overnight.
$Items = Get-PnPListItem -List $LibraryName -PageSize 2000 `
-Fields "FileRef", "FileLeafRef", "File_x0020_Size", "Modified"
Verify that the download is complete
Never trust a script that just says it finished. Compare the number of files in SharePoint with the number on disk:
$SPCount = (Get-PnPListItem -List $LibraryName -PageSize 2000 |
Where-Object { $_.FileSystemObjectType -eq "File" }).Count
$LocalCount = (Get-ChildItem -Path $DownloadPath -Recurse -File).Count
Write-Host "SharePoint files : $SPCount"
Write-Host "Local files : $LocalCount"
if ($SPCount -eq $LocalCount) {
Write-Host "MATCH - download complete." -ForegroundColor Green
} else {
Write-Host "MISMATCH - check the log file." -ForegroundColor Red
}
For a stricter check, compare the total size in bytes:
$SPBytes = (Get-PnPListItem -List $LibraryName -PageSize 2000 |
Where-Object { $_.FileSystemObjectType -eq "File" } |
Measure-Object -Property { [int64]$_.FieldValues.File_x0020_Size } -Sum).Sum
$LocalBytes = (Get-ChildItem $DownloadPath -Recurse -File | Measure-Object Length -Sum).Sum
Write-Host "SharePoint: $([math]::Round($SPBytes/1GB,2)) GB"
Write-Host "Local : $([math]::Round($LocalBytes/1GB,2)) GB"
A flat download with renamed duplicates still matches on count. If the numbers differ, the CSV log shows which files failed; rerun only those. To send files the other way, here’s how to upload files to a SharePoint document library.
If the real goal is moving content to another Microsoft 365 tenant rather than to disk, look at copying SharePoint files to another tenant instead.
Schedule the download to run every night
Once the script works, save it as Download-SPFiles.ps1, switch the connection to certificate-based app-only authentication (an interactive sign-in can’t run unattended) and register a scheduled task. More SharePoint automation lives in the SharePoint PowerShell tutorials:
$Action = New-ScheduledTaskAction -Execute "pwsh.exe" `
-Argument "-NoProfile -ExecutionPolicy Bypass -File C:\Scripts\Download-SPFiles.ps1"
$Trigger = New-ScheduledTaskTrigger -Daily -At 2:00AM
Register-ScheduledTask -TaskName "SharePoint Nightly Download" `
-Action $Action -Trigger $Trigger -RunLevel Highest -Description "Downloads files from SharePoint library"
Note: without -User and -Password (or a -Principal), the task runs only while you’re signed in to Windows, and -RunLevel Highest needs an elevated window to register. Pair it with the last-7-days filter so each night pulls only the changes.
Troubleshooting common download errors
- “A parameter cannot be found that matches parameter name ‘Interactive'”: you ran the app registration line with -Interactive. Remove it, as noted in the prerequisites.
- “File Not Found” from Get-PnPFile: the URL is wrong. Use FileRef as is; a site-relative path must start after the site URL, such as Shared Documents/Budget.xlsx.
- “The attempted operation is prohibited because it exceeds the list view threshold”: a CAML filter hit a non-indexed column in a library over 5,000 items. Index the column or filter with Where-Object.
- Access denied: your account or app lacks Read on that library or folder, or the app-only permission wasn’t consented.
- Some files are locked or checked out: checked-out files download the last checked-in version. A file someone is editing can also report the file is locked for shared use error.
- Path too long or invalid characters: use the long path fixes and the character replace above.
- Connection drops in older CSOM-based scripts: see the ExecuteQuery unable to connect error; PnP PowerShell handles sign-in for you.
If you want guided practice with scripts like these, the SharePoint PowerShell training course walks through them on a real tenant.
If you’re automating document libraries with PnP PowerShell, these guides pair well with this one:
Frequently Asked Questions
How do I download all files from a SharePoint library using PowerShell?
Connect with Connect-PnPOnline using your own app’s -ClientId, read the files with Get-PnPListItem -PageSize 2000, skip folder items, and save each file with Get-PnPFile -Url FileRef -Path folder -Filename FileLeafRef -AsFile -Force.
What does Get-PnPFile -AsFile do?
-AsFile saves the file to the local folder in -Path with the name in -Filename, and -Force overwrites an existing copy. Without it, Get-PnPFile returns a file object. The Get-PnPFile reference lists the -AsString, -AsListItem and -AsMemoryStream options.
How many files can I download from SharePoint at once in the browser?
Up to 10,000 files per zip download, with compressed files capped at 10 GB each and 20 GB overall. Microsoft lists every limit in its download files and folders support article. PowerShell has no such cap.
Can I download a SharePoint folder with all subfolders using PnP PowerShell?
Yes. Use Get-PnPFolderItem with -ItemType File in a recursive function, or the -Recursive switch, and call Get-PnPFile -AsFile for each file, creating matching local folders as you go.
Why does Connect-PnPOnline -Interactive fail without a client ID?
Since September 9, 2024, PnP PowerShell needs an Entra ID app registered in your own tenant. Register it once with Register-PnPEntraIDAppForInteractiveLogin and pass its ID with -ClientId.

Hey! I’m Bijay Kumar, founder of SPGuides.com and a Microsoft Business Applications MVP (Power Automate, Power Apps). I launched this site in 2020 because I truly enjoy working with SharePoint, Power Platform, and SharePoint Framework (SPFx), and wanted to share that passion through step-by-step tutorials, guides, and training videos. My mission is to help you learn these technologies so you can utilize SharePoint, enhance productivity, and potentially build business solutions along the way.