Rolling Back a SharePoint List After Bad Automation Runs Wild
The problem
Bad data lands in a SharePoint list — misbehaving script, duplicate sync run, runaway workflow. You need the list back to its state before a given timestamp, without a full backup restore (wrong granularity: whole site/content DB, not one list, and it clobbers everything else that changed since).
If versioning is on, the fix doesn't need a backup at all — the version history already has it. Two independent operations:
- Items created after the cutoff: delete.
- Items modified after the cutoff: restore the last pre-cutoff version, purge the versions after it.
Restore-SPListState.ps1 does both, batched, via CSOM/server-side SPQuery.
Mechanics
Configured via $webUrl, $listName, $cutoff.
Part 1. SPQuery paginated on Created > $cutoff, IDs collected into a list, deleted in a second pass. Deleting while enumerating the same SPListItemCollection corrupts the enumerator — collect first, delete after.
Part 2. SPQuery on Modified > $cutoff, then per item: walk $item.Versions, split into $versionsAfterCutoff and the single newest version at-or-before cutoff ($restoreVersion / $restoreIndex). No pre-cutoff version → warn and skip, no guessing.
Two non-obvious API constraints drive the logic:
Versions.Restore()takes a collection index, not a version ID.$version.IDwas empty in this environment, soRestoreByID()wasn't viable. Track$ifrom the loop instead.Restore()inserts a new current version; it doesn't overwrite. Post-restore,Versions[0]is the new current version and everything you want to delete has shifted to index 1+. SharePoint throws on deletingVersions[0]("Cannot delete the current version"). The version objects captured beforeRestore()are stale afterward — reload the item and re-enumerate before deleting.
$dryRun gates all mutation and logs the exact plan. Run it once before flipping to $false.
One thing Restore() will not give you back: a clean audit trail. The restored version is a new entry, not the original — its version number won't be contiguous with what came before (a 3.0 restore onto a list at 7.0 doesn't reset the counter), its Modified timestamp is the moment you ran the script, not the original edit time, and Modified By becomes whoever ran the script, not the original editor. Content matches; metadata doesn't. If you need to prove after the fact who wrote what and when, this script's own run becomes the newest entry in that history.
The version-number gap is just how Restore() works — nothing to fix there. Modified and Modified By are a different story, and material for another post — and probably another script.
The script
Download Restore-SPListState.ps1
Add-PSSnapin Microsoft.SharePoint.PowerShell -ErrorAction SilentlyContinue
# =========================
# CONFIGURATION
# =========================
$webUrl = "https://sharepoint/site"
$listName = "LIST_TITLE"
# Anything created/modified AFTER this date is targeted
$cutoff = Get-Date "2026-08-19 00:00:00"
# Number of items retrieved from SharePoint per batch
$batchSize = 500
# IMPORTANT:
# $true = report only, DO NOT DELETE
# $false = actually delete
$dryRun = $false
# ============================================================
# CONNECT TO SHAREPOINT
# ============================================================
$web = Get-SPWeb $webUrl
try {
$list = $web.Lists[$listName]
Write-Host ""
Write-Host "==================================================" -ForegroundColor Cyan
Write-Host "SHAREPOINT ROLLBACK SCRIPT"
Write-Host "==================================================" -ForegroundColor Cyan
Write-Host "Web: $webUrl"
Write-Host "List: $listName"
Write-Host "Cutoff: $cutoff"
Write-Host "Batch size: $batchSize"
Write-Host "Dry run: $dryRun"
Write-Host "==================================================" -ForegroundColor Cyan
# ========================================================
# PART 1 - FIND AND DELETE ITEMS CREATED AFTER CUTOFF
# ========================================================
Write-Host ""
Write-Host "==================================================" -ForegroundColor Cyan
Write-Host "PART 1 - ITEMS CREATED AFTER CUTOFF"
Write-Host "==================================================" -ForegroundColor Cyan
$itemsToDelete = New-Object System.Collections.Generic.List[int]
$position = $null
$cutoffUtc = $cutoff.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
do {
$query = New-Object Microsoft.SharePoint.SPQuery
$query.RowLimit = $batchSize
$query.Query = @"
<Where>
<Gt>
<FieldRef Name='Created' />
<Value Type='DateTime' IncludeTimeValue='TRUE'>$cutoffUtc</Value>
</Gt>
</Where>
<OrderBy>
<FieldRef Name='ID' Ascending='TRUE' />
</OrderBy>
"@
$query.ListItemCollectionPosition = $position
$items = $list.GetItems($query)
$position = $items.ListItemCollectionPosition
Write-Host ""
Write-Host "Batch returned: $($items.Count) items" -ForegroundColor Yellow
foreach ($item in $items) {
$itemId = [int]$item.ID
Write-Host ("FOUND ITEM: ID={0} | Created={1} | Title={2}" -f $itemId, $item["Created"], $item["Title"])
# Only collect the ID here. Do NOT call $item.Delete() while
# enumerating $items — mutating a collection you're iterating
# over corrupts the enumeration.
$itemsToDelete.Add($itemId)
}
}
while ($null -ne $position)
Write-Host ""
Write-Host "Items found: $($itemsToDelete.Count)" -ForegroundColor Yellow
if ($itemsToDelete.Count -gt 0) {
Write-Host ""
Write-Host "--------------------------------------------------" -ForegroundColor DarkGray
Write-Host "ITEM DELETION"
Write-Host "--------------------------------------------------" -ForegroundColor DarkGray
if ($dryRun) {
Write-Host "DRY RUN - items will NOT be deleted." -ForegroundColor Yellow
foreach ($id in $itemsToDelete) {
Write-Host "WOULD DELETE ITEM: ID=$id"
}
}
else {
$itemsDeleted = 0
foreach ($id in $itemsToDelete) {
try {
# Re-fetch by ID instead of reusing the enumerated item —
# we're no longer inside that collection's enumeration.
$item = $list.GetItemById($id)
if ($null -ne $item) {
Write-Host "DELETE ITEM: ID=$id"
$item.Delete()
$itemsDeleted++
}
}
catch {
Write-Host ("ERROR deleting item ID={0}: {1}" -f $id, $_.Exception.Message) -ForegroundColor Red
}
}
Write-Host ""
Write-Host "Items deleted: $itemsDeleted" -ForegroundColor Green
}
}
# ========================================================
# PART 2 - ROLLBACK VERSIONS AFTER CUTOFF
# ========================================================
Write-Host ""
Write-Host "==================================================" -ForegroundColor Cyan
Write-Host "PART 2 - VERSION ROLLBACK"
Write-Host "==================================================" -ForegroundColor Cyan
if (-not $list.EnableVersioning) {
Write-Host ""
Write-Host "Versioning is disabled on this list." -ForegroundColor Yellow
}
else {
$position = $null
$itemsProcessed = 0
$itemsToRollback = 0
$versionsFound = 0
$versionsDeleted = 0
do {
$query = New-Object Microsoft.SharePoint.SPQuery
$query.RowLimit = $batchSize
# We only retrieve items whose CURRENT Modified date is after the
# cutoff, then inspect their complete version collection.
$query.Query = @"
<Where>
<Gt>
<FieldRef Name='Modified' />
<Value Type='DateTime' IncludeTimeValue='TRUE'>$cutoffUtc</Value>
</Gt>
</Where>
<OrderBy>
<FieldRef Name='ID' Ascending='TRUE' />
</OrderBy>
"@
$query.ListItemCollectionPosition = $position
$items = $list.GetItems($query)
$position = $items.ListItemCollectionPosition
Write-Host ""
Write-Host "Version batch: $($items.Count) items" -ForegroundColor Yellow
foreach ($item in $items) {
$itemsProcessed++
$itemId = $item.ID
if ($item.Versions.Count -eq 0) {
continue
}
# Find every version created after cutoff, and the newest
# version created at or before cutoff (the restore target).
#
# IMPORTANT: don't use $version.ID — in this environment it's
# empty, so RestoreByID() can't be used. We track the actual
# collection index instead.
$versionsAfterCutoff = @()
$restoreVersion = $null
$restoreIndex = -1
for ($i = 0; $i -lt $item.Versions.Count; $i++) {
$version = $item.Versions[$i]
Write-Host ("[{0}] Item={1} | Version={2} | Created={3}" -f $i, $itemId, $version.VersionLabel, $version.Created)
if ($version.Created -gt $cutoff) {
$versionsAfterCutoff += $version
$versionsFound++
Write-Host ("VERSION FOUND: Item ID={0} | Version={1} | Created={2}" -f $itemId, $version.VersionLabel, $version.Created) -ForegroundColor Magenta
}
elseif ($null -eq $restoreVersion -or $version.Created -gt $restoreVersion.Created) {
$restoreVersion = $version
$restoreIndex = $i
}
}
if ($versionsAfterCutoff.Count -eq 0) {
continue
}
$itemsToRollback++
if ($restoreIndex -lt 0) {
Write-Host ("WARNING: Item ID={0} has no version before cutoff. SKIPPING." -f $itemId) -ForegroundColor Red
continue
}
Write-Host ""
Write-Host "--------------------------------------------------" -ForegroundColor DarkGray
Write-Host "Item ID: $itemId"
Write-Host ("Current version: {0}" -f $item.Versions[0].VersionLabel)
Write-Host ("Restore version: {0}" -f $restoreVersion.VersionLabel)
Write-Host ("Restore index: {0}" -f $restoreIndex)
Write-Host ("Restore date: {0}" -f $restoreVersion.Created)
Write-Host ("Versions after: {0}" -f $versionsAfterCutoff.Count)
Write-Host "--------------------------------------------------" -ForegroundColor DarkGray
if ($dryRun) {
Write-Host ("DRY RUN: would restore Versions.Restore({0})" -f $restoreIndex) -ForegroundColor Yellow
Write-Host ("DRY RUN: would then delete {0} post-cutoff versions." -f $versionsAfterCutoff.Count) -ForegroundColor Yellow
continue
}
try {
Write-Host ("RESTORING Version={0} using Restore({1})..." -f $restoreVersion.VersionLabel, $restoreIndex) -ForegroundColor Yellow
# Restore() belongs to the version collection and takes the
# collection INDEX, not $restoreVersion.ID and not
# $item.Versions.RestoreByID(...).
$item.Versions.Restore($restoreIndex)
Write-Host ("RESTORE SUCCESS: Item ID={0} -> Version {1}" -f $itemId, $restoreVersion.VersionLabel) -ForegroundColor Green
}
catch {
Write-Host ("ERROR restoring version {0} of item {1}: {2}" -f $restoreVersion.VersionLabel, $itemId, $_.Exception.Message) -ForegroundColor Red
continue
}
try {
$item = $list.GetItemById($itemId)
}
catch {
Write-Host ("ERROR reloading item {0}: {1}" -f $itemId, $_.Exception.Message) -ForegroundColor Red
continue
}
Write-Host ""
Write-Host ("VERSION COLLECTION AFTER RESTORE - ITEM {0}" -f $itemId) -ForegroundColor Cyan
for ($i = 0; $i -lt $item.Versions.Count; $i++) {
$v = $item.Versions[$i]
Write-Host ("[{0}] Version={1} | Created={2}" -f $i, $v.VersionLabel, $v.Created)
}
# Restore() creates a NEW current version, e.g.:
# [0] 3.0 <-- new current, restored content
# [1] 2.0 <-- bad version, delete
# [2] 1.0 <-- old good version, keep
#
# Versions[0] is always the current version — SharePoint
# rejects deleting it ("Cannot delete the current version").
# Re-scan from the reloaded item; the old $version objects
# from before Restore() are stale.
$versionsToDelete = @()
for ($i = 0; $i -lt $item.Versions.Count; $i++) {
$version = $item.Versions[$i]
if ($i -eq 0) {
Write-Host ("KEEP CURRENT: Version={0}" -f $version.VersionLabel) -ForegroundColor Green
continue
}
if ($version.Created -gt $cutoff) {
$versionsToDelete += $version
}
}
# Delete newest-first.
$versionsToDelete = $versionsToDelete | Sort-Object Created -Descending
foreach ($version in $versionsToDelete) {
try {
Write-Host ("DELETE VERSION: Item ID={0} | Version={1} | Created={2}" -f $itemId, $version.VersionLabel, $version.Created) -ForegroundColor Yellow
$version.Delete()
$versionsDeleted++
}
catch {
Write-Host ("ERROR deleting version {0} of item {1}: {2}" -f $version.VersionLabel, $itemId, $_.Exception.Message) -ForegroundColor Red
}
}
}
}
while ($null -ne $position)
Write-Host ""
Write-Host "==================================================" -ForegroundColor Green
Write-Host "VERSION ROLLBACK SUMMARY"
Write-Host "==================================================" -ForegroundColor Green
Write-Host "Items processed: $itemsProcessed"
Write-Host "Items to rollback: $itemsToRollback"
Write-Host "Versions found: $versionsFound"
if ($dryRun) {
Write-Host ""
Write-Host "DRY RUN - NOTHING WAS CHANGED." -ForegroundColor Yellow
}
else {
Write-Host "Versions deleted: $versionsDeleted" -ForegroundColor Green
}
}
# ========================================================
# FINAL SUMMARY
# ========================================================
Write-Host ""
Write-Host "==================================================" -ForegroundColor Green
Write-Host "SCRIPT COMPLETE"
Write-Host "==================================================" -ForegroundColor Green
Write-Host "Items found for deletion: $($itemsToDelete.Count)"
if ($dryRun) {
Write-Host ""
Write-Host "***** DRY RUN *****" -ForegroundColor Yellow
Write-Host "No items or versions were modified." -ForegroundColor Yellow
Write-Host ""
Write-Host "Set:" -ForegroundColor Yellow
Write-Host '$dryRun = $false' -ForegroundColor Yellow
Write-Host "to perform the changes."
}
}
finally {
if ($null -ne $web) {
$web.Dispose()
}
Write-Host ""
Write-Host "SharePoint web disposed." -ForegroundColor Gray
}
Destructive by design — $dryRun first on anything you can't rebuild.