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.
- 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.
<#
.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
| Parameter | Type | Default | What it's for |
|---|---|---|---|
-Path | string | $HOME\Downloads | The folder to sort. Only files sitting directly in it are touched; subfolders are left alone. |
-Destination | string | same as Path | Where the category folders are created. Point it somewhere else to sort into a separate archive. |
-RulePath | string | — | 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_*"). |
-OtherFolder | string | Other | Where files that match no rule end up. |
-SkipUnknown | switch | — | Leave files that match no rule where they are, instead of moving them to OtherFolder. |
-Exclude | string[] | desktop.ini, Thumbs.db, .DS_Store | Wildcard patterns for files to never move. Replacing the list replaces the defaults, so include them again if you want them. |
-CleanPartial | switch | — | Delete partial downloads (.part, .crdownload, .partial, .download, .opdownload) older than PartialAgeHours. |
-PartialAgeHours | int | 24 | How old a partial download has to be before -CleanPartial deletes it, so anything still downloading is safe. |
-RemoveEmptyFolders | switch | — | Remove empty subfolders afterwards, deepest first. Path itself is never removed. |
Run it
See the whole plan first. Nothing moves.
.\Move-FileByType.ps1 -WhatIfSort Downloads, clear stale partial downloads and tidy up empty folders.
.\Move-FileByType.ps1 -CleanPartial -RemoveEmptyFoldersSort 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 -SkipUnknownHow many files went where.
.\Move-FileByType.ps1 | Group-Object Category | Sort-Object Count -Descending | Select-Object Count, NameWhat you'll see
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
- 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. - Look at the top level only.
Get-ChildItem -Filewithout-Recurseor-Force, so hidden files, subfolders and anything in-Excludeare left alone. The script also skips itself if you happen to run it from the folder it's sorting. - 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,-WhatIfshows exactly the renames a real run would make. - 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.
- Deal with partial downloads. Without
-CleanPartialthey're left completely alone. With it, only ones older than-PartialAgeHoursare deleted. - Remove empty folders last. Deepest first, and a folder whose only contents were empty folders counts as empty too, so the
-WhatIflist matches what a real run removes.
Take it further
- Make it a habit. A weekly scheduled task running
-CleanPartial -RemoveEmptyFolderskeeps 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.