DEV Community

Scott Williams
Scott Williams

Posted on

Find what's filling your Windows drive with PowerShell: a read-only audit first, then a cleanup you can preview with -WhatIf

AI disclosure: this post was written with AI assistance. The code was syntax-checked and linted, and its logic was tested with simulated drives and a throwaway test folder, but it has not yet been run on Windows: not on a real Windows 11 PC, and not on Windows PowerShell 5.1. Read it before you run it, and try the cleanup with -WhatIf first.

When the C: drive turns red, the first stop is Windows itself. Settings > System > Storage > Cleanup recommendations lists temporary files, large or unused files, files synced to the cloud and unused apps, and shows how much each would free. Storage Sense on the same page can clear temporary files and the Recycle Bin automatically. Use those first.

What Settings doesn't give you is a quick, scriptable answer to where did the space go? This post builds that answer in PowerShell, in two parts:

  1. A read-only audit. Free space per drive, the biggest folders under your profile, and the size of the places that usually hold junk. It changes nothing.
  2. One narrow cleanup. Delete files from your own TEMP folder that haven't changed in a week. It asks first, supports -WhatIf, and refuses to run if TEMP looks wrong.

Everything else (Downloads, the Recycle Bin, the Windows Update cache, Windows.old) is only measured. You decide what to do with those.

Step 1: free space per drive

Win32_LogicalDisk reports every logical disk. DriveType 3 means "Local Disk", and Size and FreeSpace are in bytes:

function Get-FixedDriveSpace {
    [CmdletBinding()]
    param()
    Get-CimInstance -ClassName Win32_LogicalDisk -Filter 'DriveType = 3' -ErrorAction Stop |
        ForEach-Object {
            $pct = if ($_.Size -gt 0) { [math]::Round(100 * $_.FreeSpace / $_.Size, 1) } else { $null }
            [pscustomobject]@{
                Drive       = $_.DeviceID
                SizeGB      = [math]::Round($_.Size / 1GB, 1)
                FreeGB      = [math]::Round($_.FreeSpace / 1GB, 1)
                PercentFree = $pct
            }
        }
}

Get-FixedDriveSpace | Format-Table -AutoSize
Enter fullscreen mode Exit fullscreen mode

Only DriveType = 3 disks come back. Removable and network drives have other DriveType values, so they're left out.

Step 2: measure a folder without following links

The obvious one-liner is Get-ChildItem -Recurse | Measure-Object Length -Sum. It has a catch: junctions and symbolic links. A link can point at a folder somewhere else, so it gets counted twice or sends you into a loop. The rules also differ between versions: in PowerShell 6 and later, -Recurse doesn't follow symbolic links to directories unless you add -FollowSymlink, and Windows PowerShell 5.1 doesn't have that switch. So walk the tree yourself, and skip anything with the ReparsePoint attribute:

function Get-FolderSize {
    [CmdletBinding()]
    param([Parameter(Mandatory)] [string] $Path)

    $result = [pscustomobject]@{ Path = $Path; Bytes = [long]0; Files = 0; SkippedLinks = 0; Unreadable = 0 }
    if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return $result }

    $stack = New-Object System.Collections.Generic.Stack[string]
    $stack.Push($Path)
    while ($stack.Count -gt 0) {
        $dir = $stack.Pop()
        $errs = $null
        $items = Get-ChildItem -LiteralPath $dir -Force -ErrorAction SilentlyContinue -ErrorVariable errs
        $result.Unreadable += @($errs).Count
        foreach ($item in $items) {
            $isLink = ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0
            if ($item.PSIsContainer) {
                if ($isLink) { $result.SkippedLinks++ } else { $stack.Push($item.FullName) }
            }
            elseif ($isLink) { $result.SkippedLinks++ }
            else { $result.Bytes += $item.Length; $result.Files++ }
        }
    }
    $result
}
Enter fullscreen mode Exit fullscreen mode

Two details:

  • -ErrorAction SilentlyContinue -ErrorVariable errs keeps going past folders you can't read (some system folders need admin rights) and counts them, instead of filling the screen with red.
  • The total is the sum of file lengths. That can differ from "size on disk" in File Explorer, for example for compressed files or OneDrive files that are only stored online.

Then list the biggest folders directly under your profile:

function Get-LargestFolder {
    [CmdletBinding()]
    param([string] $Path = $env:USERPROFILE, [int] $Top = 10)
    Get-ChildItem -LiteralPath $Path -Directory -Force -ErrorAction Stop |
        Where-Object { ($_.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq 0 } |
        ForEach-Object {
            $s = Get-FolderSize -Path $_.FullName
            [pscustomobject]@{ Folder = $_.FullName; SizeGB = [math]::Round($s.Bytes / 1GB, 2); Files = $s.Files; Bytes = $s.Bytes }
        } |
        Sort-Object -Property Bytes -Descending |
        Select-Object -First $Top
}

Get-LargestFolder -Top 10 | Format-Table Folder, SizeGB, Files -AutoSize
Enter fullscreen mode Exit fullscreen mode

Run it again with -Path "$env:USERPROFILE\AppData\Local" (or any folder that stands out) to drill down. Measuring a big profile can take a few minutes.

Step 3: the usual suspects

These are the places Microsoft's own cleanup advice keeps coming back to:

Location How the script finds it What to do about it
Your TEMP folder $env:TEMP Cleanup recommendations > Temporary files, or Step 4 below
Windows Update download cache %SystemRoot%\SoftwareDistribution\Download Leave it to Cleanup recommendations or Disk Cleanup
Recycle Bin Shell's Recycle Bin folder (see below) Look inside, then Clear-RecycleBin
Downloads %USERPROFILE%\Downloads (the default location) Your files: sort by size and decide
Previous Windows install %SystemDrive%\Windows.old Removing it means you can't go back to the previous version

The Recycle Bin is a virtual folder, so there's no plain path to measure. The Shell object exposes it as special folder 10 (ssfBITBUCKET), and each item has a Size:

function Get-RecycleBinByteCount {
    [CmdletBinding()]
    [OutputType([long])]
    param()
    try {
        $bin = (New-Object -ComObject Shell.Application).NameSpace(10)
        $total = [long]0
        foreach ($i in $bin.Items()) { $total += [long]$i.Size }
        $total
    }
    catch {
        Write-Warning "Could not read the Recycle Bin size: $($_.Exception.Message)"
        $null
    }
}
Enter fullscreen mode Exit fullscreen mode

The snippets here are trimmed; the repo version adds parameter validation and more output fields. The full script wraps all of this in one Get-CleanupCandidate function that prints a row per location with its size and advice:

Get-CleanupCandidate | Format-Table Location, SizeGB, Advice -Wrap
Enter fullscreen mode Exit fullscreen mode

A note on Downloads: %USERPROFILE%\Downloads is only the default. Microsoft notes that known folders can be redirected, so if yours has moved, pass the real path with -DownloadsPath.

Step 4: clear old TEMP files, with a preview

This is the only part that deletes anything, so it has the most guard rails:

function Assert-SafeTempPath {
    [CmdletBinding()]
    param([string] $TempPath)
    if ([string]::IsNullOrWhiteSpace($TempPath)) { throw 'TEMP path is empty. Refusing to clean.' }
    if (-not (Test-Path -LiteralPath $TempPath -PathType Container)) { throw "TEMP folder not found: $TempPath" }
    $leaf = Split-Path -Path $TempPath.TrimEnd('\', '/') -Leaf
    if ($leaf -notin @('Temp', 'Tmp')) {
        throw "Refusing to clean '$TempPath': the folder is not named Temp or Tmp."
    }
}

function Get-OldTempFile {
    [CmdletBinding()]
    param([string] $TempPath = $env:TEMP, [ValidateRange(0, 3650)] [int] $OlderThanDays = 7)
    if ([string]::IsNullOrWhiteSpace($TempPath) -or -not (Test-Path -LiteralPath $TempPath -PathType Container)) { return }
    $cutoff = (Get-Date).AddDays(-$OlderThanDays)
    $stack = New-Object System.Collections.Generic.Stack[string]
    $stack.Push($TempPath)
    while ($stack.Count -gt 0) {
        foreach ($item in (Get-ChildItem -LiteralPath $stack.Pop() -ErrorAction SilentlyContinue)) {
            if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { continue }
            if ($item.PSIsContainer) { $stack.Push($item.FullName) }
            elseif ($item.LastWriteTime -lt $cutoff) { $item }
        }
    }
}

function Remove-OldTempFile {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param([ValidateRange(1, 3650)] [int] $OlderThanDays = 7, [string] $TempPath = $env:TEMP)

    Assert-SafeTempPath -TempPath $TempPath
    $files = @(Get-OldTempFile -TempPath $TempPath -OlderThanDays $OlderThanDays)
    $mb = [math]::Round((($files | Measure-Object -Property Length -Sum).Sum) / 1MB, 1)

    $deleted = 0; $skipped = 0
    $target = "$($files.Count) file(s) ($mb MB) in $TempPath not changed in $OlderThanDays days"
    if ($files.Count -gt 0 -and $PSCmdlet.ShouldProcess($target, 'Delete (no undo)')) {
        foreach ($f in $files) {
            try   { Remove-Item -LiteralPath $f.FullName -ErrorAction Stop -Confirm:$false; $deleted++ }
            catch { $skipped++ }   # usually a file a running program still has open
        }
    }
    [pscustomobject]@{ Found = $files.Count; Deleted = $deleted; Skipped = $skipped }
}
Enter fullscreen mode Exit fullscreen mode

Why each piece is there:

  • SupportsShouldProcess gives the function -WhatIf and -Confirm. Nothing inside the if ($PSCmdlet.ShouldProcess(...)) block runs under -WhatIf.
  • ConfirmImpact = 'High' means PowerShell asks before deleting even when you don't pass -Confirm, because the default $ConfirmPreference is High. You confirm once for the whole batch. -Confirm:$false on Remove-Item stops it asking again for every file.
  • The name check is there because .NET's GetTempPath falls back to your profile folder, or even the Windows folder, when the TMP and TEMP variables are missing. A cleanup that trusts the variable blindly could be pointed at the wrong place. This one only runs on a folder actually named Temp or Tmp.
  • No link-following, for the same reason as Step 2. A junction inside TEMP can't lead the script out of TEMP.
  • No -Force on Remove-Item. Without -Force, PowerShell won't delete hidden or read-only files, and those are best left alone here.

Preview, then run:

Remove-OldTempFile -OlderThanDays 7 -WhatIf
Remove-OldTempFile -OlderThanDays 7
Enter fullscreen mode Exit fullscreen mode

What about the Recycle Bin?

PowerShell already has Clear-RecycleBin, and it supports -WhatIf and -Confirm:

Clear-RecycleBin -DriveLetter C -WhatIf
Clear-RecycleBin -DriveLetter C
Enter fullscreen mode Exit fullscreen mode

Open the Recycle Bin and look first: once it's emptied, the files are gone. Don't add -Force. Microsoft's documentation for Clear-RecycleBin says -Force also overrides -WhatIf and -Confirm, which defeats the whole point of previewing.

What not to touch by hand

  • C:\Windows\WinSxS. Microsoft says not to delete the WinSxS folder and to shrink it with tools built into Windows instead, for example Dism.exe /online /Cleanup-Image /StartComponentCleanup from an elevated prompt.
  • Windows.old. Remove it from Cleanup recommendations, and only once you're sure you won't roll back.
  • The Windows Update cache. Disk Cleanup and Cleanup recommendations handle system files. If Windows Update itself is stuck, follow Microsoft's reset steps rather than deleting the folder while updates are running.

Limits, honestly

  • The code was tested with PowerShell 7 on Linux, using fake drives, a fake Recycle Bin and real files in a throwaway test folder. PSScriptAnalyzer reported nothing, and Pester tests of the size maths, the link skipping, the -WhatIf path and the refusal checks passed. It has not been run on a real Windows PC or on Windows PowerShell 5.1. Try it with -WhatIf first.
  • Sizes are file lengths, not "size on disk".
  • There's no undo for the TEMP cleanup. That's why it's limited to old files in a folder meant for temporary files.
  • The full script, with its Pester tests, is at https://github.com/fecklesspleb/disk-space-audit (MIT).

Sources (Microsoft)

This article was created with the help of AI. Not affiliated with or endorsed by Microsoft.

If you want the rest of the fixes (slow startup, full drive, stuck updates) in one place, I sell a 40-page guide with the scripts: Windows 11 Fix-It Guide + PowerShell Toolkit, $19.

Top comments (0)