
What it is
A PowerShell script that compares current AD user objects against a snapshot taken with Backup-ADUserAttributes.ps1
and reports every difference. With -Apply, it writes the snapshot values back. Users are matched on objectGUID, so renamed or moved accounts are still found.
It is report-only by default. Nothing changes in AD unless you pass -Apply.
What it does
- Loads a
.clixmlsnapshot and looks up each user by GUID. - Compares each captured attribute (or the subset in
-Attributes) and builds a per-attribute diff. - For each difference, plans either Replace (set to the snapshot value) or Clear (snapshot value was empty).
- Handles
managerby GUID, not DN, so a manager who was renamed or moved still resolves. - Optionally restores CN, OU and enabled state, each behind its own switch.
- Writes a CSV report of every difference and what happened to it, and prints a count per attribute.
- Users that no longer exist are reported as
MISSING - check AD Recycle Bin. Other read failures are reported per user and skipped.
Requirements
- PowerShell 5.1+
- ActiveDirectory module (RSAT, or run on a DC/management server)
- A snapshot produced by
Backup-ADUserAttributes.ps1 - For
-Apply: write permission on the target user objects (and, for-RestoreOU, permission to move into the original OU, which must still exist)
Before you run it with -Apply
- Stop whatever caused the change. If a directory sync, identity provider or provisioning job writes to these attributes, disable that write path first. Otherwise it can overwrite your restored values on its next run.
- Run the report-only pass first and read the CSV.
- Start narrow. Use
-Identityand/or-Attributeson a test user before restoring everyone.
Parameters
| Parameter | Required | Description |
|---|---|---|
-SnapshotPath | Yes | Path to the .clixml from the backup script. |
-Attributes | No | Only compare/restore these attributes. Default: everything captured. |
-Identity | No | Limit to these sAMAccountNames or objectGUIDs. |
-RestoreName | No | Rename the CN back to the snapshot value (changes the DN). |
-RestoreOU | No | Move the object back to its original OU. |
-RestoreEnabled | No | Enable/disable the account to match the snapshot. |
-Apply | No | Actually write. Without it, report only. |
-Server | No | DC to read from and write to. |
-ReportPath | No | Output CSV. Default: .\ad-restore-report-<timestamp>.csv |
CN, OU and enabled-state differences are always listed in the report. They show as Rename (needs -RestoreName), Move (needs -RestoreOU) and SetEnabled (needs -RestoreEnabled) until you opt in.
Usage
# 1. Diff only: changes nothing
.\Restore-ADUserAttributes.ps1 -SnapshotPath .\ad-snapshot-20261014-090000.clixml
# 2. Restore one user, including CN
.\Restore-ADUserAttributes.ps1 -SnapshotPath .\snap.clixml -Identity jdoe -RestoreName -Apply
# 3. Restore only specific attributes for everyone
.\Restore-ADUserAttributes.ps1 -SnapshotPath .\snap.clixml -Attributes title,department,manager -Apply
Report
One row per difference:
| Column | Meaning |
|---|---|
SamAccountName, ObjectGUID | The user. |
Attribute | Attribute name, or cn, OU, Enabled, (object) for non-attribute differences. |
SnapshotValue, CurrentValue | Normalized values. Multi-valued attributes are sorted and joined with a pipe character. |
Action | Replace, Clear, Rename, Move, SetEnabled, or a note such as MISSING / Manager object not found. |
Applied | True only if the write for that user succeeded. |
Error | Error text, if any. |
Behavior worth knowing
- Order of operations per user: attribute Replace/Clear via
Set-ADUser, then rename, then move, then enable/disable. If any step fails, the user’s rows are marked with the error andAppliedstaysFalse. - Comparison is case-sensitive for values. Multi-valued attributes compare as sorted sets, so reordering alone isn’t a difference.
- The default capture list includes identity attributes (
sAMAccountName,userPrincipalName,mail,proxyAddresses). Restoring those can affect sign-in and mail routing. If that isn’t what you want, restrict with-Attributes. - Not restored: passwords, group membership, attributes that weren’t captured, and deleted objects (use the AD Recycle Bin for those).
License
Copyright © 2026 Steve LaPorte. Released under the GNU General Public License v3.0 or later . Provided as-is, with no warranty. Test in a lab before running against production.
Script
View on GitHub . Source also provided below:
<#
.SYNOPSIS
Compares current AD users to a Backup-ADUserAttributes.ps1 snapshot and optionally restores them.
.DESCRIPTION
Default is REPORT ONLY: writes a per-attribute diff to CSV and changes nothing.
Add -Apply to write. Users are matched on objectGUID, so renamed/moved objects are still found.
Name (CN), OU and enabled state are only compared/restored when the matching switch is used.
Manager is compared and restored by GUID.
Before applying, stop whatever caused the change (e.g. a directory sync or identity provider
provisioning job that writes attributes to AD), or it may overwrite the restored values on its next run.
.PARAMETER SnapshotPath
Path to the .clixml file produced by Backup-ADUserAttributes.ps1.
.PARAMETER Attributes
Restore only these attributes. Default: every attribute captured in the snapshot.
.PARAMETER Identity
Limit to these sAMAccountNames or objectGUIDs.
.PARAMETER RestoreName
Rename the CN back to the snapshot value (changes the DN).
.PARAMETER RestoreOU
Move the object back to its original OU.
.PARAMETER RestoreEnabled
Enable/disable the account to match the snapshot.
.PARAMETER Apply
Actually write changes. Without it, the script only reports.
.PARAMETER Server
DC to read from and write to.
.PARAMETER ReportPath
Output CSV for the diff/results. Default: .\ad-restore-report-<timestamp>.csv
.EXAMPLE
.\Restore-ADUserAttributes.ps1 -SnapshotPath .\ad-snapshot-20261014-090000.clixml
Diff only.
.EXAMPLE
.\Restore-ADUserAttributes.ps1 -SnapshotPath .\snap.clixml -Identity jdoe -RestoreName -Apply
Restore one user, including CN.
.EXAMPLE
.\Restore-ADUserAttributes.ps1 -SnapshotPath .\snap.clixml -Attributes title,department,manager -Apply
Restore only specific attributes for everyone.
.NOTES
Author: Steve LaPorte (stevelaporte.net | github.com/stevelaporte)
Requires: PowerShell 5.1+, ActiveDirectory module (RSAT), write access to the target user objects
License: GPL-3.0-or-later
Copyright (C) 2026 Steve LaPorte
This program is free software: you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free Software Foundation,
either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this
program. If not, see <https://www.gnu.org/licenses/>.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$SnapshotPath,
[string[]]$Attributes, # subset to restore; default = all captured
[string[]]$Identity, # limit to these sAMAccountNames or GUIDs
[switch]$RestoreName, # rename CN back (changes DN)
[switch]$RestoreOU, # move back to original OU
[switch]$RestoreEnabled, # re-enable/disable to match snapshot
[switch]$Apply,
[string]$Server,
[string]$ReportPath = ".\ad-restore-report-$(Get-Date -Format 'yyyyMMdd-HHmmss').csv"
)
Import-Module ActiveDirectory -ErrorAction Stop
$common = @{}
if ($Server) { $common.Server = $Server }
$snapshot = Import-Clixml $SnapshotPath
if ($Identity) {
$snapshot = $snapshot | Where-Object { $Identity -contains $_.SamAccountName -or $Identity -contains "$($_.ObjectGUID)" }
}
if (-not $snapshot) { throw "No snapshot entries matched." }
$captured = @(@($snapshot)[0].Attributes.Keys)
# @() forces an array: a single attribute would otherwise be a string, and string + 'cn' concatenates
$toCheck = @(if ($Attributes) { $Attributes | Where-Object { $captured -contains $_ } } else { $captured })
if (-not $toCheck.Count) { throw "None of the requested attributes are in the snapshot." }
$missingAttrs = $Attributes | Where-Object { $captured -notcontains $_ }
if ($missingAttrs) { Write-Warning "Not in snapshot, ignored: $($missingAttrs -join ', ')" }
function Get-Norm($v) {
if ($null -eq $v) { return '' }
if ($v -is [System.Collections.IEnumerable] -and $v -isnot [string]) {
return (@($v | ForEach-Object { "$_" }) | Sort-Object) -join "`n"
}
"$v"
}
function Get-ParentDN([string]$dn) { $dn -replace '^CN=.+?(?<!\\),', '' }
$report = [System.Collections.Generic.List[object]]::new()
function Add-Row($s, $attr, $snapVal, $curVal, $action, $applied, $err) {
$row = [pscustomobject]@{
SamAccountName = $s.SamAccountName
ObjectGUID = $s.ObjectGUID
Attribute = $attr
SnapshotValue = (Get-Norm $snapVal) -replace "`n", ' | '
CurrentValue = (Get-Norm $curVal) -replace "`n", ' | '
Action = $action
Applied = $applied
Error = $err
}
$report.Add($row)
}
foreach ($s in $snapshot) {
try {
$cur = Get-ADUser @common -Identity $s.ObjectGUID -Properties (@($toCheck) + 'cn')
} catch {
$action = if ($_.Exception.GetType().Name -eq 'ADIdentityNotFoundException') { 'MISSING - check AD Recycle Bin' } else { 'READ ERROR - user skipped' }
Add-Row $s '(object)' $s.DistinguishedName $null $action $false $_.Exception.Message
continue
}
$replace = @{}
$clear = [System.Collections.Generic.List[string]]::new()
foreach ($a in $toCheck) {
$snapVal = $s.Attributes[$a]
$curVal = $cur.$a
if ($a -eq 'manager') {
$curMgrGuid = $null
if ($curVal) { try { $curMgrGuid = (Get-ADUser @common -Identity $curVal).ObjectGUID } catch {} }
if ("$curMgrGuid" -eq "$($s.ManagerGuid)") { continue }
if (-not $s.ManagerGuid) { $clear.Add('manager'); Add-Row $s $a $snapVal $curVal 'Clear' $false $null; continue }
try {
$replace['manager'] = (Get-ADUser @common -Identity $s.ManagerGuid).DistinguishedName
Add-Row $s $a $snapVal $curVal 'Replace' $false $null
} catch { Add-Row $s $a $snapVal $curVal 'Manager object not found' $false $_.Exception.Message }
continue
}
if ((Get-Norm $snapVal) -ceq (Get-Norm $curVal)) { continue }
if ($null -eq $snapVal -or (Get-Norm $snapVal) -eq '') {
$clear.Add($a); Add-Row $s $a $snapVal $curVal 'Clear' $false $null
} else {
# Clixml returns multi-valued attributes as ArrayList; hand AD a string[]
if ($snapVal -is [System.Collections.IEnumerable] -and $snapVal -isnot [string]) {
$replace[$a] = [string[]]@($snapVal | ForEach-Object { "$_" })
} else {
$replace[$a] = $snapVal
}
Add-Row $s $a $snapVal $curVal 'Replace' $false $null
}
}
$nameChanged = $cur.cn -cne $s.CN
$ouChanged = (Get-ParentDN $cur.DistinguishedName) -ne $s.ParentDN
$enChanged = $cur.Enabled -ne $s.Enabled
if ($nameChanged) { Add-Row $s 'cn' $s.CN $cur.cn $(if ($RestoreName) { 'Rename' } else { 'Rename (needs -RestoreName)' }) $false $null }
if ($ouChanged) { Add-Row $s 'OU' $s.ParentDN (Get-ParentDN $cur.DistinguishedName) $(if ($RestoreOU) { 'Move' } else { 'Move (needs -RestoreOU)' }) $false $null }
if ($enChanged) { Add-Row $s 'Enabled' $s.Enabled $cur.Enabled $(if ($RestoreEnabled) { 'SetEnabled' } else { 'SetEnabled (needs -RestoreEnabled)' }) $false $null }
if (-not $Apply) { continue }
$userRows = $report | Where-Object { $_.ObjectGUID -eq $s.ObjectGUID -and $_.Action -notmatch 'needs|MISSING|not found' }
try {
$set = @{}
if ($replace.Count) { $set.Replace = $replace }
if ($clear.Count) { $set.Clear = $clear.ToArray() }
if ($set.Count) { Set-ADUser @common -Identity $s.ObjectGUID @set -ErrorAction Stop }
if ($RestoreName -and $nameChanged) { Rename-ADObject @common -Identity $s.ObjectGUID -NewName $s.CN -ErrorAction Stop }
if ($RestoreOU -and $ouChanged) { Move-ADObject @common -Identity $s.ObjectGUID -TargetPath $s.ParentDN -ErrorAction Stop }
if ($RestoreEnabled -and $enChanged) {
if ($s.Enabled) { Enable-ADAccount @common -Identity $s.ObjectGUID -ErrorAction Stop }
else { Disable-ADAccount @common -Identity $s.ObjectGUID -ErrorAction Stop }
}
$userRows | ForEach-Object { $_.Applied = $true }
} catch {
$msg = $_.Exception.Message
$userRows | ForEach-Object { $_.Error = "$($_.Error)$msg" }
Write-Warning "$($s.SamAccountName): $msg"
}
}
$report | Export-Csv $ReportPath -NoTypeInformation
$mode = if ($Apply) { 'APPLIED' } else { 'REPORT ONLY (use -Apply to write)' }
Write-Host "$mode - $(@($report).Count) differences across $(@($report | Select-Object -Unique ObjectGUID).Count) users -> $ReportPath"
$report | Group-Object Attribute | Sort-Object Count -Descending | Format-Table Name, Count -AutoSize