If you’ve ever tried to download a few thousand files from a SharePoint Online document library using the browser, you already know how painful it is.
You select the files, click Download, SharePoint starts zipping them, and then… it fails. Or it silently skips files. Or it hits the 250 MB / 10,000 file limit and gives up.
There’s a much better way – PnP PowerShell.
In this tutorial, I will show you how to download all documents from a SharePoint Online document library using PnP PowerShell, along with a lot of practical variations that people actually need in real projects – such as downloading only PDF files, downloading only files larger than 5 MB, downloading files from a specific folder, downloading files older than 30 days, and more.
Everything here is copy-paste ready. You only need to change a few variables at the top of each script.
Let’s get started.
What You Need Before You Start (Prerequisites)
Before running any script in this guide, make sure you have the following ready.
1. PowerShell 7 (recommended)
PnP PowerShell (version 2.x and above) requires PowerShell 7. Windows PowerShell 5.1 works only with the older, deprecated SharePointPnPPowerShellOnline module, which I do not recommend for new work.
To check your version, run:
$PSVersionTable.PSVersion
If you see 5.1, install PowerShell 7 first.
2. The PnP.PowerShell module
Install it with this command:
Install-Module -Name PnP.PowerShell -Scope CurrentUser -Force
To confirm it installed correctly:
Get-Module PnP.PowerShell -ListAvailable | Select-Object Name, Version
3. Permissions on the SharePoint site
You need at least Read access to the document library you want to download from. If you’re a Site Owner or SharePoint Administrator, you’re good.
4. An Entra ID (Azure AD) app registration
This is the part that trips up most beginners in PnP PowerShell v2 and v3. The old multi-tenant PnP app no longer works, so you must register your own app once per tenant.
Run this one-time command (you need to be a Global Administrator or Application Administrator):
Register-PnPEntraIDAppForInteractiveLogin -ApplicationName "PnP Rocks" -Tenant contoso.onmicrosoft.com -Interactive
Copy the Client ID that gets returned. You will use it in every connection command below.
Note: If your tenant already has a registered PnP app, ask your admin for the Client ID instead of creating a new one.
5. Connect to Your SharePoint Site
Every script in this tutorial starts with a connection. The most common method is interactive login (a browser window pops up, and you sign in normally).
$SiteURL = "https://contoso.sharepoint.com/sites/Finance"
$ClientId = "11111111-2222-3333-4444-555555555555"
Connect-PnPOnline -Url $SiteURL -Interactive -ClientId $ClientId
To verify the connection worked:
Get-PnPWeb | Select-Object Title, Url
If you see your site title, you’re connected.
Running it unattended? If you plan to schedule the script (Task Scheduler or Azure Automation), use certificate-based app-only authentication instead:
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)
App-only auth needs the Sites.Read.All (or Sites.FullControl.All) Graph/SharePoint API permission granted with admin consent.
6. Find the Correct Library Name and URL
Before downloading, confirm the exact name of your document library. Display names and internal URLs are often different (for example, a library shown as “Project Files” may have the URL /ProjectFiles).
Run this to list every document library on the site:
Get-PnPList | Where-Object { $_.BaseTemplate -eq 101 } |
Select-Object Title, DefaultViewUrl, ItemCount
Make a note of the Title and the folder part of the DefaultViewUrl. You’ll need these next.
Check out Get SharePoint Document Library Size Using PnP PowerShell
Download All Files from a SharePoint Document Library
This is the core script. It downloads every file from the SharePoint document library, including all files inside subfolders, and recreates the exact same folder structure on your local drive.
# ---------- 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
How this script works:
Get-PnPListItemwith-PageSize 2000pulls items in batches so you don’t hit the 5,000 item list view threshold.FileSystemObjectType -eq "File"filters out folder objects, so you only process actual documents.FileRefis the server-relative URL of the file (for example,/sites/Finance/Shared Documents/2024/Report.pdf).- Replacing the library URL with an empty string gives you the relative path, which is then used to recreate the folder tree locally.
-AsFile -Forcewrites the file to disk and overwrites it if it already exists.
That’s it. Run it once and your entire library lands on your local drive with folders intact.
You can see the screenshot below for your reference.

Check out Upload File to SharePoint Document Library With Metadata in SPFx
Download All Files Without Keeping the Folder Structure (Flat Download)
Sometimes you just want every file dumped into one folder. The problem is duplicate file names in different subfolders will overwrite each other.
The script below handles that by adding a numeric suffix when a duplicate name is found.
<#
=====================================================================
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
Check out Delete Files From SharePoint Document Library Using Rest API in Power Automate
Download Only PDF Files from a SharePoint Library
This is one of the most common requests. You only want the PDFs – not the Word docs, not the images.
The cleanest approach is to filter on the File_x0020_Type field, which stores the extension without the dot.
$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
}
Faster method using a CAML query
If your library has tens of thousands of items, filtering on the server side is dramatically faster because SharePoint returns only the matching rows:
$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
}
Note: Scope='RecursiveAll' is important – without it, CAML only looks at the root folder and ignores subfolders.
Here is the complete PnP PowerShell script:
# ------------------------- 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
Read Create Folders and Subfolders in SharePoint document library
Download Only Excel Files (or Any Set of File Types)
Excel files can be .xlsx, .xls, .xlsm, or .csv, so you need to check for multiple extensions.
$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
}
You can reuse this pattern for any file type. For Word documents use @("docx","doc"), for images use @("jpg","jpeg","png","gif"), and for PowerPoint use @("pptx","ppt").
Download Files Larger Than 5 MB
Useful when you’re cleaning up storage or auditing which large files are eating your site quota.
The file size is stored in the File_x0020_Size field, in bytes.
$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
}
Tip: If you only want a report of large files (without downloading them), replace the download line with an export:
$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
Here is the complete PnP PowerShell script:
# ------------------------- 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
Here, the first script pulls every item in the library and then filters in PowerShell. That works, but on a 50,000-item library you’re transferring metadata for all 50,000 items just to find the 300 that are over 5 MB.
The CAML version pushes the File_x0020_Size comparison to the server. SharePoint returns only the matching items. The <OrderBy> clause also sorts largest-first, so you see the biggest offenders immediately and can cancel early if the total looks wrong.
One thing to note: File_x0020_Size is not stored on folder objects, so the Where-Object { $_.FileSystemObjectType -eq "File" } line stays in as a safety filter.
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 a Specific Type
$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>
"@
Check out Create and Delete SharePoint Document Library Using PowerShell
Download Files from a Specific Folder in SharePoint
If you only need one folder (and not the entire library), use Get-PnPFolderItem. This is much faster because it doesn’t scan the whole library.
Download files from one folder only (no subfolders):
$FolderUrl = "/sites/Finance/Shared Documents/2025/Q1 Reports"
$DownloadPath = "C:\SPDownloads\Q1Reports"
New-Item -Path $DownloadPath -ItemType Directory -Force | Out-Null
$Files = Get-PnPFolderItem -FolderSiteRelativeUrl "Shared Documents/2025/Q1 Reports" -ItemType File
foreach ($File in $Files) {
Get-PnPFile -Url $File.ServerRelativeUrl -Path $DownloadPath `
-FileName $File.Name -AsFile -Force
}
Here is the complete PnP PowerShell script:
# ------------------------- CONFIGURATION -------------------------
$SiteURL = "https://contoso.sharepoint.com/sites/Finance"
$ClientId = "11111111-2222-3333-4444-555555555555"
$FolderUrl = "/sites/Finance/Shared Documents/2025/Q1 Reports"
$SiteRelative = "Shared Documents/2025/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
Download a folder including all its subfolders (recursive):
$FolderUrl = "/sites/Finance/Shared Documents/2025"
$DownloadPath = "C:\SPDownloads\2025"
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/2025" -LocalPath $DownloadPath
Note: -FolderSiteRelativeUrl is relative to the site, not the server. So use Shared Documents/2025, not /sites/Finance/Shared Documents/2025.
Check out Get SharePoint Folder Permissions Using PnP PowerShell
Download Files Older Than 30 Days
Perfect for archiving. This filters on the Modified date, but you can switch to Created just as easily.
$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
}
Server-side version using CAML (faster on big libraries):
$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 Files Modified in the Last 7 Days (Incremental Download)
This is the reverse of the above and is extremely useful if you’re running a scheduled sync job – you only pull what changed.
$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
}
Even smarter – skip files that haven’t changed locally:
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
}
Download Files Created or Modified by a Specific User
Handy during offboarding, when you need to collect everything a departing employee uploaded.
$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
}
Change Author to Editor if you want files last modified by that person instead of files they created.
Here is the complete PnP PowerShell script:
# ------------------------- 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
Download Files Based on a Metadata Column Value
Most real-world libraries have custom columns like Department, Status, or Project. You can filter on those too.
# 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
}
Not sure of the internal column name? Run this to list them:
Get-PnPField -List $LibraryName |
Select-Object Title, InternalName, TypeAsString |
Sort-Object Title
Note: Always use the InternalName in your scripts, not the display name. Spaces become _x0020_ in internal names.
Check out Add More Than 5000 Items to a SharePoint Online List Using PnP PowerShell
Download Files by Name Pattern (Wildcard Search)
Need all files with “Invoice” in the name? Use a simple -like match.
$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 from SharePoint
If you just need one specific document, you don’t need any loops at all:
Get-PnPFile -Url "/sites/Finance/Shared Documents/Budget 2025.xlsx" `
-Path "C:\SPDownloads" `
-FileName "Budget 2025.xlsx" -AsFile -Force
You can also grab the file’s contents directly into memory instead of saving it:
$Content = Get-PnPFile -Url "/sites/Finance/Shared Documents/Notes.txt" -AsString
Write-Host $Content
Download All Files and Zip Them Automatically
Great for handing over an archive to someone or for a monthly backup job.
$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 ALL Document Libraries on a Site
If you want everything on a site (not just one library), loop through all libraries and skip the system ones.
$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
}
}
Add Logging, Retry Logic, and Error Handling (Production-Ready Script)
The scripts above are fine for a one-off task. But if you’re downloading 20,000 files, you need logging and retries – because SharePoint Online will throttle you.
Here’s a hardened version you can use in production.
# ---------- 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
Why the exponential back-off matters: when SharePoint Online throttles you, it returns HTTP 429 or 503. If you keep hammering it, the throttling gets more aggressive. Waiting 10 seconds, then 20, then 40 gives the service time to breathe and dramatically improves your success rate on large jobs.
Handle Long File Paths (The 260-Character Problem)
Windows has a legacy 260-character path limit. SharePoint allows much longer paths, so deeply nested libraries will fail on download with a “path too long” error.
Two fixes:
1. Enable long paths in Windows (requires admin rights and a restart):
New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" `
-Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force
2. Use a short root download folder and truncate names in your script:
$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 strip invalid Windows characters from folder names coming from SharePoint:
$SafeName = $Name -replace '[\\/:*?"<>|]', '_'
Performance Tips for Large Document Libraries
If your library has 50,000+ files, these tips will save you hours.
1. Always use -PageSize. Without it, you’ll hit the 5,000 item list view threshold error. -PageSize 2000 is a good balance.
2. Filter on the server, not in PowerShell. A CAML query returns only matching items. Where-Object downloads all metadata first, then filters – wasteful on large libraries.
3. Request only the fields you need:
$Items = Get-PnPListItem -List $LibraryName -PageSize 2000 `
-Fields "FileRef", "FileLeafRef", "File_x0020_Size", "Modified"
This alone can cut your metadata retrieval time in half.
4. Download to a local SSD, not a network drive or mapped OneDrive folder. Writing to a synced OneDrive folder means every file gets uploaded again immediately – which is both slow and pointless.
5. Batch by folder. Instead of one script pulling 100,000 files, run separate jobs per top-level folder. If one fails, you don’t restart everything.
6. Don’t run it during business hours. Throttling is far more likely during peak tenant usage. Schedule overnight runs.
Verify Your Download Was Complete
Never assume the download worked. Always compare counts. Below PowerShell script you can run to compare the count.
$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 an even stricter check, compare total byte size:
$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"
Schedule the Download to Run Automatically
Once your script works, you can schedule it to run nightly.
Save the script as Download-SPFiles.ps1, switch the connection to certificate-based app-only authentication (interactive login won’t work unattended), and create a scheduled task:
$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"
Tip: Combine this with the “modified in the last 7 days” filter so each nightly run only pulls the delta instead of re-downloading everything.
Wrapping Up
In this tutorial, we learned how to download all files from a SharePoint document library using PnP PowerShell.
Here’s my suggested approach:
- Start with the basic download all files script and confirm your connection works on a small library.
- Add the filter you actually need – file type, size, date, folder, or metadata.
- Once it works, upgrade to the production script with logging and retries.
- Verify with the file count and byte size comparison.
- Schedule it if this needs to happen regularly.
The single biggest mistake I see people make is skipping the verification step. Always compare the SharePoint count with the local count. A script that “finished successfully” while silently skipping 400 throttled files is worse than no script at all.
Bookmark this page – you’ll find yourself coming back to these snippets more often than you’d expect.
You may also like:
- Delete All Items from a SharePoint List Using PnP PowerShell
- Add SharePoint List Fields From Excel Using PnP PowerShell
- Enable Sensitivity Labels For Microsoft 365 Groups & SharePoint Sites Using PowerShell
- Add Bulk Data from Excel File to SharePoint List Using PnP PowerShell

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.