Live training: next batches coming soon. Join the waitlist:Waitlists open:Developer Training →Developer →Copilot Studio Training →Copilot Studio →

Download All Files from a SharePoint Document Library Using PnP PowerShell

A finance manager once asked me to hand over a full copy of a document library before a site was archived. It had about 9,000 files in nested folders. The browser’s Download button zipped for a while, then failed, and the partial zip was missing whole folders.

That’s when PowerShell earns its keep. Below are the PnP PowerShell scripts I use to download every file from a SharePoint Online library, keep the folder structure, filter by type, size, date, user or column, survive throttling and prove nothing was skipped.

Quick answer: Connect with Connect-PnPOnline -Url <site> -ClientId <your-app-id> -Interactive, read every file with Get-PnPListItem -List "Documents" -PageSize 2000, keep the items where FileSystemObjectType is File, and save each one with Get-PnPFile -Url $Item.FieldValues.FileRef -Path <local folder> -Filename $Item.FieldValues.FileLeafRef -AsFile -Force.

Before you start: PowerShell 7, PnP PowerShell and an Entra ID app

Why not just use the browser? Its multi-file download builds a zip, and Microsoft caps that at 10,000 files, with compressed files such as .zip or .mp4 limited to 10 GB each and 20 GB overall. A script has no such cap, can resume, and leaves a log.

Current PnP PowerShell needs PowerShell 7 (version 3.x asks for 7.4 or later), so it won’t load in Windows PowerShell 5.1. Check your version, then install the module. My guide to installing PnP PowerShell for SharePoint Online covers the other install options.

$PSVersionTable.PSVersion
Install-Module -Name PnP.PowerShell -Scope CurrentUser -Force
Get-Module PnP.PowerShell -ListAvailable | Select-Object Name, Version

You also need at least Read access to the library. The bigger hurdle is the sign-in: since September 9, 2024, Connect-PnPOnline requires a -ClientId from an Entra ID app registered in your own tenant. Register one once and reuse its ID everywhere:

Register-PnPEntraIDAppForInteractiveLogin -ApplicationName "PnP Rocks" -Tenant contoso.onmicrosoft.com -Interactive

Note: Register-PnPEntraIDAppForInteractiveLogin has no -Interactive parameter, so this line stops with “A parameter cannot be found that matches parameter name ‘Interactive'”. Drop -Interactive (the browser sign-in is the default) or swap it for -DeviceLogin. Creating the app usually needs an Application Administrator, and granting consent a Global Administrator.

Now connect to the site and confirm the session works. If anything here fails, my walkthrough on connecting to SharePoint Online using PowerShell explains every sign-in method and error.

$SiteURL  = "https://contoso.sharepoint.com/sites/Finance"
$ClientId = "11111111-2222-3333-4444-555555555555"

Connect-PnPOnline -Url $SiteURL -Interactive -ClientId $ClientId
Get-PnPWeb | Select-Object Title, Url

For a scheduled job nobody watches, use certificate-based app-only authentication instead. The app needs a SharePoint application permission such as Sites.Read.All (or Sites.Selected for one site) with admin consent:

Connect-PnPOnline -Url $SiteURL `
    -ClientId "11111111-2222-3333-4444-555555555555" `
    -Tenant "contoso.onmicrosoft.com" `
    -CertificatePath "C:\Certs\PnPCert.pfx" `
    -CertificatePassword (ConvertTo-SecureString -String "YourPfxPassword" -AsPlainText -Force)

Note: this works, but it keeps the .pfx password in plain text inside the script. For real jobs, load it with Read-Host -AsSecureString, a secret store, or use -Thumbprint with a certificate installed in the Windows certificate store.

Last check: confirm the library’s exact name. The display name and URL often differ; a library called Documents lives at /Shared Documents. This lists every library (base template 101), and I go further in getting all lists and libraries from a site.

Get-PnPList | Where-Object { $_.BaseTemplate -eq 101 } |
    Select-Object Title, DefaultViewUrl, ItemCount

Download all files from a SharePoint document library (folders kept)

This is the core script. It reads every item in the library, skips folders, rebuilds each file’s folder path under your local download folder and saves the file there with Get-PnPFile:

# ---------- CONFIGURATION ----------
$SiteURL      = "https://contoso.sharepoint.com/sites/Finance"
$ClientId     = "11111111-2222-3333-4444-555555555555"
$LibraryName  = "Documents"
$DownloadPath = "C:\SPDownloads\Finance"
# -----------------------------------

Connect-PnPOnline -Url $SiteURL -Interactive -ClientId $ClientId

# Make sure the destination folder exists
if (-not (Test-Path $DownloadPath)) {
    New-Item -Path $DownloadPath -ItemType Directory -Force | Out-Null
}

# Get the library and its server-relative URL
$List       = Get-PnPList -Identity $LibraryName -Includes RootFolder
$LibraryUrl = $List.RootFolder.ServerRelativeUrl

# Get all FILES only (FSObjType 0), page by page for large libraries
$Items = Get-PnPListItem -List $LibraryName -PageSize 2000 |
         Where-Object { $_.FileSystemObjectType -eq "File" }

Write-Host "Found $($Items.Count) files. Starting download..." -ForegroundColor Cyan

$Counter = 0
foreach ($Item in $Items) {

    $Counter++
    $FileUrl = $Item.FieldValues.FileRef

    # Recreate the SharePoint folder structure locally
    $RelativePath = $FileUrl.Replace($LibraryUrl, "").Replace("/", "\")
    $LocalFile    = Join-Path $DownloadPath $RelativePath.TrimStart("\")
    $LocalFolder  = Split-Path $LocalFile -Parent

    if (-not (Test-Path $LocalFolder)) {
        New-Item -Path $LocalFolder -ItemType Directory -Force | Out-Null
    }

    Write-Progress -Activity "Downloading files" `
                   -Status "$Counter of $($Items.Count): $($Item.FieldValues.FileLeafRef)" `
                   -PercentComplete (($Counter / $Items.Count) * 100)

    try {
        Get-PnPFile -Url $FileUrl -Path $LocalFolder `
                    -FileName $Item.FieldValues.FileLeafRef -AsFile -Force
    }
    catch {
        Write-Host "Failed: $FileUrl - $($_.Exception.Message)" -ForegroundColor Red
    }
}

Write-Host "Download complete. Files saved to $DownloadPath" -ForegroundColor Green
Disconnect-PnPOnline

Here’s what each part does:

  • -PageSize 2000 makes Get-PnPListItem read the library in pages, which is how you read past the 5,000-item list view threshold. The same idea applies when adding more than 5,000 items with PnP PowerShell.
  • FileSystemObjectType -eq “File” drops folder items so only documents get downloaded.
  • FileRef is the file’s server-relative URL, such as /sites/Finance/Shared Documents/Reports/Budget.xlsx. FileLeafRef is just the file name.
  • Removing the library’s root URL from FileRef leaves the relative path, which becomes the local subfolder.
  • -AsFile -Force writes the file to disk and overwrites an older local copy.

Run it on a small library first. On a big one, check the total size before you start; my post on getting the SharePoint document library size shows how. Here’s the script finishing against my test library:

PnP PowerShell script downloading all files from a SharePoint document library into a local folder with its subfolders

The script checked out fine against the current Get-PnPFile docs. One small thing: the cmdlet’s parameter is spelled -Filename; PowerShell ignores case, so -FileName works too.

Download every file into one folder (flat download)

Sometimes people want every file in a single folder, for a vendor or an import tool. The catch is duplicate names: Report.pdf in two subfolders would overwrite each other. This version renames duplicates (Report_1.pdf), retries failed downloads and writes a CSV that maps each saved name back to its SharePoint path.

If you keep hitting duplicates, the library may need a cleaner SharePoint folder structure.

<#
=====================================================================
 Script  : Download all files from a SharePoint library (FLAT)
 Purpose : Downloads every file from a document library, including
           all subfolders, into ONE local folder without recreating
           the SharePoint folder structure.
 Requires: PowerShell 7+ and the PnP.PowerShell module
=====================================================================
#>

# ------------------------- CONFIGURATION -------------------------
$SiteURL      = "https://contoso.sharepoint.com/sites/Finance"
$ClientId     = "11111111-2222-3333-4444-555555555555"
$LibraryName  = "Documents"                       # Library display name
$DownloadPath = "C:\SPDownloads\Finance_Flat"     # Local flat folder
$MappingFile  = "C:\SPDownloads\FlatDownload_Map_$(Get-Date -f 'yyyyMMdd_HHmmss').csv"
$MaxRetries   = 3
$SkipExisting = $false    # $true = skip files already downloaded
# -----------------------------------------------------------------

# ---------- STEP 1: Connect to SharePoint ----------
try {
    Connect-PnPOnline -Url $SiteURL -Interactive -ClientId $ClientId -ErrorAction Stop
    Write-Host "Connected to $SiteURL" -ForegroundColor Green
}
catch {
    Write-Host "Connection failed: $($_.Exception.Message)" -ForegroundColor Red
    return
}

# ---------- STEP 2: Prepare the local download folder ----------
if (-not (Test-Path $DownloadPath)) {
    New-Item -Path $DownloadPath -ItemType Directory -Force | Out-Null
    Write-Host "Created folder: $DownloadPath" -ForegroundColor Gray
}

# ---------- STEP 3: Get every file in the library ----------
Write-Host "Reading library '$LibraryName'..." -ForegroundColor Cyan

$Items = Get-PnPListItem -List $LibraryName -PageSize 2000 `
         -Fields "FileRef", "FileLeafRef", "File_x0020_Size", "Modified" |
         Where-Object { $_.FileSystemObjectType -eq "File" }

$Total = @($Items).Count

if ($Total -eq 0) {
    Write-Host "No files found in '$LibraryName'. Nothing to download." -ForegroundColor Yellow
    Disconnect-PnPOnline
    return
}

Write-Host "Found $Total files. Starting flat download...`n" -ForegroundColor Cyan

# ---------- STEP 4: Download loop with duplicate-name handling ----------
$Downloaded = 0
$Skipped    = 0
$Failed     = 0
$Renamed    = 0
$Counter    = 0
$Mapping    = @()

foreach ($Item in $Items) {

    $Counter++
    $SourceUrl    = $Item.FieldValues.FileRef
    $OriginalName = $Item.FieldValues.FileLeafRef

    Write-Progress -Activity "Flat download from '$LibraryName'" `
                   -Status "$Counter of $Total : $OriginalName" `
                   -PercentComplete (($Counter / $Total) * 100)

    # --- Strip characters Windows does not allow in file names ---
    $SafeName = $OriginalName -replace '[\\/:*?"<>|]', '_'

    $TargetName = $SafeName
    $TargetPath = Join-Path $DownloadPath $TargetName

    # --- Optionally skip if the exact file already exists ---
    if ($SkipExisting -and (Test-Path $TargetPath)) {
        Write-Host "[$Counter/$Total] Skipped (exists): $TargetName" -ForegroundColor DarkGray
        $Skipped++
        continue
    }

    # --- Resolve duplicate names: File.pdf -> File_1.pdf -> File_2.pdf ---
    $Suffix = 1
    while (Test-Path $TargetPath) {
        $BaseName   = [System.IO.Path]::GetFileNameWithoutExtension($SafeName)
        $Extension  = [System.IO.Path]::GetExtension($SafeName)
        $TargetName = "$($BaseName)_$Suffix$Extension"
        $TargetPath = Join-Path $DownloadPath $TargetName
        $Suffix++
    }

    if ($TargetName -ne $SafeName) { $Renamed++ }

    # --- Download with retry + exponential back-off for throttling ---
    $Attempt = 0
    $Success = $false

    while (-not $Success -and $Attempt -lt $MaxRetries) {

        $Attempt++

        try {
            Get-PnPFile -Url $SourceUrl `
                        -Path $DownloadPath `
                        -FileName $TargetName `
                        -AsFile -Force -ErrorAction Stop

            $Success = $true
            $Downloaded++

            Write-Host "[$Counter/$Total] Downloaded: $TargetName" -ForegroundColor Green

            $Mapping += [PSCustomObject]@{
                OriginalName   = $OriginalName
                SavedAs        = $TargetName
                SharePointPath = $SourceUrl
                SizeKB         = [math]::Round([int64]$Item.FieldValues.File_x0020_Size / 1KB, 2)
                Modified       = $Item.FieldValues.Modified
                Status         = "Success"
            }
        }
        catch {
            if ($Attempt -ge $MaxRetries) {

                $Failed++
                Write-Host "[$Counter/$Total] FAILED: $OriginalName - $($_.Exception.Message)" -ForegroundColor Red

                $Mapping += [PSCustomObject]@{
                    OriginalName   = $OriginalName
                    SavedAs        = ""
                    SharePointPath = $SourceUrl
                    SizeKB         = 0
                    Modified       = $Item.FieldValues.Modified
                    Status         = "Failed: $($_.Exception.Message)"
                }
            }
            else {
                # Wait 10s, then 20s, then 40s before retrying
                Start-Sleep -Seconds ([math]::Pow(2, $Attempt) * 5)
            }
        }
    }
}

Write-Progress -Activity "Flat download" -Completed

# ---------- STEP 5: Export the mapping file ----------
if ($Mapping.Count -gt 0) {
    $Mapping | Export-Csv -Path $MappingFile -NoTypeInformation -Encoding UTF8
}

# ---------- STEP 6: Summary ----------
$LocalCount = (Get-ChildItem -Path $DownloadPath -File).Count

Write-Host "`n================ SUMMARY ================" -ForegroundColor Cyan
Write-Host " Library          : $LibraryName"
Write-Host " Files in library : $Total"
Write-Host " Downloaded       : $Downloaded" -ForegroundColor Green
Write-Host " Renamed (dupes)  : $Renamed"    -ForegroundColor Yellow
Write-Host " Skipped          : $Skipped"    -ForegroundColor DarkGray
Write-Host " Failed           : $Failed"     -ForegroundColor Red
Write-Host " Files on disk    : $LocalCount"
Write-Host " Mapping CSV      : $MappingFile"
Write-Host "=========================================`n" -ForegroundColor Cyan

Disconnect-PnPOnline

Note: the retry comment says it waits 10, then 20, then 40 seconds. With $MaxRetries = 3, the third failure ends the loop, so it only waits 10 and 20 seconds. Set $MaxRetries = 4 if you want the 40-second wait. The same applies to the other retry scripts below.

Also, with $SkipExisting = $false, a second run saves every file again as Name_1, Name_2, so set it to $true for reruns.

Download only PDF files

The File_x0020_Type field holds each file’s extension without the dot, so filtering on “pdf” is the simplest way to grab only PDFs:

$PDFs = Get-PnPListItem -List $LibraryName -PageSize 2000 |
        Where-Object { $_.FieldValues.File_x0020_Type -eq "pdf" }

Write-Host "Found $($PDFs.Count) PDF files."

foreach ($Item in $PDFs) {
    Get-PnPFile -Url $Item.FieldValues.FileRef -Path $DownloadPath `
                -FileName $Item.FieldValues.FileLeafRef -AsFile -Force
}

On a big library, a CAML query is faster because SharePoint returns only the matching items. If CAML is new to you, the CAML query builder for SharePoint Online helps you write and test queries:

$CAML = @"
<View Scope='RecursiveAll'>
  <Query>
    <Where>
      <Eq>
        <FieldRef Name='File_x0020_Type'/>
        <Value Type='Text'>pdf</Value>
      </Eq>
    </Where>
  </Query>
</View>
"@

$PDFs = Get-PnPListItem -List $LibraryName -Query $CAML -PageSize 2000

foreach ($Item in $PDFs) {
    Get-PnPFile -Url $Item.FieldValues.FileRef -Path $DownloadPath `
                -FileName $Item.FieldValues.FileLeafRef -AsFile -Force
}

Scope='RecursiveAll' matters: without it, the query only looks at the library’s root folder. Here’s the complete PDF script with folder structure, retries and a CSV log:

# ------------------------- CONFIGURATION -------------------------
$SiteURL      = "https://contoso.sharepoint.com/sites/Finance"
$ClientId     = "11111111-2222-3333-4444-555555555555"
$LibraryName  = "Documents"
$DownloadPath = "C:\SPDownloads\PDFs"
$KeepStructure = $true      # $true = recreate folders, $false = flat
$MaxRetries    = 3
$LogFile       = "C:\SPDownloads\PDFDownload_$(Get-Date -f 'yyyyMMdd_HHmmss').csv"
# -----------------------------------------------------------------

try {
    Connect-PnPOnline -Url $SiteURL -Interactive -ClientId $ClientId -ErrorAction Stop
    Write-Host "Connected to $SiteURL" -ForegroundColor Green
}
catch {
    Write-Host "Connection failed: $($_.Exception.Message)" -ForegroundColor Red
    return
}

if (-not (Test-Path $DownloadPath)) {
    New-Item -Path $DownloadPath -ItemType Directory -Force | Out-Null
}

$List       = Get-PnPList -Identity $LibraryName -Includes RootFolder
$LibraryUrl = $List.RootFolder.ServerRelativeUrl

$CAML = @"
<View Scope='RecursiveAll'>
  <Query>
    <Where>
      <Eq>
        <FieldRef Name='File_x0020_Type'/>
        <Value Type='Text'>pdf</Value>
      </Eq>
    </Where>
  </Query>
  <ViewFields>
    <FieldRef Name='FileRef'/>
    <FieldRef Name='FileLeafRef'/>
    <FieldRef Name='File_x0020_Size'/>
    <FieldRef Name='Modified'/>
  </ViewFields>
</View>
"@

Write-Host "Searching for PDF files in '$LibraryName'..." -ForegroundColor Cyan

$PDFs  = Get-PnPListItem -List $LibraryName -Query $CAML -PageSize 2000
$Total = @($PDFs).Count

if ($Total -eq 0) {
    Write-Host "No PDF files found." -ForegroundColor Yellow
    Disconnect-PnPOnline
    return
}

$TotalMB = [math]::Round((($PDFs | Measure-Object -Property { [int64]$_.FieldValues.File_x0020_Size } -Sum).Sum / 1MB), 2)
Write-Host "Found $Total PDF files ($TotalMB MB). Starting download...`n" -ForegroundColor Cyan

$Downloaded = 0
$Failed     = 0
$Counter    = 0
$Log        = @()

foreach ($Item in $PDFs) {

    $Counter   = $Counter + 1
    $SourceUrl = $Item.FieldValues.FileRef
    $FileName  = $Item.FieldValues.FileLeafRef -replace '[\\/:*?"<>|]', '_'

    if ($KeepStructure) {
        $RelPath     = $SourceUrl.Replace($LibraryUrl, "").Replace("/", "\").TrimStart("\")
        $TargetFolder = Split-Path (Join-Path $DownloadPath $RelPath) -Parent
    }
    else {
        $TargetFolder = $DownloadPath
        $Suffix = 1
        while (Test-Path (Join-Path $TargetFolder $FileName)) {
            $Base     = [System.IO.Path]::GetFileNameWithoutExtension($Item.FieldValues.FileLeafRef)
            $FileName = "$($Base)_$Suffix.pdf"
            $Suffix   = $Suffix + 1
        }
    }

    if (-not (Test-Path $TargetFolder)) {
        New-Item -Path $TargetFolder -ItemType Directory -Force | Out-Null
    }

    Write-Progress -Activity "Downloading PDFs from '$LibraryName'" `
                   -Status "$Counter of $Total : $FileName" `
                   -PercentComplete (($Counter / $Total) * 100)

    $Attempt = 0
    $Success = $false

    while (-not $Success -and $Attempt -lt $MaxRetries) {

        $Attempt = $Attempt + 1

        try {
            Get-PnPFile -Url $SourceUrl -Path $TargetFolder -FileName $FileName -AsFile -Force -ErrorAction Stop
            $Success    = $true
            $Downloaded = $Downloaded + 1

            $SizeKB = [math]::Round([int64]$Item.FieldValues.File_x0020_Size / 1KB, 2)
            Write-Host "[$Counter/$Total] $FileName ($SizeKB KB)" -ForegroundColor Green

            $Log += [PSCustomObject]@{
                FileName       = $FileName
                SharePointPath = $SourceUrl
                SizeKB         = $SizeKB
                Modified       = $Item.FieldValues.Modified
                Status         = "Success"
            }
        }
        catch {
            if ($Attempt -ge $MaxRetries) {
                $Failed = $Failed + 1
                Write-Host "[$Counter/$Total] FAILED: $FileName" -ForegroundColor Red

                $Log += [PSCustomObject]@{
                    FileName       = $FileName
                    SharePointPath = $SourceUrl
                    SizeKB         = 0
                    Modified       = $Item.FieldValues.Modified
                    Status         = "Failed: $($_.Exception.Message)"
                }
            }
            else {
                Start-Sleep -Seconds ([math]::Pow(2, $Attempt) * 5)
            }
        }
    }
}

Write-Progress -Activity "Downloading PDFs" -Completed

if ($Log.Count -gt 0) {
    $Log | Export-Csv -Path $LogFile -NoTypeInformation -Encoding UTF8
}

Write-Host "`n============== SUMMARY ==============" -ForegroundColor Cyan
Write-Host " PDFs found  : $Total"
Write-Host " Downloaded  : $Downloaded" -ForegroundColor Green
Write-Host " Failed      : $Failed"     -ForegroundColor Red
Write-Host " Total size  : $TotalMB MB"
Write-Host " Saved to    : $DownloadPath"
Write-Host " Log file    : $LogFile"
Write-Host "=====================================`n" -ForegroundColor Cyan

Disconnect-PnPOnline

Note: in a library with more than 5,000 items, a CAML Where on a column that isn’t indexed can fail with the list view threshold error, even with -PageSize. Add an index to the column first, or use the Where-Object version above. Here’s how to fix the list view threshold error if you see it.

Download Excel files (or any set of file types)

Excel workbooks come in several extensions, so match a list of them. Swap the array for @(“docx”,”doc”) for Word, @(“pptx”,”ppt”) for PowerPoint or @(“jpg”,”jpeg”,”png”,”gif”) for images. Keeping file types in separate libraries is one of the document library best practices that makes these jobs simpler:

$Extensions = @("xlsx", "xls", "xlsm", "csv")

$ExcelFiles = Get-PnPListItem -List $LibraryName -PageSize 2000 |
              Where-Object { $Extensions -contains $_.FieldValues.File_x0020_Type }

foreach ($Item in $ExcelFiles) {
    Get-PnPFile -Url $Item.FieldValues.FileRef -Path $DownloadPath `
                -FileName $Item.FieldValues.FileLeafRef -AsFile -Force
}

Download files larger than 5 MB

When a site is running out of space, the big files are the first suspects. File_x0020_Size stores the size in bytes, so this pulls everything over 5 MB. If storage is the real problem, also read how to manage site storage limits in SharePoint:

$MinSizeMB = 5
$MinBytes  = $MinSizeMB * 1MB

$BigFiles = Get-PnPListItem -List $LibraryName -PageSize 2000 |
            Where-Object {
                $_.FileSystemObjectType -eq "File" -and
                [int64]$_.FieldValues.File_x0020_Size -gt $MinBytes
            }

Write-Host "Files larger than $MinSizeMB MB: $($BigFiles.Count)"

foreach ($Item in $BigFiles) {
    $SizeMB = [math]::Round([int64]$Item.FieldValues.File_x0020_Size / 1MB, 2)
    Write-Host "Downloading $($Item.FieldValues.FileLeafRef) ($SizeMB MB)"

    Get-PnPFile -Url $Item.FieldValues.FileRef -Path $DownloadPath `
                -FileName $Item.FieldValues.FileLeafRef -AsFile -Force
}

If you only want a report of the large files, export them instead of downloading:

$BigFiles | ForEach-Object {
    [PSCustomObject]@{
        Name   = $_.FieldValues.FileLeafRef
        SizeMB = [math]::Round([int64]$_.FieldValues.File_x0020_Size / 1MB, 2)
        Path   = $_.FieldValues.FileRef
    }
} | Export-Csv "C:\Reports\LargeFiles.csv" -NoTypeInformation

The complete script below filters on the server with CAML, sorts largest first, shows the total size and asks before it downloads anything:

# ------------------------- CONFIGURATION -------------------------
$SiteURL       = "https://contoso.sharepoint.com/sites/Finance"
$ClientId      = "11111111-2222-3333-4444-555555555555"
$LibraryName   = "Documents"
$DownloadPath  = "C:\SPDownloads\LargeFiles"
$MinSizeMB     = 5
$KeepStructure = $true
$MaxRetries    = 3
$LogFile       = "C:\SPDownloads\LargeFiles_$(Get-Date -f 'yyyyMMdd_HHmmss').csv"
# -----------------------------------------------------------------

$MinBytes = $MinSizeMB * 1MB

try {
    Connect-PnPOnline -Url $SiteURL -Interactive -ClientId $ClientId -ErrorAction Stop
    Write-Host "Connected to $SiteURL" -ForegroundColor Green
}
catch {
    Write-Host "Connection failed: $($_.Exception.Message)" -ForegroundColor Red
    return
}

if (-not (Test-Path $DownloadPath)) {
    New-Item -Path $DownloadPath -ItemType Directory -Force | Out-Null
}

$List       = Get-PnPList -Identity $LibraryName -Includes RootFolder
$LibraryUrl = $List.RootFolder.ServerRelativeUrl

$CAML = @"
<View Scope='RecursiveAll'>
  <Query>
    <Where>
      <Gt>
        <FieldRef Name='File_x0020_Size'/>
        <Value Type='Number'>$MinBytes</Value>
      </Gt>
    </Where>
    <OrderBy>
      <FieldRef Name='File_x0020_Size' Ascending='FALSE'/>
    </OrderBy>
  </Query>
  <ViewFields>
    <FieldRef Name='FileRef'/>
    <FieldRef Name='FileLeafRef'/>
    <FieldRef Name='File_x0020_Size'/>
    <FieldRef Name='File_x0020_Type'/>
    <FieldRef Name='Modified'/>
  </ViewFields>
</View>
"@

Write-Host "Searching for files larger than $MinSizeMB MB..." -ForegroundColor Cyan

$BigFiles = Get-PnPListItem -List $LibraryName -Query $CAML -PageSize 2000 |
            Where-Object { $_.FileSystemObjectType -eq "File" }

$Total = @($BigFiles).Count

if ($Total -eq 0) {
    Write-Host "No files larger than $MinSizeMB MB found." -ForegroundColor Yellow
    Disconnect-PnPOnline
    return
}

$TotalMB = [math]::Round((($BigFiles | ForEach-Object { [int64]$_.FieldValues.File_x0020_Size } | Measure-Object -Sum).Sum / 1MB), 2)
$TotalGB = [math]::Round($TotalMB / 1024, 2)

Write-Host "Found $Total files larger than $MinSizeMB MB" -ForegroundColor Cyan
Write-Host "Total download size: $TotalMB MB ($TotalGB GB)`n" -ForegroundColor Cyan

$Confirm = Read-Host "Continue with download? (Y/N)"
if ($Confirm -ne "Y") {
    Write-Host "Cancelled by user." -ForegroundColor Yellow
    Disconnect-PnPOnline
    return
}

$Downloaded    = 0
$Failed        = 0
$Counter       = 0
$BytesDone     = 0
$Log           = @()
$StartTime     = Get-Date

foreach ($Item in $BigFiles) {

    $Counter   = $Counter + 1
    $SourceUrl = $Item.FieldValues.FileRef
    $FileName  = $Item.FieldValues.FileLeafRef -replace '[\\/:*?"<>|]', '_'
    $Bytes     = [int64]$Item.FieldValues.File_x0020_Size
    $SizeMB    = [math]::Round($Bytes / 1MB, 2)

    if ($KeepStructure) {
        $RelPath      = $SourceUrl.Replace($LibraryUrl, "").Replace("/", "\").TrimStart("\")
        $TargetFolder = Split-Path (Join-Path $DownloadPath $RelPath) -Parent
    }
    else {
        $TargetFolder = $DownloadPath
        $Suffix = 1
        while (Test-Path (Join-Path $TargetFolder $FileName)) {
            $Base      = [System.IO.Path]::GetFileNameWithoutExtension($Item.FieldValues.FileLeafRef)
            $Ext       = [System.IO.Path]::GetExtension($Item.FieldValues.FileLeafRef)
            $FileName  = "$($Base)_$Suffix$Ext"
            $Suffix    = $Suffix + 1
        }
    }

    if (-not (Test-Path $TargetFolder)) {
        New-Item -Path $TargetFolder -ItemType Directory -Force | Out-Null
    }

    $PctDone = [math]::Round(($BytesDone / ($TotalMB * 1MB)) * 100, 1)

    Write-Progress -Activity "Downloading files over $MinSizeMB MB" `
                   -Status "$Counter of $Total : $FileName ($SizeMB MB) - $PctDone% of data" `
                   -PercentComplete (($Counter / $Total) * 100)

    $Attempt = 0
    $Success = $false

    while (-not $Success -and $Attempt -lt $MaxRetries) {

        $Attempt = $Attempt + 1

        try {
            Get-PnPFile -Url $SourceUrl -Path $TargetFolder -FileName $FileName -AsFile -Force -ErrorAction Stop

            $Success    = $true
            $Downloaded = $Downloaded + 1
            $BytesDone  = $BytesDone + $Bytes

            Write-Host "[$Counter/$Total] $FileName ($SizeMB MB)" -ForegroundColor Green

            $Log += [PSCustomObject]@{
                FileName       = $FileName
                SizeMB         = $SizeMB
                FileType       = $Item.FieldValues.File_x0020_Type
                SharePointPath = $SourceUrl
                Modified       = $Item.FieldValues.Modified
                Status         = "Success"
            }
        }
        catch {
            if ($Attempt -ge $MaxRetries) {
                $Failed = $Failed + 1
                Write-Host "[$Counter/$Total] FAILED: $FileName ($SizeMB MB)" -ForegroundColor Red

                $Log += [PSCustomObject]@{
                    FileName       = $FileName
                    SizeMB         = $SizeMB
                    FileType       = $Item.FieldValues.File_x0020_Type
                    SharePointPath = $SourceUrl
                    Modified       = $Item.FieldValues.Modified
                    Status         = "Failed: $($_.Exception.Message)"
                }
            }
            else {
                Start-Sleep -Seconds ([math]::Pow(2, $Attempt) * 5)
            }
        }
    }
}

Write-Progress -Activity "Downloading large files" -Completed

if ($Log.Count -gt 0) {
    $Log | Export-Csv -Path $LogFile -NoTypeInformation -Encoding UTF8
}

$Elapsed      = (Get-Date) - $StartTime
$DownloadedMB = [math]::Round($BytesDone / 1MB, 2)
$SpeedMBps    = if ($Elapsed.TotalSeconds -gt 0) { [math]::Round($DownloadedMB / $Elapsed.TotalSeconds, 2) } else { 0 }

Write-Host "`n================ SUMMARY ================" -ForegroundColor Cyan
Write-Host " Size threshold : $MinSizeMB MB"
Write-Host " Files found    : $Total"
Write-Host " Downloaded     : $Downloaded" -ForegroundColor Green
Write-Host " Failed         : $Failed"     -ForegroundColor Red
Write-Host " Data pulled    : $DownloadedMB MB"
Write-Host " Elapsed        : $($Elapsed.ToString('hh\:mm\:ss'))"
Write-Host " Avg speed      : $SpeedMBps MB/s"
Write-Host " Saved to       : $DownloadPath"
Write-Host " Log file       : $LogFile"
Write-Host "=========================================`n" -ForegroundColor Cyan

Disconnect-PnPOnline

Filtering on the server means SharePoint sends back only the matching files instead of metadata for the whole library. The same threshold caveat applies here: the size filter and the sort work best on indexed columns in SharePoint. The Where-Object line stays as a safety filter for folders. A few CAML variations:

Files between 5 MB and 100 MB:

$MinBytes = 5MB
$MaxBytes = 100MB

$CAML = @"
<View Scope='RecursiveAll'>
  <Query>
    <Where>
      <And>
        <Gt><FieldRef Name='File_x0020_Size'/><Value Type='Number'>$MinBytes</Value></Gt>
        <Lt><FieldRef Name='File_x0020_Size'/><Value Type='Number'>$MaxBytes</Value></Lt>
      </And>
    </Where>
  </Query>
</View>
"@

Large files of one type (MP4 videos here):

$MinBytes = 5MB

$CAML = @"
<View Scope='RecursiveAll'>
  <Query>
    <Where>
      <And>
        <Gt><FieldRef Name='File_x0020_Size'/><Value Type='Number'>$MinBytes</Value></Gt>
        <Eq><FieldRef Name='File_x0020_Type'/><Value Type='Text'>mp4</Value></Eq>
      </And>
    </Where>
  </Query>
</View>
"@

Large files not modified in over a year:

$MinBytes = 5MB
$CutOff   = (Get-Date).AddYears(-1).ToString("yyyy-MM-ddTHH:mm:ssZ")

$CAML = @"
<View Scope='RecursiveAll'>
  <Query>
    <Where>
      <And>
        <Gt><FieldRef Name='File_x0020_Size'/><Value Type='Number'>$MinBytes</Value></Gt>
        <Leq><FieldRef Name='Modified'/><Value Type='DateTime' IncludeTimeValue='TRUE'>$CutOff</Value></Leq>
      </And>
    </Where>
  </Query>
</View>
"@

Note: the cutoff string adds a Z (UTC) to your local time, so it’s off by your UTC offset. That doesn’t matter for a one-year cutoff. If you need hour precision, use (Get-Date).AddYears(-1).ToUniversalTime().

Download files from one folder (with or without subfolders)

If you only need one folder, Get-PnPFolderItem is faster because it doesn’t scan the whole library. Check the path first with my guide to checking if a folder or file exists. This downloads the files in one folder only:

$FolderUrl    = "/sites/Finance/Shared Documents/2026/Q1 Reports"
$DownloadPath = "C:\SPDownloads\Q1Reports"

New-Item -Path $DownloadPath -ItemType Directory -Force | Out-Null

$Files = Get-PnPFolderItem -FolderSiteRelativeUrl "Shared Documents/2026/Q1 Reports" -ItemType File

foreach ($File in $Files) {
    Get-PnPFile -Url $File.ServerRelativeUrl -Path $DownloadPath `
                -FileName $File.Name -AsFile -Force
}

The complete version adds an optional subfolder switch, sizes, retries and a log:

# ------------------------- CONFIGURATION -------------------------
$SiteURL      = "https://contoso.sharepoint.com/sites/Finance"
$ClientId     = "11111111-2222-3333-4444-555555555555"
$FolderUrl    = "/sites/Finance/Shared Documents/2026/Q1 Reports"
$SiteRelative = "Shared Documents/2026/Q1 Reports"
$DownloadPath = "C:\SPDownloads\Q1Reports"
$IncludeSubfolders = $false
$MaxRetries   = 3
$LogFile      = "C:\SPDownloads\FolderDownload_$(Get-Date -f 'yyyyMMdd_HHmmss').csv"
# -----------------------------------------------------------------

try {
    Connect-PnPOnline -Url $SiteURL -Interactive -ClientId $ClientId -ErrorAction Stop
    Write-Host "Connected to $SiteURL" -ForegroundColor Green
}
catch {
    Write-Host "Connection failed: $($_.Exception.Message)" -ForegroundColor Red
    return
}

if (-not (Test-Path $DownloadPath)) {
    New-Item -Path $DownloadPath -ItemType Directory -Force | Out-Null
}

function Get-FilesRecursive {
    param (
        [string]$RelativeUrl,
        [string]$LocalPath
    )

    if (-not (Test-Path $LocalPath)) {
        New-Item -Path $LocalPath -ItemType Directory -Force | Out-Null
    }

    $Items = Get-PnPFolderItem -FolderSiteRelativeUrl $RelativeUrl -ItemType File
    foreach ($Item in $Items) {
        [PSCustomObject]@{
            Name      = $Item.Name
            Url       = $Item.ServerRelativeUrl
            Length    = $Item.Length
            LocalPath = $LocalPath
        }
    }

    if ($IncludeSubfolders) {
        $SubFolders = Get-PnPFolderItem -FolderSiteRelativeUrl $RelativeUrl -ItemType Folder |
                      Where-Object { $_.Name -ne "Forms" }

        foreach ($Sub in $SubFolders) {
            Get-FilesRecursive -RelativeUrl "$RelativeUrl/$($Sub.Name)" -LocalPath (Join-Path $LocalPath $Sub.Name)
        }
    }
}

Write-Host "Reading folder '$FolderUrl'..." -ForegroundColor Cyan

$Files = @(Get-FilesRecursive -RelativeUrl $SiteRelative -LocalPath $DownloadPath)
$Total = $Files.Count

if ($Total -eq 0) {
    Write-Host "No files found in the specified folder." -ForegroundColor Yellow
    Disconnect-PnPOnline
    return
}

$TotalMB = [math]::Round((($Files | Measure-Object -Property Length -Sum).Sum / 1MB), 2)
Write-Host "Found $Total files ($TotalMB MB). Starting download...`n" -ForegroundColor Cyan

$Downloaded = 0
$Failed     = 0
$Counter    = 0
$Log        = @()

foreach ($File in $Files) {

    $Counter  = $Counter + 1
    $FileName = $File.Name -replace '[\\/:*?"<>|]', '_'
    $SizeMB   = [math]::Round($File.Length / 1MB, 2)

    Write-Progress -Activity "Downloading from '$SiteRelative'" `
                   -Status "$Counter of $Total : $FileName" `
                   -PercentComplete (($Counter / $Total) * 100)

    $Attempt = 0
    $Success = $false

    while (-not $Success -and $Attempt -lt $MaxRetries) {

        $Attempt = $Attempt + 1

        try {
            Get-PnPFile -Url $File.Url -Path $File.LocalPath -FileName $FileName -AsFile -Force -ErrorAction Stop

            $Success    = $true
            $Downloaded = $Downloaded + 1

            Write-Host "[$Counter/$Total] $FileName ($SizeMB MB)" -ForegroundColor Green

            $Log += [PSCustomObject]@{
                FileName       = $FileName
                SizeMB         = $SizeMB
                SharePointPath = $File.Url
                LocalFolder    = $File.LocalPath
                Status         = "Success"
            }
        }
        catch {
            if ($Attempt -ge $MaxRetries) {
                $Failed = $Failed + 1
                Write-Host "[$Counter/$Total] FAILED: $FileName" -ForegroundColor Red

                $Log += [PSCustomObject]@{
                    FileName       = $FileName
                    SizeMB         = $SizeMB
                    SharePointPath = $File.Url
                    LocalFolder    = $File.LocalPath
                    Status         = "Failed: $($_.Exception.Message)"
                }
            }
            else {
                Start-Sleep -Seconds ([math]::Pow(2, $Attempt) * 5)
            }
        }
    }
}

Write-Progress -Activity "Downloading folder" -Completed

if ($Log.Count -gt 0) {
    $Log | Export-Csv -Path $LogFile -NoTypeInformation -Encoding UTF8
}

Write-Host "`n============== SUMMARY ==============" -ForegroundColor Cyan
Write-Host " Source folder : $FolderUrl"
Write-Host " Subfolders    : $IncludeSubfolders"
Write-Host " Files found   : $Total"
Write-Host " Downloaded    : $Downloaded" -ForegroundColor Green
Write-Host " Failed        : $Failed"     -ForegroundColor Red
Write-Host " Total size    : $TotalMB MB"
Write-Host " Saved to      : $DownloadPath"
Write-Host " Log file      : $LogFile"
Write-Host "=====================================`n" -ForegroundColor Cyan

Disconnect-PnPOnline

And this recursive function downloads a folder with all its subfolders:

$FolderUrl    = "/sites/Finance/Shared Documents/2026"
$DownloadPath = "C:\SPDownloads\2026"

function Download-SPFolder {
    param($SPFolderUrl, $LocalPath)

    if (-not (Test-Path $LocalPath)) {
        New-Item -Path $LocalPath -ItemType Directory -Force | Out-Null
    }

    # Download files in the current folder
    Get-PnPFolderItem -FolderSiteRelativeUrl $SPFolderUrl -ItemType File | ForEach-Object {
        Get-PnPFile -Url $_.ServerRelativeUrl -Path $LocalPath -FileName $_.Name -AsFile -Force
        Write-Host "Downloaded: $($_.Name)"
    }

    # Recurse into subfolders
    Get-PnPFolderItem -FolderSiteRelativeUrl $SPFolderUrl -ItemType Folder | ForEach-Object {
        Download-SPFolder -SPFolderUrl "$SPFolderUrl/$($_.Name)" `
                          -LocalPath (Join-Path $LocalPath $_.Name)
    }
}

Download-SPFolder -SPFolderUrl "Shared Documents/2026" -LocalPath $DownloadPath

-FolderSiteRelativeUrl is relative to the site, not the server: use Shared Documents/Reports, not /sites/Finance/Shared Documents/Reports. If a folder holds one project’s files with shared metadata, weigh SharePoint document sets vs folders.

Note: the recursive function doesn’t skip the hidden Forms folder. That’s fine when you start inside a folder, but point it at the library root and it also walks Forms. Add | Where-Object { $_.Name -ne "Forms" } to the folder line, as the complete script does.

Two smaller points: the $FolderUrl variable isn’t used, and current PnP PowerShell also offers a -Recursive switch on Get-PnPFolderItem. Folder permissions can block parts of the tree; see getting SharePoint folder permissions.

Download files older than 30 days

This is my go-to for archiving. It filters on Modified; switch to Created if that fits better. For long-term keeping, pair it with site retention policies in SharePoint rather than relying on local copies:

$DaysOld  = 30
$CutOff   = (Get-Date).AddDays(-$DaysOld)

$OldFiles = Get-PnPListItem -List $LibraryName -PageSize 2000 |
            Where-Object {
                $_.FileSystemObjectType -eq "File" -and
                [datetime]$_.FieldValues.Modified -lt $CutOff
            }

Write-Host "Files older than $DaysOld days: $($OldFiles.Count)"

foreach ($Item in $OldFiles) {
    Get-PnPFile -Url $Item.FieldValues.FileRef -Path $DownloadPath `
                -FileName $Item.FieldValues.FileLeafRef -AsFile -Force
}

The server-side CAML version is faster on big libraries (index the Modified column if the library is over 5,000 items):

$CutOffString = (Get-Date).AddDays(-30).ToString("yyyy-MM-ddTHH:mm:ssZ")

$CAML = @"
<View Scope='RecursiveAll'>
  <Query>
    <Where>
      <Lt>
        <FieldRef Name='Modified'/>
        <Value Type='DateTime' IncludeTimeValue='TRUE'>$CutOffString</Value>
      </Lt>
    </Where>
  </Query>
</View>
"@

$OldFiles = Get-PnPListItem -List $LibraryName -Query $CAML -PageSize 2000

Download only files modified in the last 7 days (incremental)

For a nightly sync, pull only what changed. If you’d rather not run scripts at all, you can download SharePoint files with Power Automate instead.

$Since = (Get-Date).AddDays(-7)

$RecentFiles = Get-PnPListItem -List $LibraryName -PageSize 2000 |
               Where-Object {
                   $_.FileSystemObjectType -eq "File" -and
                   [datetime]$_.FieldValues.Modified -ge $Since
               }

foreach ($Item in $RecentFiles) {
    Get-PnPFile -Url $Item.FieldValues.FileRef -Path $DownloadPath `
                -FileName $Item.FieldValues.FileLeafRef -AsFile -Force
}

Smarter still, skip files whose local copy is already up to date:

foreach ($Item in $Items) {
    $LocalFile = Join-Path $DownloadPath $Item.FieldValues.FileLeafRef
    $SPModified = [datetime]$Item.FieldValues.Modified

    if (Test-Path $LocalFile) {
        $LocalModified = (Get-Item $LocalFile).LastWriteTime
        if ($LocalModified -ge $SPModified) {
            Write-Host "Skipping (unchanged): $($Item.FieldValues.FileLeafRef)" -ForegroundColor DarkGray
            continue
        }
    }

    Get-PnPFile -Url $Item.FieldValues.FileRef -Path $DownloadPath `
                -FileName $Item.FieldValues.FileLeafRef -AsFile -Force
}

Note: SharePoint returns Modified in UTC, while LastWriteTime is local time, so files changed within your UTC offset can download again. Comparing (Get-Item $LocalFile).LastWriteTimeUtc against $SPModified.ToUniversalTime() removes that gap. The loop also uses $Items from the main script, so run that query first.

Download files created or modified by a specific user

This one comes up during offboarding, when you need everything a departing employee uploaded. Change Author to Editor for files they last modified. Their OneDrive is a separate store; see OneDrive for Business features. Their personal files live elsewhere, so it helps to know when to use OneDrive vs SharePoint:

$TargetUser = "john.doe@contoso.com"

$UserFiles = Get-PnPListItem -List $LibraryName -PageSize 2000 |
             Where-Object {
                 $_.FileSystemObjectType -eq "File" -and
                 $_.FieldValues.Author.Email -eq $TargetUser
             }

foreach ($Item in $UserFiles) {
    Get-PnPFile -Url $Item.FieldValues.FileRef -Path $DownloadPath `
                -FileName $Item.FieldValues.FileLeafRef -AsFile -Force
}

The complete script resolves the user to an ID and filters on the server:

# ------------------------- CONFIGURATION -------------------------
$SiteURL       = "https://contoso.sharepoint.com/sites/Finance"
$ClientId      = "11111111-2222-3333-4444-555555555555"
$LibraryName   = "Documents"
$DownloadPath  = "C:\SPDownloads\UserFiles"
$TargetUser    = "john.doe@contoso.com"
$MatchField    = "Author"        # Author = created by, Editor = last modified by
$KeepStructure = $true
$MaxRetries    = 3
$LogFile       = "C:\SPDownloads\UserFiles_$(Get-Date -f 'yyyyMMdd_HHmmss').csv"
# -----------------------------------------------------------------

try {
    Connect-PnPOnline -Url $SiteURL -Interactive -ClientId $ClientId -ErrorAction Stop
    Write-Host "Connected to $SiteURL" -ForegroundColor Green
}
catch {
    Write-Host "Connection failed: $($_.Exception.Message)" -ForegroundColor Red
    return
}

try {
    $User   = Get-PnPUser | Where-Object { $_.Email -eq $TargetUser }
    if ($null -eq $User) {
        $User = New-PnPUser -LoginName $TargetUser -ErrorAction Stop
    }
    $UserId = $User.Id
    Write-Host "Resolved user '$TargetUser' to ID $UserId" -ForegroundColor Gray
}
catch {
    Write-Host "Could not resolve user '$TargetUser': $($_.Exception.Message)" -ForegroundColor Red
    Disconnect-PnPOnline
    return
}

if (-not (Test-Path $DownloadPath)) {
    New-Item -Path $DownloadPath -ItemType Directory -Force | Out-Null
}

$List       = Get-PnPList -Identity $LibraryName -Includes RootFolder
$LibraryUrl = $List.RootFolder.ServerRelativeUrl

$CAML = @"
<View Scope='RecursiveAll'>
  <Query>
    <Where>
      <Eq>
        <FieldRef Name='$MatchField' LookupId='TRUE'/>
        <Value Type='Integer'>$UserId</Value>
      </Eq>
    </Where>
  </Query>
  <ViewFields>
    <FieldRef Name='FileRef'/>
    <FieldRef Name='FileLeafRef'/>
    <FieldRef Name='File_x0020_Size'/>
    <FieldRef Name='Author'/>
    <FieldRef Name='Editor'/>
    <FieldRef Name='Created'/>
    <FieldRef Name='Modified'/>
  </ViewFields>
</View>
"@

Write-Host "Searching for files where $MatchField = $TargetUser..." -ForegroundColor Cyan

$UserFiles = Get-PnPListItem -List $LibraryName -Query $CAML -PageSize 2000 |
             Where-Object { $_.FileSystemObjectType -eq "File" }

$Total = @($UserFiles).Count

if ($Total -eq 0) {
    Write-Host "No files found for user '$TargetUser'." -ForegroundColor Yellow
    Disconnect-PnPOnline
    return
}

$TotalMB = [math]::Round((($UserFiles | ForEach-Object { [int64]$_.FieldValues.File_x0020_Size } | Measure-Object -Sum).Sum / 1MB), 2)
Write-Host "Found $Total files ($TotalMB MB). Starting download...`n" -ForegroundColor Cyan

$Downloaded = 0
$Failed     = 0
$Counter    = 0
$Log        = @()

foreach ($Item in $UserFiles) {

    $Counter   = $Counter + 1
    $SourceUrl = $Item.FieldValues.FileRef
    $FileName  = $Item.FieldValues.FileLeafRef -replace '[\\/:*?"<>|]', '_'
    $SizeMB    = [math]::Round([int64]$Item.FieldValues.File_x0020_Size / 1MB, 2)

    if ($KeepStructure) {
        $RelPath      = $SourceUrl.Replace($LibraryUrl, "").Replace("/", "\").TrimStart("\")
        $TargetFolder = Split-Path (Join-Path $DownloadPath $RelPath) -Parent
    }
    else {
        $TargetFolder = $DownloadPath
        $Suffix = 1
        while (Test-Path (Join-Path $TargetFolder $FileName)) {
            $Base     = [System.IO.Path]::GetFileNameWithoutExtension($Item.FieldValues.FileLeafRef)
            $Ext      = [System.IO.Path]::GetExtension($Item.FieldValues.FileLeafRef)
            $FileName = "$($Base)_$Suffix$Ext"
            $Suffix   = $Suffix + 1
        }
    }

    if (-not (Test-Path $TargetFolder)) {
        New-Item -Path $TargetFolder -ItemType Directory -Force | Out-Null
    }

    Write-Progress -Activity "Downloading files for $TargetUser" `
                   -Status "$Counter of $Total : $FileName" `
                   -PercentComplete (($Counter / $Total) * 100)

    $Attempt = 0
    $Success = $false

    while (-not $Success -and $Attempt -lt $MaxRetries) {

        $Attempt = $Attempt + 1

        try {
            Get-PnPFile -Url $SourceUrl -Path $TargetFolder -FileName $FileName -AsFile -Force -ErrorAction Stop

            $Success    = $true
            $Downloaded = $Downloaded + 1

            Write-Host "[$Counter/$Total] $FileName ($SizeMB MB)" -ForegroundColor Green

            $Log += [PSCustomObject]@{
                FileName       = $FileName
                SizeMB         = $SizeMB
                CreatedBy      = $Item.FieldValues.Author.LookupValue
                ModifiedBy     = $Item.FieldValues.Editor.LookupValue
                Created        = $Item.FieldValues.Created
                Modified       = $Item.FieldValues.Modified
                SharePointPath = $SourceUrl
                Status         = "Success"
            }
        }
        catch {
            if ($Attempt -ge $MaxRetries) {
                $Failed = $Failed + 1
                Write-Host "[$Counter/$Total] FAILED: $FileName" -ForegroundColor Red

                $Log += [PSCustomObject]@{
                    FileName       = $FileName
                    SizeMB         = $SizeMB
                    CreatedBy      = $Item.FieldValues.Author.LookupValue
                    ModifiedBy     = $Item.FieldValues.Editor.LookupValue
                    Created        = $Item.FieldValues.Created
                    Modified       = $Item.FieldValues.Modified
                    SharePointPath = $SourceUrl
                    Status         = "Failed: $($_.Exception.Message)"
                }
            }
            else {
                Start-Sleep -Seconds ([math]::Pow(2, $Attempt) * 5)
            }
        }
    }
}

Write-Progress -Activity "Downloading user files" -Completed

if ($Log.Count -gt 0) {
    $Log | Export-Csv -Path $LogFile -NoTypeInformation -Encoding UTF8
}

Write-Host "`n============== SUMMARY ==============" -ForegroundColor Cyan
Write-Host " User        : $TargetUser"
Write-Host " Match field : $MatchField"
Write-Host " Files found : $Total"
Write-Host " Downloaded  : $Downloaded" -ForegroundColor Green
Write-Host " Failed      : $Failed"     -ForegroundColor Red
Write-Host " Total size  : $TotalMB MB"
Write-Host " Saved to    : $DownloadPath"
Write-Host " Log file    : $LogFile"
Write-Host "=====================================`n" -ForegroundColor Cyan

Disconnect-PnPOnline

Note: if the user isn’t in the site’s user list yet, New-PnPUser adds them, which is a write to the site and needs more than Read access. If you only have Read, use the Where-Object version above.

Download files by a metadata column value

Most business libraries have columns such as Department, Status or Project. This downloads only files whose Status column is Approved:

# Download only files where the "Status" choice column equals "Approved"
$Approved = Get-PnPListItem -List $LibraryName -PageSize 2000 |
            Where-Object { $_.FieldValues.Status -eq "Approved" }

foreach ($Item in $Approved) {
    Get-PnPFile -Url $Item.FieldValues.FileRef -Path $DownloadPath `
                -FileName $Item.FieldValues.FileLeafRef -AsFile -Force
}

Scripts need the column’s internal name, not its display name; spaces become _x0020_. Filtering on choice columns in a document library works the same way. This lists them, and my post on getting SharePoint column internal names explains the details:

Get-PnPField -List $LibraryName |
    Select-Object Title, InternalName, TypeAsString |
    Sort-Object Title

Download files by name pattern

Need every file with Invoice in its name? A -like wildcard does it. Power Automate users can do the same with a filename contains check in Power Automate.

$Pattern = "*Invoice*"

$Matched = Get-PnPListItem -List $LibraryName -PageSize 2000 |
           Where-Object {
               $_.FileSystemObjectType -eq "File" -and
               $_.FieldValues.FileLeafRef -like $Pattern
           }

foreach ($Item in $Matched) {
    Get-PnPFile -Url $Item.FieldValues.FileRef -Path $DownloadPath `
                -FileName $Item.FieldValues.FileLeafRef -AsFile -Force
}

Download a single file with Get-PnPFile

For one document, no loop is needed. Get-PnPFile accepts a server-relative or site-relative URL:

Get-PnPFile -Url "/sites/Finance/Shared Documents/Budget 2026.xlsx" `
            -Path "C:\SPDownloads" `
            -FileName "Budget 2026.xlsx" -AsFile -Force

To read a text file’s contents without saving it, use -AsString. If users only need a nicer button, you can try customizing the SharePoint Download button with SPFx. For app makers, there’s also a guide to downloading a file from a SharePoint library in Power Apps.

$Content = Get-PnPFile -Url "/sites/Finance/Shared Documents/Notes.txt" -AsString
Write-Host $Content

Zip the downloaded files

For a handover or a monthly backup, compress the folder once the download finishes. Microsoft documents a 2 GB maximum file size for Compress-Archive, and it skips hidden files, so very large folders are better zipped with a dedicated tool. If downloads are blocked by policy, check whether someone set up restrict or block download in SharePoint.

$DownloadPath = "C:\SPDownloads\Finance"
$ZipPath      = "C:\SPDownloads\Finance_$(Get-Date -Format 'yyyyMMdd').zip"

# ... run your download loop first ...

Compress-Archive -Path "$DownloadPath\*" -DestinationPath $ZipPath -Force
Write-Host "Archive created: $ZipPath" -ForegroundColor Green

Download files from every document library on a site

To copy a whole site, loop through each visible document library and skip the system ones. Each library goes into its own local folder. To repeat this across many sites, start from my list of getting all SharePoint Online sites with PnP PowerShell.

Before a full-site copy, I check storage and owners with SharePoint Online site details in PowerShell so I know how big the job is.

$ExcludeLists = @("Form Templates", "Site Assets", "Style Library",
                  "Site Pages", "Preservation Hold Library", "Customized Reports")

$Libraries = Get-PnPList | Where-Object {
    $_.BaseTemplate -eq 101 -and $_.Hidden -eq $false -and
    $ExcludeLists -notcontains $_.Title
}

foreach ($Lib in $Libraries) {

    $LibFolder = Join-Path "C:\SPDownloads" ($Lib.Title -replace '[\\/:*?"<>|]', '_')
    New-Item -Path $LibFolder -ItemType Directory -Force | Out-Null

    Write-Host "`n=== Library: $($Lib.Title) ===" -ForegroundColor Yellow

    Get-PnPListItem -List $Lib.Title -PageSize 2000 |
    Where-Object { $_.FileSystemObjectType -eq "File" } |
    ForEach-Object {
        Get-PnPFile -Url $_.FieldValues.FileRef -Path $LibFolder `
                    -FileName $_.FieldValues.FileLeafRef -AsFile -Force
    }
}

Production script: logging, retries and throttling

The scripts above are fine for one-off jobs. For 20,000 files, SharePoint Online will throttle you with HTTP 429 or 503 responses, so you need retries and a log. More reusable scripts are in my collection of PnP PowerShell scripts for SharePoint developers.

# ---------- CONFIGURATION ----------
$SiteURL      = "https://contoso.sharepoint.com/sites/Finance"
$ClientId     = "11111111-2222-3333-4444-555555555555"
$LibraryName  = "Documents"
$DownloadPath = "C:\SPDownloads\Finance"
$LogFile      = "C:\SPDownloads\DownloadLog_$(Get-Date -f 'yyyyMMdd_HHmmss').csv"
$MaxRetries   = 3
# -----------------------------------

function Write-Log {
    param($File, $Status, $Message)
    [PSCustomObject]@{
        Timestamp = (Get-Date -Format "yyyy-MM-dd HH:mm:ss")
        File      = $File
        Status    = $Status
        Message   = $Message
    } | Export-Csv -Path $LogFile -NoTypeInformation -Append
}

Connect-PnPOnline -Url $SiteURL -Interactive -ClientId $ClientId

$List       = Get-PnPList -Identity $LibraryName -Includes RootFolder
$LibraryUrl = $List.RootFolder.ServerRelativeUrl

$Items = Get-PnPListItem -List $LibraryName -PageSize 2000 |
         Where-Object { $_.FileSystemObjectType -eq "File" }

$Total = $Items.Count
$Ok = 0; $Fail = 0; $n = 0

foreach ($Item in $Items) {

    $n++
    $FileUrl  = $Item.FieldValues.FileRef
    $FileName = $Item.FieldValues.FileLeafRef

    $RelPath     = $FileUrl.Replace($LibraryUrl, "").Replace("/", "\").TrimStart("\")
    $LocalFile   = Join-Path $DownloadPath $RelPath
    $LocalFolder = Split-Path $LocalFile -Parent

    if (-not (Test-Path $LocalFolder)) {
        New-Item -Path $LocalFolder -ItemType Directory -Force | Out-Null
    }

    Write-Progress -Activity "Downloading from $LibraryName" `
                   -Status "$n / $Total : $FileName" `
                   -PercentComplete (($n / $Total) * 100)

    $Attempt = 0
    $Done = $false

    while (-not $Done -and $Attempt -lt $MaxRetries) {
        $Attempt++
        try {
            Get-PnPFile -Url $FileUrl -Path $LocalFolder -FileName $FileName -AsFile -Force -ErrorAction Stop
            Write-Log -File $FileUrl -Status "Success" -Message "Attempt $Attempt"
            $Ok++; $Done = $true
        }
        catch {
            if ($Attempt -ge $MaxRetries) {
                Write-Log -File $FileUrl -Status "Failed" -Message $_.Exception.Message
                Write-Host "FAILED: $FileName" -ForegroundColor Red
                $Fail++
            }
            else {
                # Exponential back-off for throttling (429 / 503)
                Start-Sleep -Seconds ([math]::Pow(2, $Attempt) * 5)
            }
        }
    }
}

Write-Host "`n===== SUMMARY =====" -ForegroundColor Cyan
Write-Host "Total files : $Total"
Write-Host "Downloaded  : $Ok"   -ForegroundColor Green
Write-Host "Failed      : $Fail" -ForegroundColor Red
Write-Host "Log file    : $LogFile"

Disconnect-PnPOnline

When SharePoint throttles a client, retrying immediately makes it worse. The growing pause (10 seconds, then 20) gives the service room to recover, and the CSV log tells you exactly which files to rerun. As noted earlier, raise $MaxRetries to 4 for a third, 40-second wait.

Fix long file paths (the 260-character limit)

Windows limits classic paths to 260 characters, while SharePoint allows much longer ones, so deep folders fail with a path-too-long error. There are two fixes. The first is to enable long paths in Windows from an elevated window. Flattening deep trees by moving folders in SharePoint Online also helps.

New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" `
    -Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force

Microsoft notes that this setting only helps apps that are long-path aware, and a reboot might be needed before every process picks it up. The second fix is a short download root (C:\SP) plus trimming names that are too long:

$MaxPath = 240

if ($LocalFile.Length -gt $MaxPath) {
    $Ext  = [System.IO.Path]::GetExtension($FileName)
    $Base = [System.IO.Path]::GetFileNameWithoutExtension($FileName)
    $Allowed = $MaxPath - $LocalFolder.Length - $Ext.Length - 1
    if ($Allowed -gt 10) {
        $FileName  = $Base.Substring(0, $Allowed) + $Ext
        $LocalFile = Join-Path $LocalFolder $FileName
    }
}

Also replace characters Windows doesn’t allow in file and folder names:

$SafeName = $Name -replace '[\\/:*?"<>|]', '_'

Speed up downloads from large libraries

With 50,000+ files, these habits save hours. Indexes make server-side filters possible, and SharePoint can enable automatic indexing on SharePoint for list views.

  • Always use -PageSize. 2,000 is a good balance between round trips and memory.
  • Filter on the server with CAML when the column is indexed; Where-Object downloads every item’s metadata first.
  • Request only the fields you need (below). -Fields isn’t available together with -Query; use ViewFields in the CAML instead.
  • Download to a local SSD, not a synced OneDrive folder, which uploads every file straight back.
  • Split by top-level folder so one failure doesn’t restart everything, and run big jobs overnight.
$Items = Get-PnPListItem -List $LibraryName -PageSize 2000 `
         -Fields "FileRef", "FileLeafRef", "File_x0020_Size", "Modified"

Verify that the download is complete

Never trust a script that just says it finished. Compare the number of files in SharePoint with the number on disk:

$SPCount    = (Get-PnPListItem -List $LibraryName -PageSize 2000 |
               Where-Object { $_.FileSystemObjectType -eq "File" }).Count

$LocalCount = (Get-ChildItem -Path $DownloadPath -Recurse -File).Count

Write-Host "SharePoint files : $SPCount"
Write-Host "Local files      : $LocalCount"

if ($SPCount -eq $LocalCount) {
    Write-Host "MATCH - download complete." -ForegroundColor Green
} else {
    Write-Host "MISMATCH - check the log file." -ForegroundColor Red
}

For a stricter check, compare the total size in bytes:

$SPBytes = (Get-PnPListItem -List $LibraryName -PageSize 2000 |
            Where-Object { $_.FileSystemObjectType -eq "File" } |
            Measure-Object -Property { [int64]$_.FieldValues.File_x0020_Size } -Sum).Sum

$LocalBytes = (Get-ChildItem $DownloadPath -Recurse -File | Measure-Object Length -Sum).Sum

Write-Host "SharePoint: $([math]::Round($SPBytes/1GB,2)) GB"
Write-Host "Local     : $([math]::Round($LocalBytes/1GB,2)) GB"

A flat download with renamed duplicates still matches on count. If the numbers differ, the CSV log shows which files failed; rerun only those. To send files the other way, here’s how to upload files to a SharePoint document library.

If the real goal is moving content to another Microsoft 365 tenant rather than to disk, look at copying SharePoint files to another tenant instead.

Schedule the download to run every night

Once the script works, save it as Download-SPFiles.ps1, switch the connection to certificate-based app-only authentication (an interactive sign-in can’t run unattended) and register a scheduled task. More SharePoint automation lives in the SharePoint PowerShell tutorials:

$Action  = New-ScheduledTaskAction -Execute "pwsh.exe" `
           -Argument "-NoProfile -ExecutionPolicy Bypass -File C:\Scripts\Download-SPFiles.ps1"

$Trigger = New-ScheduledTaskTrigger -Daily -At 2:00AM

Register-ScheduledTask -TaskName "SharePoint Nightly Download" `
    -Action $Action -Trigger $Trigger -RunLevel Highest -Description "Downloads files from SharePoint library"

Note: without -User and -Password (or a -Principal), the task runs only while you’re signed in to Windows, and -RunLevel Highest needs an elevated window to register. Pair it with the last-7-days filter so each night pulls only the changes.

Troubleshooting common download errors

  • “A parameter cannot be found that matches parameter name ‘Interactive'”: you ran the app registration line with -Interactive. Remove it, as noted in the prerequisites.
  • “File Not Found” from Get-PnPFile: the URL is wrong. Use FileRef as is; a site-relative path must start after the site URL, such as Shared Documents/Budget.xlsx.
  • “The attempted operation is prohibited because it exceeds the list view threshold”: a CAML filter hit a non-indexed column in a library over 5,000 items. Index the column or filter with Where-Object.
  • Access denied: your account or app lacks Read on that library or folder, or the app-only permission wasn’t consented.
  • Some files are locked or checked out: checked-out files download the last checked-in version. A file someone is editing can also report the file is locked for shared use error.
  • Path too long or invalid characters: use the long path fixes and the character replace above.
  • Connection drops in older CSOM-based scripts: see the ExecuteQuery unable to connect error; PnP PowerShell handles sign-in for you.

If you want guided practice with scripts like these, the SharePoint PowerShell training course walks through them on a real tenant.

If you’re automating document libraries with PnP PowerShell, these guides pair well with this one:

Frequently Asked Questions

How do I download all files from a SharePoint library using PowerShell?

Connect with Connect-PnPOnline using your own app’s -ClientId, read the files with Get-PnPListItem -PageSize 2000, skip folder items, and save each file with Get-PnPFile -Url FileRef -Path folder -Filename FileLeafRef -AsFile -Force.

What does Get-PnPFile -AsFile do?

-AsFile saves the file to the local folder in -Path with the name in -Filename, and -Force overwrites an existing copy. Without it, Get-PnPFile returns a file object. The Get-PnPFile reference lists the -AsString, -AsListItem and -AsMemoryStream options.

How many files can I download from SharePoint at once in the browser?

Up to 10,000 files per zip download, with compressed files capped at 10 GB each and 20 GB overall. Microsoft lists every limit in its download files and folders support article. PowerShell has no such cap.

Can I download a SharePoint folder with all subfolders using PnP PowerShell?

Yes. Use Get-PnPFolderItem with -ItemType File in a recursive function, or the -Recursive switch, and call Get-PnPFile -AsFile for each file, creating matching local folders as you go.

Why does Connect-PnPOnline -Interactive fail without a client ID?

Since September 9, 2024, PnP PowerShell needs an Entra ID app registered in your own tenant. Register it once with Register-PnPEntraIDAppForInteractiveLogin and pass its ID with -ClientId.