Tags: Relational databasesAutomationDesktopCommand line
“I need the Maintenance Requests list in SQL Server so finance can join it to invoices.
Every PowerShell example I find either uses Get-SPWeb, which is for on-premises
servers, or stops at Export-Csv.” That is the usual starting point. The list lives in
SharePoint Online, the report wants a table, and PnP PowerShell is already installed on
someone’s laptop.
PnP PowerShell is a good tool for this. It is free and cross-platform, and one cmdlet pages through a list of any size. This post gives you a script that copies a list into SQL Server, with the sign-in setup that changed in 2024 and again in 2026. The second half covers where a nightly run of that script starts to cost you time.
Can PnP PowerShell export a SharePoint list to SQL Server?
Yes, with two modules and about a hundred lines of PowerShell. PnP PowerShell reads the
list. Microsoft’s SqlServer module brings the SQL client library, and its SqlBulkCopy
class loads the rows. The script here was written against these versions, which were
current on 23 October 2026:
- PowerShell 7.4 or later, which PnP.PowerShell 3.4.1 requires
- PnP.PowerShell 3.4.1, the latest release that is not a preview
- SqlServer 22.4.5.1, from the PowerShell Gallery
Pin the PnP version. The project ships often, and parameter behaviour does change between releases, so a scheduled job should only move to a new version after a test run.
We ran the SQL half of this script (table creation, bulk load, staging swap, lookup split and the 12-lookup guard) against SQL Server 2022 with simulated SharePoint list items. We have not run it end to end against a live SharePoint Online list, so test it on a copy of your list first.
What sign-in does PnP PowerShell need now?
Two changes catch people who last wrote one of these scripts a few years ago.
PnP’s shared multi-tenant app is gone. The PnP guide to
registering an application says that using your own Entra ID app registration became
mandatory on 9 September 2024. Every Connect-PnPOnline call now passes the client id of
an app in your own tenant.
The old SharePoint app-only route has gone as well. Microsoft’s
Azure ACS retirement notice
says ACS stopped working for existing tenants and was fully retired on 2 April 2026. A
client id and secret created through appregnew.aspx no longer signs in.
For a script that runs unattended, the setup is:
- Create the app registration and its certificate.
Register-PnPEntraIDAppdoes both in one step, or you can create them in the Entra admin centre and upload the public key. - Give the app the SharePoint application permission
Sites.Selected, which on its own grants access to no site at all, and have an administrator consent to it. - Grant the app read access to the one site it needs with
Grant-PnPEntraIDAppSitePermission -AppId <client id> -DisplayName "List export" -Permissions Read -Site <site url>, run by an administrator who signs in interactively. The same steps for SPSync, with the list-level variant, are on Connect to SharePoint.
PnP’s authentication guide is clear that app-only access needs a certificate; a client
secret is not supported for it. Note that the permission sits on the SharePoint API. The
cmdlets in this script use SharePoint’s client object model, and when we pointed the
script at a site our app could read only through a Microsoft Graph Sites.Selected grant,
Connect-PnPOnline succeeded and the first read came back Unauthorized.
The script, step by step
Save this as Export-ListToSql.ps1. It takes the site, list and table as parameters and
reads both secrets from environment variables, so nothing sensitive is written into the
file or printed to the console.
#Requires -Version 7.4#Requires -Modules @{ ModuleName = 'PnP.PowerShell'; RequiredVersion = '3.4.1' }#Requires -Modules @{ ModuleName = 'SqlServer'; ModuleVersion = '22.4.5.1' }<# Copies one SharePoint Online list into a SQL Server table. Full reload on every run: read every item, load a staging table, then swap it for the live table in one transaction. A failed run leaves the old table.
Secrets come from environment variables, never from this file: PNP_CERT_PASSWORD password for the app registration's .pfx file SQL_CONNECTION SQL Server connection string#>param( [Parameter(Mandatory)] [string] $SiteUrl, [Parameter(Mandatory)] [string] $ListName, [Parameter(Mandatory)] [string] $ClientId, [Parameter(Mandatory)] [string] $Tenant, [Parameter(Mandatory)] [string] $CertificatePath, [Parameter(Mandatory)] [string] $TableName, [string] $Schema = 'dbo', [int] $PageSize = 2000)$ErrorActionPreference = 'Stop'Import-Module SqlServer # brings Microsoft.Data.SqlClient
# 1. Sign in as your own Entra ID app registration, app-only, with a certificate.$certPassword = ConvertTo-SecureString $env:PNP_CERT_PASSWORD -AsPlainText -ForceConnect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Tenant $Tenant ` -CertificatePath $CertificatePath -CertificatePassword $certPassword
# 2. Read the column definitions and decide a SQL type for each one.$sqlTypes = @{ Counter = 'int'; Text = 'nvarchar(255)'; Note = 'nvarchar(max)' Choice = 'nvarchar(255)'; MultiChoice = 'nvarchar(max)' Number = 'float'; Currency = 'decimal(19,4)'; DateTime = 'datetime2' Boolean = 'bit'; URL = 'nvarchar(max)' Lookup = 'int'; User = 'int'; LookupMulti = 'nvarchar(max)'; UserMulti = 'nvarchar(max)'}$clrTypes = @{ int = [int]; float = [double]; 'decimal(19,4)' = [decimal]; datetime2 = [datetime]; bit = [bool] }$keep = 'ID', 'Created', 'Modified', 'Author', 'Editor'
$visible = Get-PnPField -List $ListName | Where-Object { -not $_.Hidden -and (-not $_.ReadOnlyField -or $_.InternalName -in $keep)}$fields = @($visible | Where-Object { $sqlTypes.ContainsKey($_.TypeAsString) })$skipped = @($visible | Where-Object { -not $sqlTypes.ContainsKey($_.TypeAsString) })if ($skipped) { Write-Host "Skipping: $(($skipped | ForEach-Object { "$($_.InternalName) ($($_.TypeAsString))" }) -join ', ')" }$lookupTypes = 'Lookup', 'LookupMulti', 'User', 'UserMulti'$lookupCount = @($fields | Where-Object TypeAsString -in $lookupTypes).Countif ($lookupCount -gt 12) { throw "$ListName has $lookupCount lookup and person columns; one query can project 12."}
# One SQL column per field; a lookup or person column becomes an id and a display value.function New-Column($name, $field, $part, $sqlType) { [pscustomobject]@{ Name = $name; Field = $field.InternalName; Kind = $field.TypeAsString; Part = $part; SqlType = $sqlType }}$columns = foreach ($f in $fields) { if ($f.TypeAsString -in 'Lookup', 'User') { New-Column "$($f.InternalName)_id" $f 'id' 'int' New-Column "$($f.InternalName)_value" $f 'label' 'nvarchar(255)' } else { New-Column $f.InternalName $f 'value' $sqlTypes[$f.TypeAsString] }}
# 3. Read every item, a page at a time, into a DataTable shaped like the SQL table.$data = [System.Data.DataTable]::new()foreach ($c in $columns) { $clr = $clrTypes[$c.SqlType]; if (-not $clr) { $clr = [string] } [void]$data.Columns.Add($c.Name, $clr)}
function Get-CellValue($value, $column) { if ($null -eq $value) { return [DBNull]::Value } switch ($column.Kind) { { $_ -in 'Lookup', 'User' } { if ($column.Part -eq 'id') { return $value.LookupId } else { return $value.LookupValue } } { $_ -in 'LookupMulti', 'UserMulti' } { return (@($value | ForEach-Object LookupValue) -join '; ') } 'MultiChoice' { return (@($value) -join '; ') } 'URL' { return $value.Url } default { return $value } }}
$fieldNames = @($fields.InternalName)Get-PnPListItem -List $ListName -PageSize $PageSize -Fields $fieldNames | ForEach-Object { $row = $data.NewRow() foreach ($c in $columns) { $row[$c.Name] = Get-CellValue $_.FieldValues[$c.Field] $c } $data.Rows.Add($row)}Write-Host "Read $($data.Rows.Count) items and $($columns.Count) columns from '$ListName'."
# 4. Load a staging table, then swap it in one transaction.function Quote([string] $name) { '[' + $name.Replace(']', ']]') + ']' }$target = "$(Quote $Schema).$(Quote $TableName)"$stagingName = "$($TableName)__staging"$staging = "$(Quote $Schema).$(Quote $stagingName)"$columnSql = ($columns | ForEach-Object { $constraint = if ($_.Name -eq 'ID') { ' NOT NULL PRIMARY KEY' } else { ' NULL' } "$(Quote $_.Name) $($_.SqlType)$constraint"}) -join ', '
$conn = [Microsoft.Data.SqlClient.SqlConnection]::new($env:SQL_CONNECTION)$conn.Open()try { $cmd = $conn.CreateCommand() $cmd.CommandText = "DROP TABLE IF EXISTS $staging; CREATE TABLE $staging ($columnSql);" [void]$cmd.ExecuteNonQuery()
$bulk = [Microsoft.Data.SqlClient.SqlBulkCopy]::new($conn) $bulk.DestinationTableName = $staging $bulk.BulkCopyTimeout = 0 foreach ($c in $columns) { [void]$bulk.ColumnMappings.Add($c.Name, $c.Name) } $bulk.WriteToServer($data)
$tx = $conn.BeginTransaction() $cmd = $conn.CreateCommand(); $cmd.Transaction = $tx $cmd.CommandText = "DROP TABLE IF EXISTS $target; EXEC sp_rename @old, @new;" [void]$cmd.Parameters.AddWithValue('@old', "$Schema.$stagingName") [void]$cmd.Parameters.AddWithValue('@new', $TableName) [void]$cmd.ExecuteNonQuery() $tx.Commit() Write-Host "Replaced $target with $($data.Rows.Count) rows."}finally { $conn.Dispose() Disconnect-PnPOnline}What each part does:
Connect-PnPOnlinesigns in as the app registration with the certificate file. The certificate password comes fromPNP_CERT_PASSWORD. On Windows you can import the certificate into the user’s certificate store and pass-Thumbprintinstead, which removes the password altogether.Get-PnPFieldreturns the list’s columns. The script keeps visible, editable columns plus the ID, Created, Modified, Author and Editor columns, and maps each SharePoint type to a SQL type. Column names are SharePoint internal names, which stay the same when someone renames a column in the browser. Any type the script does not map, such as managed metadata, is named on screen and left out.Get-PnPListItem -PageSize 2000pages through the list by item ID, which is why it works on lists well past the 5,000-item view threshold. A lookup or person column becomes two SQL columns, the item id and the display value. Multi-value columns are joined into one string separated by semicolons.- The rows go into a staging table with
SqlBulkCopy. Only when the load succeeds does one transaction drop the old table and rename the staging table into its place, so readers never see a half-loaded table.
Running the export on a schedule
Set the two environment variables from your secret store at run time, then call the script:
$env:PNP_CERT_PASSWORD = (Get-Secret -Name ListExportCertPassword -AsPlainText)$env:SQL_CONNECTION = (Get-Secret -Name ListExportSqlConnection -AsPlainText)
./Export-ListToSql.ps1 -SiteUrl https://contoso.sharepoint.com/sites/estates ` -ListName 'Maintenance Requests' -ClientId <client id> ` -Tenant contoso.onmicrosoft.com -CertificatePath ./list-export.pfx ` -TableName maintenance_requestsGet-Secret comes from Microsoft’s SecretManagement module; Azure Key Vault or a
pipeline’s secret variables work the same way. Windows authentication to SQL Server, or
Microsoft Entra authentication to Azure SQL, keeps the password out of the connection
string entirely. Run it from Task Scheduler, cron or a CI job, and send the script’s exit
code to whatever already watches your scheduled jobs.
Where the script is the right tool
If you need a one-off export before a migration, or a small list that someone checks every Monday, stop here. You have a working copy, and the code is yours to read and change. PnP PowerShell is also the better choice when the job includes logic specific to your tenant, such as tidying values as they move or filling in a column from another system in the same run.
The trouble appears later, when the export has run every night for a year and a report depends on it. The rest of this post covers the four places it usually breaks.
Throttling on a nightly full read
The script reads every item on every run. For a 30,000-item list at 2,000 items a page,
that is 15 requests, which on its own is light. The cost depends on which API you use.
Microsoft’s
throttling guidance
prices a Microsoft Graph multi-item query at 2 resource units, and a Graph delta request
with a token at 1 resource unit. Get-PnPListItem uses SharePoint’s client object model
(CSOM), and the same page says CSOM and REST have no predetermined resource unit cost,
usually consume more than Graph for the same work, and are subject to other internal
limits as well. A tenant with 1,001 to 5,000 licences gives each app 2,500 resource units
a minute.
PnP does handle the basics. Its CSOM calls go through PnP Framework’s
ExecuteQueryRetry, which waits for the time in the Retry-After header on a 429 or 503
response and tries again, up to 10 times by default. The trouble is volume. Several
scripts that share one app registration and all read in full at 2am draw on the same
per-app budget. When the retries run out, the run stops and the table stays as it
was the night before, which the staging swap makes safe but not current.
What happens when the list passes 5,000 items?
The full read keeps working, because paging by ID avoids the
list view threshold,
set at 5,000 items by default. Filtering is where large lists bite. The usual way to make
a nightly read cheaper is a CAML query with a Where on Modified, so the script only
fetches what changed. On a list past the threshold that query needs Modified to be
indexed, or SharePoint refuses it.
Even with the index in place, a Modified filter cannot see deletes, because a deleted
item has no row to come back. The full reload in this script handles deletes by
accident: the item is missing from the new read, so it is missing from the new table.
Nothing records that it existed, when it went, or who removed it. Microsoft Graph’s
list item delta query
does report deleted items, with a deleted facet, but that means rewriting the read
side around Graph and storing a delta link between runs.
Why do lookup columns break a PowerShell export?
PnP’s Get-PnPListItem reference describes a limit most people meet without warning.
When you read all items, SharePoint returns at most 12 lookup columns and silently omits
the rest, and a query that names more than 12 is refused. Person, group, managed metadata,
Created By and Modified By columns all count. Microsoft documents the list view lookup
threshold, 12 join operations per query, on its
SharePoint Server 2019 boundaries and limits
page; PnP’s reference describes the same limit in SharePoint Online.
The script above stops with an error instead of losing columns. The fix is to read the
lookups in groups of 12 and merge the groups by item ID, one more full read per group.
Our post on the
SharePoint lookup column threshold covers
the limit in more depth.
The shape is the second problem. Joining Assigned To on a display name breaks when
someone changes their name, so the script keeps the user’s item id next to it. That id
belongs to the site’s user list, which you would also need to export to resolve it to an
email address. Multi-value columns arrive as a string such as Plumbing; Electrical, and
counting jobs per trade then means splitting strings in SQL. A child table with one row
per value is the relational answer, and it is more code again.
Deletes and schema drift
The swap rebuilds the table from SharePoint’s current columns every night, so a new column appears without any work. Everything else about a changed column reaches the reports first:
- A column changed from single line of text to number changes type in SQL overnight, and any query comparing it to a string fails the next morning.
- A column someone deletes in SharePoint disappears from the table on the next run, with every value it held, and any report that reads it fails.
- A view created
WITH SCHEMABINDINGon the table blocks the swap altogether. Microsoft’s CREATE VIEW reference says a table that participates in such a view cannot be dropped. In our test the transaction failed with exactly that error and left the previous table in place.
Each of these is fixable, for example with an ALTER TABLE step that adds new columns
and refuses retypes, or a soft-delete flag fed from a delta query. Added together
they are most of a replication engine, and the
comparison of PnP PowerShell scripts and SPSync
lists what that engine has to own.
When to replace the script with replication
Keep the script while the list is small, the export runs by hand or weekly, and the person who wrote it is still around. Move to replication when a report or an application depends on the table being current and somebody would notice a missed night.
SPSync reads a list in full once, then reads only changes through Graph delta queries,
with a full reconcile every seven days by default. Lookups arrive as key columns,
multi-value columns as child tables, and deleted items stay in the table with
sp_deleted_at set. A new column appears on the next run, a retyped column moves into a
new _v2 column, and a removed column stays. It is one way: SharePoint stays the system
of record and SPSync never writes to it. Our post on
what it costs to keep a SharePoint export script running
covers that trade-off for scripts and flows alike, and the
SQL Server destination page shows the tables and reporting
views you get.
If you liked scripting it, the command-line tool keeps the habit: one YAML file, secrets as environment variable references, run from the same scheduler that runs your PowerShell today. SQL Server is one of many destinations, and the same lists can go to several of them, one replication each. Tell us if the database you need is not on the list yet.