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

SCRIPT LIBRARY · POWERSHELL

Sort a Messy Downloads Folder into Folders by File Type

Move every loose file in a folder into a category subfolder by extension, clear out stale partial downloads and empty folders, and preview all of it with -WhatIf first.

AT A GLANCEMove-FileByType.ps1
What it does
Moves the files sitting directly in a folder into category subfolders (Archives, PDFs, Images, Installers and so on) based on extension or your own name patterns. Never overwrites anything, and can also delete stale partial downloads and remove empty subfolders.
Requires
  • Windows PowerShell 5.1 or PowerShell 7+
  • No modules
Permissions
Write access to the folder you're sorting. No admin rights.
Runs on
Windows 10/11 (and PowerShell 7 on macOS or Linux)
Tested
Parse-checked and run in PowerShell 7.4 against a folder of dummy files with -WhatIf and for real, including name collisions, a JSON rules file, fresh and stale partial downloads, nested empty folders and a second run over the sorted result

Part 9 of the thread File wrangling

Downloads is where files go to be forgotten. Installers for things you set up years ago, a PDF you needed once, a few copies of the same driver, and the odd .crdownload from a download that died halfway. Every few months you give up and sort it by hand.

I went through a couple of versions of this for my own PC: first a batch file full of hard-coded moves, then a PowerShell script with a big table of extensions. It mostly worked, but it also renamed files without asking, and if a file with the same name was already in the target folder, it deleted that one first. Not ideal.

This rewrite keeps the useful part, a sensible set of categories, and fixes the rest. Nothing gets overwritten, every move and delete goes through ShouldProcess, and -WhatIf shows the whole plan, " (2)" renames included, before anything happens. Your own rules go in a small JSON file.

Move-FileByType.ps1Download
<#
.SYNOPSIS
    Sorts the loose files in a folder (Downloads, say) into category subfolders by extension.
.DESCRIPTION
    Looks at the files sitting directly in -Path, works out a category for each one from its
    extension (or from a name pattern in your own rules file), and moves it into a subfolder of
    that name, creating the folder if it has to. Nothing is ever overwritten: if a file with the
    same name is already there, the moved one gets " (2)", " (3)" and so on.
    Optionally deletes stale partial downloads (.part, .crdownload and friends) and removes empty
    subfolders afterwards. Everything goes through ShouldProcess, so -WhatIf shows the whole plan.
.PARAMETER Path
    The messy folder. Default: the Downloads folder in your profile.
.PARAMETER Destination
    Where the category folders go. Default: inside -Path.
.PARAMETER RulePath
    A JSON file of extra rules, checked before the built-in list. Each key is a folder name and
    each value is a list of extensions (".stl") or wildcard name patterns ("DRV_*", "*invoice*").
.PARAMETER OtherFolder
    Folder for files no rule matches. Default: Other. Use -SkipUnknown to leave them alone instead.
.PARAMETER SkipUnknown
    Leave files with no matching rule where they are.
.PARAMETER Exclude
    Wildcard patterns for files to leave alone, such as '*.ini' or 'keep-*'.
.PARAMETER CleanPartial
    Delete partial downloads (.part, .crdownload, .partial, .download, .opdownload) older than -PartialAgeHours.
.PARAMETER PartialAgeHours
    Only partial downloads this old are deleted, so anything still downloading is left alone. Default: 24.
.PARAMETER RemoveEmptyFolders
    Remove empty subfolders under -Path once the moves are done. -Path itself is never removed.
.EXAMPLE
    .\Move-FileByType.ps1 -WhatIf
.EXAMPLE
    .\Move-FileByType.ps1 -Path D:\Inbox -RulePath .\my-rules.json -CleanPartial -RemoveEmptyFolders
#>
[CmdletBinding(SupportsShouldProcess)]
param(
    [ValidateNotNullOrEmpty()]
    [string]$Path = (Join-Path $HOME 'Downloads'),
    [string]$Destination,
    [string]$RulePath,
    [ValidateNotNullOrEmpty()][string]$OtherFolder = 'Other',
    [switch]$SkipUnknown,
    [string[]]$Exclude = @('desktop.ini', 'Thumbs.db', '.DS_Store'),
    [switch]$CleanPartial,
    [ValidateRange(0, 8760)][int]$PartialAgeHours = 24,
    [switch]$RemoveEmptyFolders
)

# Built-in categories. First match wins, so ambiguous extensions (.ts, .bin, .key) go to the first folder listed.
$categories = [ordered]@{
    'Archives'         = '.zip', '.rar', '.7z', '.tar', '.gz', '.tgz', '.bz2', '.xz', '.zst'
    'Documents'        = '.doc', '.docx', '.odt', '.rtf', '.pages'
    'PDFs'             = '.pdf'
    'Spreadsheets'     = '.xls', '.xlsx', '.xlsm', '.ods', '.csv', '.numbers'
    'Presentations'    = '.ppt', '.pptx', '.odp'
    'Text'             = '.txt', '.md', '.log', '.nfo'
    'eBooks'           = '.epub', '.mobi', '.azw', '.azw3', '.fb2', '.djvu'
    'Comics'           = '.cbz', '.cbr', '.cb7'
    'Images'           = '.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.heif', '.bmp', '.tif', '.tiff', '.avif', '.raw', '.cr2', '.nef', '.arw', '.dng'
    'Design'           = '.psd', '.ai', '.eps', '.svg', '.xcf', '.afdesign', '.afphoto', '.fig', '.sketch'
    'Audio'            = '.mp3', '.flac', '.wav', '.m4a', '.aac', '.ogg', '.opus', '.wma', '.aiff'
    'Video'            = '.mp4', '.mkv', '.avi', '.mov', '.wmv', '.webm', '.m4v', '.mpg', '.mpeg', '.ts'
    'Subtitles'        = '.srt', '.sub', '.idx', '.vtt', '.ass'
    'Installers'       = '.exe', '.msi', '.msix', '.msixbundle', '.appx', '.appxbundle', '.dmg', '.pkg', '.deb', '.rpm', '.apk'
    'Disk Images'      = '.iso', '.img', '.bin', '.cue', '.vhd', '.vhdx', '.vmdk', '.ova'
    'Scripts'          = '.ps1', '.psm1', '.bat', '.cmd', '.sh', '.py', '.reg', '.vbs'
    'Code'             = '.json', '.xml', '.yaml', '.yml', '.toml', '.ini', '.html', '.css', '.js', '.sql'
    'Fonts'            = '.ttf', '.otf', '.woff', '.woff2'
    '3D and CAD'       = '.stl', '.3mf', '.obj', '.step', '.stp', '.gcode', '.dwg', '.dxf', '.blend'
    'Torrents'         = '.torrent'
    'Shortcuts'        = '.url', '.lnk', '.webloc'
    'Certificates'     = '.cer', '.crt', '.pem', '.pfx', '.p12', '.key'
}
$partialExtensions = '.part', '.crdownload', '.partial', '.download', '.opdownload'

try {
    $source = (Get-Item -LiteralPath $Path -ErrorAction Stop).FullName
}
catch { throw "Can't open '$Path': $($_.Exception.Message)" }
if (-not $Destination) { $Destination = $source }

# Your own rules come first: name patterns, then extensions.
$namePatterns = [System.Collections.Generic.List[object]]::new()
$extensionMap = @{}
if ($RulePath) {
    try { $custom = Get-Content -LiteralPath $RulePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop }
    catch { throw "Couldn't read rules from '$RulePath': $($_.Exception.Message)" }
    foreach ($prop in $custom.PSObject.Properties) {
        foreach ($rule in @($prop.Value)) {
            if ($rule -match '^\.[^*?]+$') { if (-not $extensionMap.ContainsKey($rule.ToLower())) { $extensionMap[$rule.ToLower()] = $prop.Name } }
            else { $namePatterns.Add([pscustomobject]@{ Pattern = $rule; Folder = $prop.Name }) }
        }
    }
}
foreach ($folder in $categories.Keys) {
    foreach ($ext in $categories[$folder]) { if (-not $extensionMap.ContainsKey($ext)) { $extensionMap[$ext] = $folder } }
}

# Track names already claimed in each target folder, so -WhatIf shows the same renames a real run would make.
$claimed = @{}
function Get-FreeName([string]$Folder, [string]$Name) {
    if (-not $claimed.ContainsKey($Folder)) { $claimed[$Folder] = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) }
    $base = [IO.Path]::GetFileNameWithoutExtension($Name)
    $ext = [IO.Path]::GetExtension($Name)
    $candidate = $Name
    $n = 1
    while ($claimed[$Folder].Contains($candidate) -or (Test-Path -LiteralPath (Join-Path $Folder $candidate))) {
        $n++
        $candidate = "$base ($n)$ext"
    }
    $null = $claimed[$Folder].Add($candidate)
    $candidate
}

$cutoff = (Get-Date).AddHours(-$PartialAgeHours)
$files = Get-ChildItem -LiteralPath $source -File | Where-Object {
    $file = $_
    $file.FullName -ne $PSCommandPath -and -not ($Exclude | Where-Object { $file.Name -like $_ })
}

foreach ($file in $files) {
    $ext = $file.Extension.ToLower()
    $result = [ordered]@{ Name = $file.Name; Category = $null; Action = $null; Destination = $null }

    try {
        if ($ext -in $partialExtensions) {
            $result.Category = 'Partial download'
            if (-not $CleanPartial) { continue }
            if ($file.LastWriteTime -gt $cutoff) { $result.Action = 'SkippedRecentPartial' }
            elseif ($PSCmdlet.ShouldProcess($file.FullName, 'Delete partial download')) {
                Remove-Item -LiteralPath $file.FullName -Force -ErrorAction Stop
                $result.Action = 'DeletedPartial'
            }
            else { $result.Action = 'WouldDelete' }
            [pscustomobject]$result
            continue
        }

        $category = ($namePatterns | Where-Object { $file.Name -like $_.Pattern } | Select-Object -First 1).Folder
        if (-not $category) { $category = $extensionMap[$ext] }
        if (-not $category) {
            if ($SkipUnknown) { continue }
            $category = $OtherFolder
        }
        $result.Category = $category

        $targetDir = Join-Path $Destination $category
        $newName = Get-FreeName -Folder $targetDir -Name $file.Name
        $target = Join-Path $targetDir $newName
        $result.Destination = $target

        if ($PSCmdlet.ShouldProcess($file.FullName, "Move to $target")) {
            if (-not (Test-Path -LiteralPath $targetDir -PathType Container)) { $null = New-Item -ItemType Directory -Path $targetDir -Force -ErrorAction Stop }
            Move-Item -LiteralPath $file.FullName -Destination $target -ErrorAction Stop
            $result.Action = if ($newName -ne $file.Name) { 'MovedRenamed' } else { 'Moved' }
        }
        else { $result.Action = 'WouldMove' }
    }
    catch {
        $result.Action = "Failed: $($_.Exception.Message)"
        Write-Warning "$($file.Name): $($_.Exception.Message)"
    }
    [pscustomobject]$result
}

if ($RemoveEmptyFolders) {
    # Deepest first, so a folder that only held empty folders goes too.
    $dirs = Get-ChildItem -LiteralPath $source -Directory -Recurse -Force -ErrorAction SilentlyContinue |
        Sort-Object { $_.FullName.Length } -Descending
    # Folders removed (or that -WhatIf would remove) count as gone, so their parents show up too.
    $gone = [System.Collections.Generic.HashSet[string]]::new()
    foreach ($dir in $dirs) {
        if (Get-ChildItem -LiteralPath $dir.FullName -Force -ErrorAction SilentlyContinue | Where-Object { -not $gone.Contains($_.FullName) } | Select-Object -First 1) { continue }
        $result = [ordered]@{ Name = $dir.Name; Category = 'Empty folder'; Action = $null; Destination = $dir.FullName }
        try {
            if ($PSCmdlet.ShouldProcess($dir.FullName, 'Remove empty folder')) {
                Remove-Item -LiteralPath $dir.FullName -ErrorAction Stop
                $result.Action = 'RemovedEmptyFolder'
            }
            else { $result.Action = 'WouldRemove' }
            $null = $gone.Add($dir.FullName)
        }
        catch {
            $result.Action = "Failed: $($_.Exception.Message)"
            Write-Warning "$($dir.FullName): $($_.Exception.Message)"
        }
        [pscustomobject]$result
    }
}

Parameters

ParameterTypeDefaultWhat it's for
-Pathstring$HOME\DownloadsThe folder to sort. Only files sitting directly in it are touched; subfolders are left alone.
-Destinationstringsame as PathWhere the category folders are created. Point it somewhere else to sort into a separate archive.
-RulePathstring—A JSON file of extra rules, checked before the built-in list. Keys are folder names; values are extensions (".stl") or wildcard name patterns ("DRV_*").
-OtherFolderstringOtherWhere files that match no rule end up.
-SkipUnknownswitch—Leave files that match no rule where they are, instead of moving them to OtherFolder.
-Excludestring[]desktop.ini, Thumbs.db, .DS_StoreWildcard patterns for files to never move. Replacing the list replaces the defaults, so include them again if you want them.
-CleanPartialswitch—Delete partial downloads (.part, .crdownload, .partial, .download, .opdownload) older than PartialAgeHours.
-PartialAgeHoursint24How old a partial download has to be before -CleanPartial deletes it, so anything still downloading is safe.
-RemoveEmptyFoldersswitch—Remove empty subfolders afterwards, deepest first. Path itself is never removed.

Run it

See the whole plan first. Nothing moves.

.\Move-FileByType.ps1 -WhatIf

Sort Downloads, clear stale partial downloads and tidy up empty folders.

.\Move-FileByType.ps1 -CleanPartial -RemoveEmptyFolders

Sort a different folder into a separate archive, with your own rules, and leave anything unrecognized alone.

.\Move-FileByType.ps1 -Path D:\Inbox -Destination D:\Sorted -RulePath .\my-rules.json -SkipUnknown

How many files went where.

.\Move-FileByType.ps1 | Group-Object Category | Sort-Object Count -Descending | Select-Object Count, Name

What you'll see

Example outputvalues are illustrative
Name                         Category         Action               Destination
----                         --------         ------               -----------
contoso-invoice-0926.pdf     PDFs             Moved                D:\Inbox\PDFs\contoso-invoice-0926.pdf
contoso-invoice-0926 (1).pdf PDFs             Moved                D:\Inbox\PDFs\contoso-invoice-0926 (1).pdf
DRV_chipset_4.2.zip          Drivers          Moved                D:\Inbox\Drivers\DRV_chipset_4.2.zip
budget-2026.xlsx             Spreadsheets     Moved                D:\Inbox\Spreadsheets\budget-2026.xlsx
IMG_4471.jpg                 Images           MovedRenamed         D:\Inbox\Images\IMG_4471 (2).jpg
setup-x64.msi                Installers       Moved                D:\Inbox\Installers\setup-x64.msi
ubuntu-24.04-desktop.iso     Disk Images      Moved                D:\Inbox\Disk Images\ubuntu-24.04-desktop.iso
mystery.dat                  Other            Moved                D:\Inbox\Other\mystery.dat
video-export.mp4.part        Partial download SkippedRecentPartial
old-backup.zip.crdownload    Partial download DeletedPartial
New folder                   Empty folder     RemovedEmptyFolder   D:\Inbox\New folder

How it works

  1. Build the rule list. Rules from your JSON file come first, name patterns before extensions, then the built-in categories. A rules file looks like { "Drivers": ["DRV_*", ".inf"], "3D Prints": [".stl", ".3mf"] }. Anything starting with a dot and without wildcards is treated as an extension; everything else is a name pattern.
  2. Look at the top level only. Get-ChildItem -File without -Recurse or -Force, so hidden files, subfolders and anything in -Exclude are left alone. The script also skips itself if you happen to run it from the folder it's sorting.
  3. Pick a free name. If the target folder already has a file with that name, or an earlier file in the same run has claimed it, the moved file becomes name (2).ext, then (3), and so on. Because claimed names are tracked in memory, -WhatIf shows exactly the renames a real run would make.
  4. Move through ShouldProcess. The category folder is only created when something is actually going into it, so you don't end up with twenty empty folders on the first run.
  5. Deal with partial downloads. Without -CleanPartial they're left completely alone. With it, only ones older than -PartialAgeHours are deleted.
  6. Remove empty folders last. Deepest first, and a folder whose only contents were empty folders counts as empty too, so the -WhatIf list matches what a real run removes.

Take it further

  • Make it a habit. A weekly scheduled task running -CleanPartial -RemoveEmptyFolders keeps Downloads from ever getting bad again.
  • Hunt down duplicates afterwards. Sorting puts all the copies of the same installer side by side. Compare the file hashes before you delete any of them.
  • Sorting a media library instead? Type folders aren't much help when everything's a video. Letter bucket folders work better there.

Things that'll trip you up

  • Only the top level gets sorted. Files inside subfolders are never moved, which is what keeps the script from re-sorting last month's category folders or tearing apart a folder you unzipped on purpose.
  • Some extensions mean two things. The first match wins, so .ts goes to Video (not TypeScript), .bin to Disk Images and .key to Certificates (not Keynote). If your downloads lean the other way, add the extension to your rules file; your rules are checked first.
  • A paused download can look stale. Browsers keep the .crdownload or .part file for as long as a download is in progress or paused. The age check protects anything from the last day, but a download you paused last week will be deleted with -CleanPartial. Bump -PartialAgeHours if that's a habit.
  • Files in use are skipped, not forced. An installer that's still running or a PDF open in a viewer can't be moved. That file gets a warning and a Failed action, and everything else carries on. Run it again later and it'll pick up the stragglers.