Wes Ellis./ a personal notebook
Technology. Stories. Side projects.
A few things worth writing down.
← Back to Script Library

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.

AT A GLANCERemove-StaleADComputer.ps1
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

Part 5 of the thread Keeping Active Directory tidy

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.

Remove-StaleADComputer.ps1Download
<#
.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

ParameterTypeDefaultWhat it's for
-ComputerNamestring[]—Names to consider. Takes pipeline input, so you can pipe a text file straight in. Blank lines are ignored.
-SearchBasestring—Consider every computer under this OU instead of a list.
-InactiveDaysint90How long both the last logon and the machine password must be quiet before a computer counts as stale. Minimum 30.
-DisableOnlyswitch—Disable stale accounts instead of deleting them. A good first pass.
-LogPathstring.\stale-computers-<timestamp>.csvWhere to write the CSV log. It's written even during -WhatIf, so the dry run leaves a record too.
-Serverstring—Domain name or domain controller to use.
-WhatIfswitch—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 -WhatIf

Disable everything in the workstations OU that's been quiet for four months.

.\Remove-StaleADComputer.ps1 -SearchBase 'OU=Workstations,DC=contoso,DC=com' -InactiveDays 120 -DisableOnly

Delete 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:$false

Feed it straight from the inventory export.

Import-Csv .\ad-computers-2026-09-29.csv | Where-Object DaysSinceLogon -gt 180 | .\Remove-StaleADComputer.ps1 -WhatIf

What you'll see

Example outputvalues are illustrative
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

  1. 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.
  2. Rule out the obvious no's. Domain controllers are spotted by the SERVER_TRUST_ACCOUNT flag (8192) in userAccountControl and skipped. So are accounts created inside the cutoff window.
  3. Check both clocks. It converts lastLogonTimestamp from its raw file-time format, then compares it and PasswordLastSet against the cutoff. If either one is recent, the computer is skipped as active, and the log says which date saved it.
  4. Act, or pretend to. ShouldProcess decides whether this is a real run or a -WhatIf. Deletes use Remove-ADObject -Recursive, because computers often have child objects (BitLocker keys, Hyper-V and print queue entries) that make a plain Remove-ADComputer fail with "the object is not a leaf."
  5. 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 -DisableOnly and 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 DaysSinceLogon for 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-MgDevice and its ApproximateLastSignInDateTime property 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.