SCRIPT LIBRARY · POWERSHELL
Deleting Stale Computer Accounts from Active Directory, Safely, with a CSV Log
Feed it a list or an OU, and it deletes (or disables) only the computers that are genuinely stale, logs every decision to CSV, and shows you the plan first with -WhatIf.
- What it does
- Checks each computer's lastLogonTimestamp and machine password age, and deletes or disables it only if both are older than your cutoff. Domain controllers and new accounts are always skipped, and every computer gets a row in a CSV log.
- Requires
- Windows PowerShell 5.1 or PowerShell 7 on Windows
- ActiveDirectory module (RSAT)
- Permissions
- Delete (or write, for -DisableOnly) rights on the computer objects. Delegated rights on the workstation OUs are enough; you don't need Domain Admin.
- Runs on
- Windows 10/11 with RSAT, Windows Server 2016+
- Tested
- Parse-checked and dry-run with mocked AD cmdlets in PowerShell 7.4, including -WhatIf, -DisableOnly and per-item failures
The original version of this script did exactly what it said: read a text file of names and delete every one of them. No questions asked. That's fine right up until somebody pastes the wrong list, or a machine on that list came back from the repair shop last week and is very much alive.
This version asks questions. For each computer it checks two things: when it last logged on (lastLogonTimestamp) and when it last changed its machine password. Windows machines change that password every 30 days on their own, so if both dates are older than your cutoff, the machine really hasn't been talking to the domain. Anything newer gets skipped, with the reason written down. Domain controllers are never touched, and neither are accounts created inside the cutoff window, like a machine that was staged but hasn't been handed out yet.
You can feed it a list (a text file piped in works fine) or point it at an OU. It supports -WhatIf, it can disable instead of delete, and every decision lands in a CSV so you can answer "what happened to PC-0311?" three months from now.
<#
.SYNOPSIS
Deletes (or disables) stale computer accounts in Active Directory and logs every decision to CSV.
.DESCRIPTION
Takes computer names from a list or the pipeline, or finds candidates under an OU, then
checks each one before touching it. A computer only counts as stale when both its
lastLogonTimestamp and its machine password are older than -InactiveDays. Domain
controllers and recently created accounts are always skipped. Every computer gets a row
in the CSV log, whether it was removed, disabled, skipped or failed. Supports -WhatIf.
.PARAMETER ComputerName
Computer names to consider. Accepts pipeline input, so Get-Content .\list.txt | works.
.PARAMETER SearchBase
Instead of a list, consider every computer under this OU.
.PARAMETER InactiveDays
How long a computer must have been quiet to count as stale. Default: 90.
.PARAMETER DisableOnly
Disable the stale accounts instead of deleting them.
.PARAMETER LogPath
CSV log path. Defaults to stale-computers-<timestamp>.csv in the current folder.
.PARAMETER Server
Domain or domain controller to use.
.EXAMPLE
Get-Content .\old-pcs.txt | .\Remove-StaleADComputer.ps1 -WhatIf
.EXAMPLE
.\Remove-StaleADComputer.ps1 -SearchBase 'OU=Workstations,DC=contoso,DC=com' -InactiveDays 120 -DisableOnly
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High', DefaultParameterSetName = 'List')]
param(
[Parameter(Mandatory, ParameterSetName = 'List', ValueFromPipeline, ValueFromPipelineByPropertyName)]
[Alias('Name')][AllowEmptyString()][string[]]$ComputerName,
[Parameter(Mandatory, ParameterSetName = 'OU')][string]$SearchBase,
[ValidateRange(30, 3650)][int]$InactiveDays = 90,
[switch]$DisableOnly,
[string]$LogPath = (Join-Path (Get-Location) ('stale-computers-{0:yyyyMMdd-HHmm}.csv' -f (Get-Date))),
[string]$Server
)
begin {
Import-Module ActiveDirectory -ErrorAction Stop
$ad = @{ ErrorAction = 'Stop' }
if ($Server) { $ad.Server = $Server }
$props = 'lastLogonTimestamp', 'PasswordLastSet', 'whenCreated', 'userAccountControl', 'OperatingSystem'
$cutoff = (Get-Date).AddDays(-$InactiveDays)
$log = [System.Collections.Generic.List[object]]::new()
$action = if ($DisableOnly) { 'Disable' } else { 'Delete' }
function Add-LogRow($Name, $Computer, $Result, $Detail) {
$lastLogon = if ($Computer.lastLogonTimestamp) { [datetime]::FromFileTime($Computer.lastLogonTimestamp) } else { $null }
$row = [pscustomobject]@{
Time = (Get-Date).ToString('s')
Name = $Name
LastLogon = $lastLogon
PasswordLastSet = $Computer.PasswordLastSet
OperatingSystem = $Computer.OperatingSystem
Action = $action
Result = $Result
Detail = $Detail
DistinguishedName = $Computer.DistinguishedName
}
$log.Add($row)
$row
}
function Invoke-Cleanup($Computer, [string]$Name) {
$lastLogon = if ($Computer.lastLogonTimestamp) { [datetime]::FromFileTime($Computer.lastLogonTimestamp) } else { $null }
# 8192 = SERVER_TRUST_ACCOUNT: this is a domain controller. Never touch those here.
if ($Computer.userAccountControl -band 8192) { return Add-LogRow $Name $Computer 'Skipped' 'Domain controller' }
if ($Computer.whenCreated -gt $cutoff) { return Add-LogRow $Name $Computer 'Skipped' "Created $($Computer.whenCreated.ToString('d')), too new to judge" }
if ($lastLogon -and $lastLogon -gt $cutoff) { return Add-LogRow $Name $Computer 'Skipped' "Active: last logon $($lastLogon.ToString('d'))" }
if ($Computer.PasswordLastSet -gt $cutoff) { return Add-LogRow $Name $Computer 'Skipped' "Active: machine password changed $($Computer.PasswordLastSet.ToString('d'))" }
$why = if ($lastLogon) { "No logon since $($lastLogon.ToString('d'))" } else { 'Never logged on' }
if (-not $PSCmdlet.ShouldProcess("$Name ($why)", "$action computer account")) { return Add-LogRow $Name $Computer 'WhatIf' $why }
try {
if ($DisableOnly) {
Disable-ADAccount -Identity $Computer.DistinguishedName -Confirm:$false @ad
}
else {
# -Recursive because computers can have child objects (BitLocker keys, Hyper-V, printers)
# that make a plain Remove-ADComputer fail with "the object is not a leaf".
Remove-ADObject -Identity $Computer.DistinguishedName -Recursive -Confirm:$false @ad
}
Add-LogRow $Name $Computer 'Done' $why
}
catch { Add-LogRow $Name $Computer 'Failed' $_.Exception.Message }
}
}
process {
if ($PSCmdlet.ParameterSetName -eq 'OU') {
foreach ($pc in Get-ADComputer -Filter * -SearchBase $SearchBase -Properties $props @ad) { Invoke-Cleanup $pc $pc.Name }
return
}
foreach ($name in $ComputerName) {
$name = $name.Trim()
if (-not $name) { continue }
try {
$pc = Get-ADComputer -Filter "Name -eq '$($name -replace "'", "''")'" -Properties $props @ad
}
catch { Add-LogRow $name $null 'Failed' $_.Exception.Message; continue }
if (-not $pc) { Add-LogRow $name $null 'Skipped' 'Not found in AD'; continue }
Invoke-Cleanup $pc $name
}
}
end {
$log | Export-Csv -Path $LogPath -NoTypeInformation -WhatIf:$false -Confirm:$false
$summary = $log | Group-Object Result | ForEach-Object { "$($_.Count) $($_.Name)" }
Write-Host ("{0}. Log saved to {1}" -f ($summary -join ', '), $LogPath)
}
Parameters
| Parameter | Type | Default | What it's for |
|---|---|---|---|
-ComputerName | string[] | — | Names to consider. Takes pipeline input, so you can pipe a text file straight in. Blank lines are ignored. |
-SearchBase | string | — | Consider every computer under this OU instead of a list. |
-InactiveDays | int | 90 | How long both the last logon and the machine password must be quiet before a computer counts as stale. Minimum 30. |
-DisableOnly | switch | — | Disable stale accounts instead of deleting them. A good first pass. |
-LogPath | string | .\stale-computers-<timestamp>.csv | Where to write the CSV log. It's written even during -WhatIf, so the dry run leaves a record too. |
-Server | string | — | Domain name or domain controller to use. |
-WhatIf | switch | — | Check everything and log what would happen, without deleting or disabling anything. |
Run it
Dry run against a list of names from a text file.
Get-Content .\old-pcs.txt | .\Remove-StaleADComputer.ps1 -WhatIfDisable everything in the workstations OU that's been quiet for four months.
.\Remove-StaleADComputer.ps1 -SearchBase 'OU=Workstations,DC=contoso,DC=com' -InactiveDays 120 -DisableOnlyDelete from a list without a prompt for every machine. Run the -WhatIf version first.
Get-Content .\old-pcs.txt | .\Remove-StaleADComputer.ps1 -InactiveDays 180 -Confirm:$falseFeed it straight from the inventory export.
Import-Csv .\ad-computers-2026-09-29.csv | Where-Object DaysSinceLogon -gt 180 | .\Remove-StaleADComputer.ps1 -WhatIfWhat you'll see
What if: Performing the operation "Delete computer account" on target "PC-0311 (No logon since 2/3/2026)".
What if: Performing the operation "Delete computer account" on target "PC-0099 (Never logged on)".
Name LastLogon Result Detail
---- --------- ------ ------
PC-0311 2/3/2026 WhatIf No logon since 2/3/2026
PC-0187 9/12/2026 Skipped Active: last logon 9/12/2026
DC01 9/28/2026 Skipped Domain controller
PC-0405 Skipped Created 9/20/2026, too new to judge
PC-0099 WhatIf Never logged on
PC-9999 Skipped Not found in AD
4 Skipped, 2 WhatIf. Log saved to C:\Temp\stale-computers-20260929-1415.csv
How it works
- Collect candidates. Names come in from the pipeline or
-ComputerName, or the script pulls every computer under-SearchBase. Names are looked up one at a time, so a typo just becomes a "Not found in AD" row. - Rule out the obvious no's. Domain controllers are spotted by the
SERVER_TRUST_ACCOUNTflag (8192) inuserAccountControland skipped. So are accounts created inside the cutoff window. - Check both clocks. It converts
lastLogonTimestampfrom its raw file-time format, then compares it andPasswordLastSetagainst the cutoff. If either one is recent, the computer is skipped as active, and the log says which date saved it. - Act, or pretend to.
ShouldProcessdecides whether this is a real run or a-WhatIf. Deletes useRemove-ADObject -Recursive, because computers often have child objects (BitLocker keys, Hyper-V and print queue entries) that make a plainRemove-ADComputerfail with "the object is not a leaf." - Log everything. Every computer gets a row: time, name, last logon, password date, OS, what was attempted and the result. Failures are caught per machine, so one "access denied" doesn't stop the batch.
Take it further
- Do it in two passes. Run with
-DisableOnlyand move the accounts to a Disabled OU, then run the delete a month later on that OU. Anyone whose machine breaks will tell you well before the second pass. - Start from a report. The computer inventory export gives you
DaysSinceLogonfor every machine, and its CSV pipes straight into this script. - Check the cloud side too. Hybrid-joined computers have a twin in Entra ID. The next Entra Connect sync should remove it, but devices that were also registered on their own tend to linger.
Get-MgDeviceand itsApproximateLastSignInDateTimeproperty will find them.
Things that'll trip you up
- lastLogonTimestamp lags by up to two weeks. AD only updates it every 9 to 14 days to keep replication quiet. That's why -InactiveDays won't go below 30. For "has it been gone for months?" it's exactly the right attribute.
- Deleting also deletes the BitLocker keys. If you store BitLocker recovery passwords in AD, they live under the computer object, and -Recursive removes them with it. If there's any chance you'll need to unlock that drive later, export the keys first or use -DisableOnly and delete later.
- It'll ask before every delete. The script is marked high impact, so PowerShell prompts for each computer unless you add -Confirm:$false. That's deliberate. Run -WhatIf, read the log, then decide.
- Know whether the AD Recycle Bin is on. With the Recycle Bin enabled, Restore-ADObject brings a deleted computer back with its SID and attributes intact. Without it, a deleted account is effectively gone and the machine has to rejoin the domain.
- Not everything is a Windows PC. NAS boxes, Linux servers joined with sssd and other appliances don't always update these attributes the way Windows does. Check the OperatingSystem column in the dry-run log before you delete anything that isn't Windows.