Skip to main content

Archiving OneDrive to SharePoint with PowerShell and Microsoft Graph

· 9 min read

When someone leaves an organisation, their OneDrive can contain files that the rest of the team still needs. Downloading those files and uploading them to SharePoint works, but it isn't a particularly enjoyable way to spend an afternoon - especially when you have several accounts to deal with.

Microsoft Graph can copy the content directly between OneDrive and SharePoint without sending the files through your computer. These scripts wrap that process so you can archive one user's content, or queue a batch and check its progress later.

What we're calling an archive​

Here, "archiving" means copying OneDrive content into a folder in a SharePoint document library. It does not mean Microsoft's OneDrive account archive service, a backup, or a compliance-retention solution.

The scripts:

  • Find the user's OneDrive and the destination site's default document library.
  • Create a destination folder using the user's Microsoft Entra display name, unless you supply your own name.
  • Queue a copy job for each top-level file or folder. Graph copies folder contents as part of that job.
  • Optionally grant someone read access to the destination folder and send a sharing invitation.
  • Record bulk copy jobs in a JSON state file so you can check them later.

They do not delete the source files, remove the user, change licensing, or complete the rest of your offboarding process.

warning

This is a content copy, not a faithful preservation of the original OneDrive. The scripts use Graph's default version behaviour, which copies the latest major version rather than the full version history. Original metadata and sharing permissions are not retained. Copied items inherit access from the destination folder.

If you need legal holds, retention, version history, or a recoverable backup, use the appropriate Microsoft 365 controls or backup tooling instead.

Prerequisites and permissions​

Use PowerShell 7 and an existing, provisioned OneDrive for a work or school account. The source OneDrive must still be accessible when the copy runs. Start this work before removing the account or losing access to its content.

The destination SharePoint site must already exist. These scripts use its default document library; they do not accept a separate library name or ID.

Create a dedicated Entra app registration in the tenant where you will run the archive, add the following Microsoft Graph application permissions, and grant admin consent:

PermissionUsed for
Files.ReadWrite.AllAccessing OneDrive content and queuing copies.
Sites.ReadWrite.AllResolving and writing to the destination SharePoint site.
User.Read.AllLooking up the user's display name for the destination folder.

An explicit DestFolderName skips the display-name lookup for that user. If every user has an explicit folder name, that lookup does not need User.Read.All.

These are broad permissions. Use a dedicated app, protect its credentials, and only run against content you are authorised to access. The examples use placeholders rather than embedded credentials.

Run one tenant per batch, with source and destination in that tenant. Supplying -TenantId does not turn this into a cross-tenant migration tool. App-only cross-geo copying is also unsupported.

Get the scripts​

Download the files from the OneDrive archive folder in Blog-Scripts and keep them together:

FilePurpose
OneDriveArchive.psm1Shared authentication, destination lookup, copy, and monitoring functions.
Invoke-OneDriveArchive.ps1Copy one user's content and poll for completion.
Invoke-OneDriveArchiveBulk.ps1Queue users from a CSV, a UPN list, or both.
Get-OneDriveArchiveStatus.ps1Refresh the bulk job state and display a summary.
New-OneDriveArchiveBulkTemplate.ps1Generate a CSV with the expected columns.

You don't need the Microsoft Graph PowerShell SDK: the scripts make HTTPS requests directly. The optional Key Vault authentication path requires Az.Accounts, Az.KeyVault, an authenticated Azure session, and permission to read the relevant secrets.

Copy one user's OneDrive​

Retrieve the client secret from your approved secrets store, then run:

$parameters = @{
ClientId = '<application-id>'
ClientSecret = $clientSecret
TenantId = '<tenant-id>'
SourceUPN = '[email protected]'
DestSiteHost = 'contoso.sharepoint.com'
DestSitePath = '/sites/OneDriveArchive'
PollTimeoutMinutes = 30
}

$job = .\Invoke-OneDriveArchive.ps1 @parameters
$job.Items | Format-Table Name, Status, Error, PollError -AutoSize

Omit TenantId to try discovery using the source UPN domain, then an onmicrosoft.com domain inferred from the destination hostname. If discovery cannot resolve the tenant, the script stops and asks you to supply the identifier explicitly.

The destination folder normally uses the user's display name. Unsupported characters are replaced with underscores, and trailing spaces and dots are removed. A failed display-name lookup stops that user's archive rather than silently substituting an email prefix.

To choose a folder name yourself, add DestFolderName to the parameter hashtable:

$parameters.DestFolderName = 'Alex Taylor - 2026-11'

The single-user command polls every 15 seconds until the jobs finish or the timeout expires. Its returned job object includes item statuses and monitor URLs. A polling timeout does not cancel the server-side copy. Use the bulk command below, even for one user, if you want the supplied status script to track the work using a persistent state file.

Queue a batch​

Generate the CSV template:

.\New-OneDriveArchiveBulkTemplate.ps1 -Path '.\leavers.csv' -NoOpen

Without -NoOpen, the template opens in your default application. The generator refuses to overwrite an existing file.

Replace the example row with your users:

SourceUPN,DestFolderName,DestSiteHost,DestSitePath,ShareWithUPN
[email protected],Sam Morgan - 2026-11,,,[email protected]

SourceUPN is required. The other columns are optional:

  • Blank DestFolderName uses the user's display name.
  • Blank DestSiteHost and DestSitePath use the defaults passed to the command.
  • Blank ShareWithUPN uses the command's default recipient, if supplied. With neither a row value nor a default, no invitation is sent.

All rows need a destination, either in the CSV or through command defaults.

$auth = @{
ClientId = '<application-id>'
ClientSecret = $clientSecret
TenantId = '<tenant-id>'
}

.\Invoke-OneDriveArchiveBulk.ps1 @auth `
-CsvPath '.\leavers.csv' `
-DestSiteHost 'contoso.sharepoint.com' `
-DestSitePath '/sites/OneDriveArchive' `
-StateFilePath '.\leavers-jobs.json'

No CSV is needed for a short list:

.\Invoke-OneDriveArchiveBulk.ps1 @auth `
-DestSiteHost 'contoso.sharepoint.com' `
-DestSitePath '/sites/OneDriveArchive' `
-StateFilePath '.\manual-jobs.json'

The command queues work without waiting for the files to finish copying. Wait for the queue command to return and save the state file before closing your session. After that, the copies run in Microsoft's infrastructure; you don't need to keep PowerShell open.

The queue command saves state after each user and refuses to reuse an existing state-file path. An interruption while processing a user can still leave that user's copies running without a saved record, so check the destination before retrying an interrupted batch.

Check progress later​

Use the same app and tenant:

.\Get-OneDriveArchiveStatus.ps1 @auth -StateFilePath '.\leavers-jobs.json'

Each invocation checks pending jobs once, updates the JSON file, and displays:

ColumnMeaning
SourceUPN / DestFolderNameThe user and the actual destination folder name returned by Graph.
TotalNumber of top-level items recorded, not the recursive file count.
CompletedItems whose copy monitors reported completion.
FailedItems rejected during queuing or reported as failed later.
PendingItems without confirmed completion or failure.
PollErrorsPending items with an error from their most recent status check.
QueueErrorA user-level error, such as a failed OneDrive lookup.
ShareErrorFailure to grant the optional recipient access.

A failed status request leaves the item pending, logs a warning, and records PollError in the state file. A later successful check clears that polling error. Graph copy failures are recorded separately.

For item-level details:

$jobs = @(Get-Content -LiteralPath '.\leavers-jobs.json' -Raw | ConvertFrom-Json)
$jobs | ForEach-Object {
Write-Host $_.SourceUPN
$_.Items | Format-Table Name, Status, Error, PollError -Wrap
}

Re-run the status command, not the queue command, to check existing jobs. It does not retry failed copies or failed sharing invitations.

Optional sharing and Key Vault credentials​

-ShareWithUPN '[email protected]' grants read access to the newly created folder, requires sign-in, and sends Microsoft's standard invitation. Sharing happens during queuing, so the invitation may arrive before the content has finished copying.

That invitation is additional access, not an access restriction. Site owners, library members, and anyone else with inherited permissions may already be able to see the archive. Choose and secure the destination accordingly.

For Key Vault credentials, store the application ID and client secret as ODArchiverClientId and ODArchiverClientSecret. After signing in to Azure with an account authorised to read them, use:

Connect-AzAccount

$auth = @{
KeyVaultName = 'ContosoAutomation'
TenantId = '<tenant-id>'
}

.\Invoke-OneDriveArchiveBulk.ps1 @auth `
-CsvPath '.\leavers.csv' `
-DestSiteHost 'contoso.sharepoint.com' `
-DestSitePath '/sites/OneDriveArchive' `
-StateFilePath '.\vault-jobs.json'

.\Get-OneDriveArchiveStatus.ps1 @auth -StateFilePath '.\vault-jobs.json'

Use ClientIdSecretName and ClientSecretSecretName if your vault uses different secret names. The queue and status commands must use the same authentication parameter set: don't combine KeyVaultName with ClientId and ClientSecret.

The bulk script​

This Script Was Updated

This script was updated after being published, if you're using it please compare the version you have with the version available here.

This script was last updated on 2026/11/02.

Invoke-OneDriveArchiveBulk.ps1
loading...

Remember to download the shared module as well; the entry point isn't standalone.

Before you rely on the result​

  • Test with one user first. Verify the copied content and access in SharePoint, not just the job summary.
  • Repeated runs create new folders. Conflict handling uses rename, not merge or overwrite. A fresh state file does not make a repeat run idempotent.
  • Large folders have limits. Microsoft documents a limit of 30,000 drive items per copy operation. A top-level folder and its contents are one operation; splitting users into smaller batches does not bypass that folder's limit.
  • Throttling and expired tokens need attention. The scripts do not automatically retry queue requests or refresh the access token during a run. Status invocations with pending work authenticate afresh. Investigate persistent polling errors instead of assuming a pending copy is still healthy.
  • Protect the state file. It contains user identifiers, file or folder names, destination identifiers, error details, and copy-monitor URLs. Treat it as operational data, not something to publish alongside the scripts.
  • Do not remove the source based only on a queued job. Confirm completion, inspect failures, verify destination content and permissions, and follow your organisation's retention and offboarding requirements.

For the underlying behaviour and service limits, see Microsoft's driveItem copy documentation and long-running actions overview.