Clean up old Windows user profiles with a PowerShell startup script
Nathan Chadwick · · 4 min read
TLDRRead the short version
- The built-in Delete user profiles older than N days GPO often does nothing useful because updates, EDR scans, and OneDrive keep profile timestamps fresh.
- It also only runs at restart and gives you almost no logging to hand an auditor.
- Better pattern: a computer startup script running as SYSTEM that queries Win32_UserProfile and uses LastUseTime as the age signal.
- Delete through Remove-CimInstance rather than Remove-Item so the ProfileList registry entry goes with the folder, and skip special or loaded profiles.
- The post includes a ready script with logging to ProgramData and a WhatIf reporting mode. Run reporting only for a week first.
- Keep the native GPO if hosts reboot often and you need no custom exclusions, and use the script as a safety net and report.
The problem#
Shared PCs, RDS hosts, and lab machines quietly grow a mountain of local profiles under C:\Users. Disk fills up, logons get slower, and nobody wants to click through System Properties deleting profiles by hand.
Windows has a built-in answer: the Group Policy setting Delete user profiles older than a specified number of days on system restart (Computer Configuration → Administrative Templates → System → User Profiles). On paper it is perfect. In practice a lot of estates turn it on, reboot, and nothing useful happens.
Why the GPO often disappoints#
That policy is driven by how Windows decides a profile is "unused". Historically that leans heavily on file timestamps (notably NTUSER.DAT). Background noise keeps those timestamps fresh even when a human has not logged on for months:
- Cumulative updates and servicing touching hive files
- Antivirus / EDR scans
- OneDrive, Office, or shell components writing into the profile tree
- Scheduled tasks or services running in the user context
So the profile never looks "old enough", the User Profile Service skips it at restart, and your disk story does not improve. Microsoft has tightened behaviour in newer Windows 11 builds, but anyone who has managed RDS or hot-desk fleets will recognise the pattern: policy says 30 or 60 days, folders older than that are still sitting there.
There is another limit: the GPO only acts on restart. Machines that stay up for weeks (or only reboot for patches) will not clean mid-cycle. You also get almost no logging you can hand to an auditor beyond "it should have run".
A better pattern: PowerShell at computer startup#
Run cleanup yourself with a computer startup script (GPO or Intune equivalent) that:
- Runs as SYSTEM before users log on
- Queries
Win32_UserProfile(WMI/CIM), not folder dates underC:\Users - Uses
LastUseTimefrom that class as the age signal - Skips special/system profiles and anything currently loaded
- Deletes through the profile APIs (
Remove-CimInstance/Remove-WmiObject), notRemove-Item -Recurseon the folder alone
LastUseTime tracks actual profile use more honestly than a hive file that patch Tuesday keeps poking. Deleting via CIM also removes the ProfileList registry entry and the folder together, which is what you want. Deleting only the folder leaves orphaned SIDs and broken logons later.
Example script (report first, then enforce)#
Save something like this as Cleanup-OldUserProfiles.ps1 and attach it as a Computer Configuration → Startup script. Start with -WhatIf style reporting in production for a week.
# Cleanup-OldUserProfiles.ps1
# Computer startup script: remove local profiles unused for N days.
# Prefer Win32_UserProfile.LastUseTime over NTUSER.DAT timestamps.
param(
[int]$Days = 60,
[switch]$WhatIf
)
$ErrorActionPreference = 'Stop'
$logDir = 'C:\ProgramData\ProfileCleanup'
$null = New-Item -ItemType Directory -Force -Path $logDir
$log = Join-Path $logDir ("cleanup-{0:yyyyMMdd-HHmmss}.log" -f (Get-Date))
function Write-Log([string]$Message) {
$line = "{0:u} {1}" -f (Get-Date).ToUniversalTime(), $Message
Add-Content -Path $log -Value $line
Write-Output $line
}
$cutoff = (Get-Date).AddDays(-$Days)
Write-Log "Starting profile cleanup. Days=$Days Cutoff=$cutoff WhatIf=$WhatIf"
$profiles = Get-CimInstance -ClassName Win32_UserProfile | Where-Object {
-not $_.Special -and
-not $_.Loaded -and
$_.LastUseTime -and
$_.LocalPath -notmatch '\\(Default|Public|Default User)$'
}
foreach ($p in $profiles) {
$lastUse = [System.Management.ManagementDateTimeConverter]::ToDateTime($p.LastUseTime)
if ($lastUse -ge $cutoff) { continue }
$msg = "Aged profile: path=$($p.LocalPath) sid=$($p.SID) lastUse=$lastUse"
if ($WhatIf) {
Write-Log "WHATIF $msg"
continue
}
try {
Remove-CimInstance -InputObject $p
Write-Log "REMOVED $msg"
}
catch {
Write-Log "FAILED $($p.LocalPath): $($_.Exception.Message)"
}
}
Write-Log 'Finished profile cleanup.'Notes:
- Tune
$Daysto your estate (45-90 is common on RDS; longer on specialist engineering kits). - Exclude known service / kiosk accounts by SID or path if you have them.
- Keep the log under
ProgramDataso helpdesk can prove what ran after a reboot. - Pair with disk monitoring so you notice if cleanup stops working.
Deploying it#
Group Policy: Computer Configuration → Windows Settings → Scripts → Startup → add the .ps1 (and ensure PowerShell startup scripts are allowed).
Intune: Deploy as a platform script or Win32 package that registers a scheduled task at startup, or use a remediation script on a cadence if you cannot rely on reboots.
Always: pilot on a few hosts, run with reporting only first, then flip to delete.
When the built-in GPO is still fine#
Use the native policy if:
- Hosts reboot often
- You are on a Windows build where CleanupProfiles behaves reliably for you
- You do not need custom exclusions or audit logs beyond Event Viewer
Even then, keep the PowerShell script as a safety net or reporting tool. Many teams leave the GPO enabled and run a weekly report against Win32_UserProfile so they can see profiles the policy refused to touch.
Takeaway#
The GPO is convenient, but it is not always honest about "last used". A short PowerShell startup script that reads Win32_UserProfile.LastUseTime and deletes through CIM gives you clearer rules, better logging, and cleanup that matches how people actually use the machine. That is usually what shared Windows fleets need.
References#
Sources and further reading for the claims above.