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

SCRIPT LIBRARY · POWERSHELL

Removing Stale Windows User Profiles the Right Way

Clean old user profiles off shared PCs and servers through Win32_UserProfile, so the folder and the registry entry go together and nobody gets a TEMP profile.

AT A GLANCERemove-StaleUserProfile.ps1
What it does
Finds local user profiles that haven't been used in a set number of days and removes them with Remove-CimInstance, skipping system profiles, loaded profiles, the account running the script and any names you exclude.
Requires
  • Windows PowerShell 5.1 or PowerShell 7+
  • No modules
Permissions
Local admin, elevated. Runs fine as SYSTEM.
Runs on
Windows 10/11, Windows Server 2016+ (including RDS hosts)
Tested
Parse-checked and dry-run with mocked cmdlets in PowerShell 7.4

Shared PCs collect user profiles like a junk drawer collects dead batteries. Every contractor, every person who logged in once to check something, every account that left the company two years ago. Each one is a few hundred megabytes at minimum, and some are much, much more.

The tempting fix is to delete the old folders out of C:\Users. Please don't. That leaves the profile's entry in the registry under ProfileList, and the next time that person signs in, Windows can't find the profile it's expecting and hands them a temporary one instead. Now you've got a new ticket. The original version of this post did exactly that, and it's the main reason it needed a rewrite.

The supported way is to delete the Win32_UserProfile object. Windows then removes the folder and the registry entry together, the same as the "Delete" button under System Properties > User Profiles. This script does that, only for profiles that aren't loaded and aren't special, and it gets the profile's age from the right place. Run it with -WhatIf first. Always.

Remove-StaleUserProfile.ps1Download
<#
.SYNOPSIS
    Removes Windows user profiles that haven't been used in a set number of days, the
    supported way: through Win32_UserProfile, never by deleting folders.
.DESCRIPTION
    Lists local profiles with CIM, skips special (system) profiles, loaded profiles, the
    account running the script and anything matching -ExcludeUser, then removes the rest
    that are older than -Days with Remove-CimInstance. That deletes the folder AND the
    ProfileList registry entry together, so Windows doesn't hand the user a TEMP profile
    next time. Age comes from the profile's last unload time where Windows records it,
    and falls back to LastUseTime. Supports -WhatIf; always run that first.
.PARAMETER Days
    Profiles unused for longer than this are removed. Default: 90.
.PARAMETER ExcludeUser
    Account names to never touch. Wildcards work. Matched against DOMAIN\user and user.
.EXAMPLE
    .\Remove-StaleUserProfile.ps1 -Days 60 -WhatIf
.EXAMPLE
    .\Remove-StaleUserProfile.ps1 -Days 120 -ExcludeUser 'CONTOSO\svc-*', 'labadmin' -Confirm:$false
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
param(
    [ValidateRange(14, 3650)]
    [int]$Days = 90,

    [string[]]$ExcludeUser = @('Administrator', 'defaultuser*')
)

$cutoff = (Get-Date).AddDays(-$Days)
$me = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
$profileList = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList'

function Get-LastUsed {
    param($UserProfile)
    # Newer Windows builds record when the profile was last unloaded (logoff). It's a much
    # better signal than LastUseTime, which all sorts of things can bump.
    $reg = Get-ItemProperty -LiteralPath (Join-Path $profileList $UserProfile.SID) -ErrorAction SilentlyContinue
    if ($reg -and $null -ne $reg.LocalProfileUnloadTimeHigh -and $null -ne $reg.LocalProfileUnloadTimeLow) {
        # Registry DWORDs come back as signed Int32, so reinterpret the bits before combining.
        $high = [BitConverter]::ToUInt32([BitConverter]::GetBytes([int32]$reg.LocalProfileUnloadTimeHigh), 0)
        $low  = [BitConverter]::ToUInt32([BitConverter]::GetBytes([int32]$reg.LocalProfileUnloadTimeLow), 0)
        $ft = ([uint64]$high -shl 32) -bor [uint64]$low
        if ($ft -gt 0) { return [pscustomobject]@{ When = [datetime]::FromFileTime([int64]$ft); Source = 'UnloadTime' } }
    }
    if ($UserProfile.LastUseTime) { return [pscustomobject]@{ When = $UserProfile.LastUseTime; Source = 'LastUseTime' } }
    [pscustomobject]@{ When = $null; Source = 'Unknown' }
}

$profiles = Get-CimInstance -ClassName Win32_UserProfile -Filter 'Special = FALSE AND Loaded = FALSE'

foreach ($p in $profiles) {
    if ($p.SID -eq $me) { continue }

    try {
        $account = ([System.Security.Principal.SecurityIdentifier]$p.SID).Translate([System.Security.Principal.NTAccount]).Value
    }
    catch {
        # The account was deleted from AD, or this machine can't reach a DC. Use the folder name.
        $account = Split-Path $p.LocalPath -Leaf
    }
    $shortName = $account.Split('\')[-1]

    if ($ExcludeUser | Where-Object { $account -like $_ -or $shortName -like $_ }) {
        Write-Verbose "Excluded: $account"
        continue
    }

    $used = Get-LastUsed -UserProfile $p
    if (-not $used.When -or $used.When -ge $cutoff) {
        Write-Verbose ("Keeping {0} (last used {1}, {2})" -f $account, $used.When, $used.Source)
        continue
    }

    $row = [pscustomobject]@{
        Account   = $account
        LocalPath = $p.LocalPath
        LastUsed  = $used.When
        AgeSource = $used.Source
        DaysIdle  = [int]((Get-Date) - $used.When).TotalDays
        Action    = $null
    }

    if ($PSCmdlet.ShouldProcess("$account ($($p.LocalPath)), idle $($row.DaysIdle) days", 'Remove user profile')) {
        try {
            Remove-CimInstance -InputObject $p -Confirm:$false -ErrorAction Stop
            $row.Action = 'Removed'
        }
        catch {
            $row.Action = "Failed: $($_.Exception.Message)"
        }
    }
    else {
        $row.Action = if ($WhatIfPreference) { 'WouldRemove' } else { 'Skipped' }
    }
    $row
}

Parameters

ParameterTypeDefaultWhat it's for
-Daysint90Profiles that haven't been used for longer than this are removed. The minimum is 14.
-ExcludeUserstring[]Administrator, defaultuser*Account names that are never touched. Wildcards work, and names are matched with and without the domain (CONTOSO\labadmin or just labadmin).

Run it

See what would be removed. Start here every time.

.\Remove-StaleUserProfile.ps1 -Days 60 -WhatIf

Run it for real, protecting service and admin accounts.

.\Remove-StaleUserProfile.ps1 -Days 120 -ExcludeUser 'Administrator', 'CONTOSO\svc-*', 'labadmin'

Unattended, from a scheduled task or deployment tool. The script prompts by default, so turn that off.

.\Remove-StaleUserProfile.ps1 -Days 90 -Confirm:$false

Keep a record of what was removed.

.\Remove-StaleUserProfile.ps1 -Days 90 -Confirm:$false | Export-Csv C:\Logs\profile-cleanup.csv -Append -NoTypeInformation

What you'll see

Example outputvalues are illustrative
Account           LocalPath          LastUsed             AgeSource   DaysIdle Action
-------           ---------          --------             ---------   -------- ------
CONTOSO\adele.v   C:\Users\adele.v   2/11/2026 5:14:08 PM UnloadTime       230 Removed
CONTOSO\alex.w    C:\Users\alex.w    4/30/2026 9:41:55 AM UnloadTime       152 Removed
CONTOSO\megan.b   C:\Users\megan.b   5/2/2026 11:02:13 AM LastUseTime      150 Failed: Access is denied.

How it works

  1. Ask Windows for the profiles. Get-CimInstance Win32_UserProfile with a filter for Special = FALSE AND Loaded = FALSE. That excludes the system accounts (SYSTEM, LocalService, NetworkService) and anyone who's signed in right now, before the script even looks at them.
  2. Skip the obvious ones. The account running the script, and anything matching -ExcludeUser. The SID is translated to a name when possible; for accounts that no longer exist in AD, it falls back to the folder name.
  3. Work out when it was last used. It reads LocalProfileUnloadTimeHigh and LocalProfileUnloadTimeLow from the profile's key under ProfileList and combines them into a date. That's the last logoff, and it's the most honest number available. If they're not there, it uses LastUseTime. If there's no date at all, the profile is kept.
  4. Remove through CIM. Remove-CimInstance on the profile object. Windows deletes the folder and the registry key together. Each profile is handled on its own, so one failure doesn't stop the rest.

Take it further

  • Or let Group Policy do it. "Delete user profiles older than a specified number of days on system restart" (Computer Configuration > Administrative Templates > System > User Profiles) does the same job at every reboot. The script is handy when you want to see the list first, need exclusions, or can't wait for a reboot.
  • Run it remotely. Invoke-Command -FilePath can't pass switches like -WhatIf, so put the script somewhere the target can reach and call it inside a scriptblock: Invoke-Command -ComputerName PC-0142 { & C:\Scripts\Remove-StaleUserProfile.ps1 -Days 90 -WhatIf }.
  • Pair it with disk cleanup. Profiles are usually the single biggest win on a shared PC. Follow up with the disk cleanup script for what's left.

Things that'll trip you up

  • LastUseTime isn't trustworthy. Win32_UserProfile.LastUseTime gets bumped by things that aren't a person signing in, like some updates and security tools, which makes every profile look fresh. The script prefers the LocalProfileUnloadTime values Windows records at logoff and only falls back to LastUseTime when they're missing. The AgeSource column tells you which one it used.
  • It prompts unless you tell it not to. The script is marked high-impact, so an interactive run asks before each removal. From a scheduled task or a deployment tool, pass -Confirm:$false or it'll sit there waiting for an answer nobody can give.
  • A profile that won't delete is usually still in use. A leftover process running as that user, a stuck logoff, or a file held open by antivirus can block removal. Those come back as Failed with the reason, and a reboot usually clears them for the next run.
  • Roaming and FSLogix profiles are different. This removes the local copy. Roaming profiles still live on the server, and FSLogix containers are handled by FSLogix. Don't point this at an FSLogix host expecting it to clean up the VHDs.