Update SharePoint List Items from a CSV with PowerShell
Export your SharePoint list, make some modifications to the cells in the Excel table, then push it back with a script, and all rows return success. But there are some changes that do not get applied as expected, and out of those there are two cases that are silently ignored.
Tested on a PnP.PowerShell 2.12.0 with an actual list..
A CSV has no types
That is the whole problem in one sentence. Export a list and read it back:
$row = Import-Csv .\projects.csv | Select-Object -First 1
foreach ($p in $row.PSObject.Properties) {
"{0,-10} {1,-18} value: {2}" -f $p.Name, $p.Value.GetType().Name, $p.Value
}
Id String value: 1
Title String value: Project 01
Status String value: Not Started
DueDate String value: 7/28/2026 5:13:57 PM
Budget String value: 1000
Notes String value: Seeded row 1 for PowerShell examples.
Everything is a String. The list on the other side has Choice, DateTime, Currency and Note columns. Handing one to the other is where it falls apart.
The version everybody writes first
$rows = Import-Csv .\changes.csv
# Build the lookup ONCE. Searching the list per row is the usual reason
# these scripts take hours instead of seconds.
$lookup = @{}
Get-PnPListItem -List 'Projects' -PageSize 500 | ForEach-Object {
$lookup[[string]$_['Title']] = $_.Id
}
foreach ($row in $rows) {
$id = $lookup[$row.Title]
if (-not $id) { Write-Host "no match for '$($row.Title)'"; continue }
Set-PnPListItem -List 'Projects' -Identity $id -Values @{
ProjectStatus = $row.Status
DueDate = $row.DueDate
Budget = $row.Budget
}
}
I fed it seven rows, four good and three broken on purpose:
updated Project 01
updated Project 02
updated Project 03
updated Project 04
FAILED Project 05: The string was not recognized as a valid DateTime.
no match for 'Project 99'
updated Project 06
Looks like it mostly worked. It did not.
Failure one: an invalid Choice value is written anyway
Row four set the status to On Hold. That is not one of the four choices on that column. Look at what happened:
Project 04 status is now: 'On Hold'
Valid choices are:
Not Started
In Progress
Blocked
Complete
SharePoint doesn’t verify the value when writing to the Choice column. It happily accepted a value that was never available, and recorded it without saying anything. And it returned success.
This value is not available via any drop-down list in the user interface. It will simply disappear from your filtered views and from your Power Automate actions. Do this to 2,000 rows and you will realize it after a few months.
It’s up to you to discover
$field = Get-PnPField -List 'Projects' -Identity 'ProjectStatus'
if ($value -notin @($field.Choices)) {
# reject it - SharePoint will not
}
Failure two: an empty cell deletes data
Row six had an empty Budget. Not zero, empty. Afterwards:
Title Status DueDate Budget
----- ------ ------- ------
Project 06 Complete 7/1/2026 12:00:00 AM
The Budget is gone. It was 6000 before.
The absence of anything in an individual cell means that it is marked “clear this field,” rather than being left blank. This makes sense, although the exact opposite would have been true had you intended what most people intend to accomplish when they send a partially populated spreadsheet to someone else. Any empty cell in your CSV file represents a deletion..
Both behaviours are reasonable, so decide which one you want rather than finding out.
Failure three: one bad cell loses the whole row
Row five had not a date in the DueDate column, and it threw. That part is fine, loud is good.
The problem is that neither Status nor Budget was entered on that row. The data entry process requires just one piece of bad information to render everything else useless. In Project 05, the old status was retained, along with its old date and budget.
Validate first and keep the good data
[5] Project 05: ProjectStatus=Complete, Budget=5500
DueDate is skipped and reported, the other two go through.
The whole script
Reads the list’s real schema, validates every value against its actual field type, batches the writes, and supports -WhatIf:
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)] [string] $SiteUrl,
[Parameter(Mandatory)] [string] $ClientId,
[Parameter(Mandatory)] [string] $List,
[Parameter(Mandatory)] [string] $CsvPath,
[string] $KeyColumn = 'Title',
[hashtable] $ColumnMap = @{},
[switch] $ClearEmptyValues
)
$ErrorActionPreference = 'Stop'
if (-not (Test-Path $CsvPath)) { throw "CSV not found: $CsvPath" }
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Interactive
$rows = @(Import-Csv -Path $CsvPath)
if ($rows.Count -eq 0) { throw "CSV is empty." }
Write-Host "Rows in CSV : $($rows.Count)" -ForegroundColor Cyan
# Read the real field types rather than assuming the CSV headers mean anything.
$fields = @{}
foreach ($f in Get-PnPField -List $List) { $fields[$f.InternalName] = $f }
$csvColumns = $rows[0].PSObject.Properties.Name | Where-Object { $_ -ne $KeyColumn }
$targets = @{}
foreach ($col in $csvColumns) {
$internal = if ($ColumnMap.ContainsKey($col)) { $ColumnMap[$col] } else { $col }
if (-not $fields.ContainsKey($internal)) {
Write-Warning "CSV column '$col' maps to '$internal', which is not a field on '$List'. Ignored."
continue
}
$targets[$col] = $fields[$internal]
}
if ($targets.Count -eq 0) { throw "No CSV columns matched fields on '$List'." }
Write-Host "Mapped columns:" -ForegroundColor Cyan
foreach ($col in $targets.Keys | Sort-Object) {
" {0,-14} -> {1,-18} ({2})" -f $col, $targets[$col].InternalName, $targets[$col].TypeAsString
}
# One pass to index the list. Searching per row is what makes these slow.
Write-Host "`nBuilding lookup..." -ForegroundColor Cyan
$lookup = @{}
$duplicates = [System.Collections.Generic.List[string]]::new()
foreach ($item in Get-PnPListItem -List $List -PageSize 500) {
$key = [string]$item[$KeyColumn]
if ([string]::IsNullOrWhiteSpace($key)) { continue }
if ($lookup.ContainsKey($key)) { $duplicates.Add($key); continue }
$lookup[$key] = $item.Id
}
Write-Host " $($lookup.Count) item(s) indexed on '$KeyColumn'"
if ($duplicates.Count) {
Write-Warning "$($duplicates.Count) duplicate key(s) - only the first of each was indexed."
}
function Convert-Value {
param($Raw, $Field)
$type = $Field.TypeAsString
if ([string]::IsNullOrWhiteSpace($Raw)) {
if ($ClearEmptyValues) { return @{ Ok = $true; Value = $null } }
return @{ Ok = $false; Reason = 'empty (skipped)' }
}
switch -Regex ($type) {
'^Choice$|^MultiChoice$' {
# The check SharePoint does not do for you.
$valid = @($Field.Choices)
if ($Raw -notin $valid) {
return @{ Ok = $false; Reason = "'$Raw' is not a valid choice (allowed: $($valid -join ', '))" }
}
return @{ Ok = $true; Value = $Raw }
}
'^DateTime$' {
$parsed = [datetime]::MinValue
if (-not [datetime]::TryParse($Raw, [ref]$parsed)) {
return @{ Ok = $false; Reason = "'$Raw' is not a date" }
}
return @{ Ok = $true; Value = $parsed }
}
'^Number$|^Currency$' {
$parsed = 0.0
if (-not [double]::TryParse($Raw, [ref]$parsed)) {
return @{ Ok = $false; Reason = "'$Raw' is not a number" }
}
return @{ Ok = $true; Value = $parsed }
}
'^Boolean$' {
if ($Raw -in @('1','true','True','yes','Yes')) { return @{ Ok = $true; Value = $true } }
if ($Raw -in @('0','false','False','no','No')) { return @{ Ok = $true; Value = $false } }
return @{ Ok = $false; Reason = "'$Raw' is not a yes/no value" }
}
default { return @{ Ok = $true; Value = $Raw } }
}
}
$plan = [System.Collections.Generic.List[object]]::new()
$problems = [System.Collections.Generic.List[object]]::new()
foreach ($row in $rows) {
$key = [string]$row.$KeyColumn
if (-not $lookup.ContainsKey($key)) {
$problems.Add([pscustomobject]@{ Key = $key; Column = '(row)'; Reason = 'no matching item' })
continue
}
$values = @{}
foreach ($col in $targets.Keys) {
$result = Convert-Value $row.$col $targets[$col]
if ($result.Ok) {
$values[$targets[$col].InternalName] = $result.Value
} elseif ($result.Reason -ne 'empty (skipped)') {
$problems.Add([pscustomobject]@{ Key = $key; Column = $col; Reason = $result.Reason })
}
}
if ($values.Count) {
$plan.Add([pscustomobject]@{ Key = $key; Id = $lookup[$key]; Values = $values })
}
}
Write-Host "`nTo update : $($plan.Count) item(s)" -ForegroundColor Green
Write-Host "Problems : $($problems.Count)"
if ($problems.Count) { $problems | Format-Table Key, Column, Reason -AutoSize }
if ($plan.Count -eq 0) { Write-Host "Nothing to do."; Disconnect-PnPOnline; return }
if ($WhatIfPreference) {
Write-Host "`n-WhatIf: nothing was written. Planned changes:" -ForegroundColor Cyan
foreach ($p in $plan | Select-Object -First 10) {
" [$($p.Id)] $($p.Key): " + (($p.Values.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join ', ')
}
if ($plan.Count -gt 10) { " ... and $($plan.Count - 10) more" }
Disconnect-PnPOnline
return
}
if ($PSCmdlet.ShouldProcess("$($plan.Count) items in '$List'", 'Update')) {
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$batch = New-PnPBatch
foreach ($p in $plan) {
Set-PnPListItem -List $List -Identity $p.Id -Values $p.Values -Batch $batch | Out-Null
}
Invoke-PnPBatch -Batch $batch
$sw.Stop()
Write-Host "`nUpdated $($plan.Count) item(s) in $([math]::Round($sw.Elapsed.TotalSeconds,1))s" -ForegroundColor Green
}
Disconnect-PnPOnline
Run it dry first
.\Update-ListFromCsv.ps1 `
-SiteUrl "https://contoso.sharepoint.com/sites/Demo" `
-ClientId "11111111-2222-3333-4444-555555555555" `
-List "Projects" -CsvPath .\changes.csv `
-ColumnMap @{ Status = 'ProjectStatus' } `
-WhatIf
Mapped columns:
Budget -> Budget (Currency)
DueDate -> DueDate (DateTime)
Status -> ProjectStatus (Choice)
Building lookup...
25 item(s) indexed on 'Title'
To update : 6 item(s)
Problems : 3
Key Column Reason
--- ------ ------
Project 04 Status 'On Hold' is not a valid choice (allowed: Not Started, In Progress, Blocked, Complete)
Project 05 DueDate 'not a date' is not a date
Project 99 (row) no matching item
-WhatIf: nothing was written. Planned changes:
[4] Project 04: Budget=4400, DueDate=09/01/2026 00:00:00
[5] Project 05: ProjectStatus=Complete, Budget=5500
Observe rows 4 and 5. The faulty cell gets dropped and the result reported while the other cells get processed. The naive approach discarded everything in row 5. Now run the test version
Updated 6 item(s) in 4.5s
It’s the -WhatIf run that counts. Thirty seconds spent reading the list of problems is better than getting to find out after six months that the choice column has a lot of invalid values.