Download All Files from a SharePoint Document Library Using PnP PowerShell

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-PnPListItem with -PageSize 2000 pulls 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.
  • FileRef is 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 -Force writes 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.

Download All Files from a SharePoint Document Library

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:

  1. Start with the basic download all files script and confirm your connection works on a small library.
  2. Add the filter you actually need – file type, size, date, folder, or metadata.
  3. Once it works, upgrade to the production script with logging and retries.
  4. Verify with the file count and byte size comparison.
  5. 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:

Power Apps functions free pdf

30 Power Apps Functions

This free guide walks you through the 30 most-used Power Apps functions with real business examples, exact syntax, and results you can see.

Live Webinar

SharePoint Integration Power Apps Form With Repeating Table [Invoice Management System]

Learn how to build an invoice management system using SharePoint integration and a repeating table.

📅 2nd September 2026 – 10:00 AM EST | 7:30 PM IST

Download User registration canvas app

DOWNLOAD USER REGISTRATION POWER APPS CANVAS APP

Download a fully functional Power Apps Canvas App (with Power Automate): User Registration App