How-to · Files

Organise Downloads safely without a cleanup utility

Sort a cluttered Downloads folder into category folders with PowerShell, without losing or overwriting anything: inventory the files, build and review a move plan, catch name collisions, confirm each move, keep a record and undo it if needed.

Reading time
8 min
Updated
Applies to
Windows 11, Windows 10
Privileges
Standard user; no elevation
Difficulty
Intermediate

Downloads becomes the place where everything lands: installers you ran once, invoices, photos, ZIP files, half-finished downloads. Cleaning it up is easy to do badly. A “cleanup” utility may decide on its own what to delete, a script copied from a forum may move everything with -Force and silently overwrite files with the same name, and dragging files around by hand leaves no record of where anything was.

This guide shows a careful manual method in PowerShell. You will sort the files directly inside Downloads into category folders (Documents, Images, Installers and so on) inside Downloads itself, with these guarantees: nothing moves until you have read the full plan, nothing is deleted or overwritten, every move is confirmed individually, and every move is recorded so you can put files back.

Nothing here needs administrator rights. The scripts were tested in PowerShell 7.6 on Windows 11 against a test folder with collisions, bracketed names, hidden files and unfinished downloads; they also run in Windows PowerShell 5.1.

What goes wrong with quick cleanups

Three habits cause most of the damage:

  • Overwriting. Move-Item -Force, move /Y in Command Prompt, robocopy /MOV and Explorer’s “Replace the file in the destination” all replace a file that already has the target name. The replaced file is gone; it does not go to the Recycle Bin.

  • Wildcards. In PowerShell the -Path parameter treats [ and ] as wildcard characters. Asked to move photo[1].jpg, Move-Item -Path actually picks up photo1.jpg if there is one, a different file. Tested:

    PS> Move-Item -Path '.\photo[1].jpg' -Destination '.\Images\' -WhatIf
    What if: Performing the operation "Move File" on target "Item: C:\Users\you\Downloads\photo1.jpg Destination: C:\Users\you\Downloads\Images\photo1.jpg".

    Every command in this guide uses -LiteralPath, which takes names exactly as written.

  • No record and no review. A script that moves and deletes in one go cannot be checked before it runs and cannot be reversed afterwards. Recursive deletes (Remove-Item -Recurse -Force) and “delete files older than 30 days” rules belong to the same family: they act on files nobody looked at.

Step 1: Find the folder and take an inventory

Downloads is not always C:\Users\you\Downloads; it can be moved to another drive. Ask Windows where it is, then look at what is there. These commands only read:

$folder = (New-Object -ComObject Shell.Application).NameSpace('shell:Downloads').Self.Path
$folder

Get-ChildItem -LiteralPath $folder -File -Force |
  Group-Object { $_.Extension.ToLowerInvariant() } |
  Sort-Object Count -Descending |
  Select-Object Count, Name,
    @{ Name = 'MB'; Expression = { [math]::Round(($_.Group | Measure-Object Length -Sum).Sum / 1MB, 1) } }
Count Name           MB
----- ----           --
  214 .pdf        187.3
  133 .jpg        402.6
   58 .exe       2210.4
   41 .zip        981.0
   12 .crdownload   3.1
    9 .xyz          0.2

The counts above are an example; yours will differ. -File lists files only, not subfolders; -Force includes hidden and system files so you see everything that is really there. The list shows which categories matter for you, and which extensions you do not recognise.

Also note how many files there are in total; you will compare against it at the end:

(Get-ChildItem -LiteralPath $folder -File -Force).Count

Step 2: Decide the categories

Write the rules down as data rather than burying them in a script. Adjust the lists to what your inventory showed:

$rules = [ordered]@{
  Documents  = '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.odt', '.ods', '.rtf', '.txt', '.csv', '.epub'
  Images     = '.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.bmp', '.tif', '.tiff', '.svg'
  Videos     = '.mp4', '.mkv', '.mov', '.avi', '.wmv', '.webm'
  Audio      = '.mp3', '.wav', '.flac', '.m4a', '.aac', '.ogg'
  Archives   = '.zip', '.7z', '.rar', '.tar', '.gz', '.iso'
  Installers = '.exe', '.msi', '.msix', '.appx'
}
$categoryOf = @{}
foreach ($category in $rules.Keys) {
  foreach ($extension in $rules[$category]) { $categoryOf[$extension] = $category }
}

Some deliberate choices:

  • Scripts and shortcuts (.ps1, .bat, .cmd, .js, .vbs, .lnk, .url) are not in any list. Filing them under Documents would make them look like something they are not. They stay where they are.
  • Only the last extension counts. invoice.pdf.exe is an .exe and goes to Installers, never Documents. If you are not sure what a file is, the guide Inspect a suspicious file with built-in Windows tools shows how to check without opening it.
  • Classification by extension does not look inside files. A file whose name does not match its content is filed by its name.

Step 3: Build the plan, including collisions

The plan lists every file, where it would go, and whether it is ready to move. Building it changes nothing:

$leaveAlone = '.crdownload', '.part', '.partial', '.download', '.tmp', '.temp'

$plan = foreach ($file in Get-ChildItem -LiteralPath $folder -File -Force) {
  $extension = $file.Extension.ToLowerInvariant()
  $category = $categoryOf[$extension]
  $destination = if ($category) { [IO.Path]::Combine($folder, $category, $file.Name) }

  $status, $reason = if ($file.Attributes -band [IO.FileAttributes]::ReparsePoint) {
    'Skip', 'link or online-only cloud file'
  } elseif ($file.Attributes -band ([IO.FileAttributes]::Hidden -bor [IO.FileAttributes]::System)) {
    'Skip', 'hidden or system file'
  } elseif ($file.Name.StartsWith('~$') -or $extension -in $leaveAlone) {
    'Skip', 'unfinished download or temporary file'
  } elseif (-not $category) {
    'Skip', "no rule for '$extension'"
  } elseif (Test-Path -LiteralPath $destination) {
    'Collision', "$category already has a file named $($file.Name)"
  } else {
    'Ready', ''
  }

  [pscustomobject]@{
    Status       = $status
    Name         = $file.Name
    Category     = $category
    Source       = $file.FullName
    Destination  = $destination
    Reason       = $reason
    Length       = $file.Length
    LastWriteUtc = $file.LastWriteTimeUtc.ToString('o')
  }
}

What each rule protects:

  • Links and cloud placeholders (ReparsePoint) are left alone: moving a link moves the link, not what it points to, and an online-only OneDrive file has no local content.
  • Hidden and system files such as desktop.ini belong to Windows or to another program.
  • Unfinished downloads (.crdownload from Chrome and Edge, .part from Firefox) are still being written. Moving one breaks the download. Office’s ~$ owner files belong to documents that are open.
  • Unrecognised extensions stay put until you add a rule for them.
  • Collisions: if Images already contains a holiday.jpg, the new holiday.jpg is not moved. You decide later whether to rename one of them.

Length and LastWriteUtc are recorded so that you can tell later whether a file changed after you reviewed the plan.

Step 4: Review the plan and record it

Read the plan before anything else happens:

$plan | Sort-Object Status, Category, Name | Format-Table Status, Name, Category, Reason -AutoSize
$plan | Group-Object Status | Select-Object Count, Name
Status    Name               Category   Reason
------    ----               --------   ------
Collision holiday.jpg        Images     Images already has a file named holiday.jpg
Ready     Invoice 2026.pdf   Documents
Ready     photo[1].jpg       Images
Ready     photo1.jpg         Images
Ready     setup.exe          Installers
Skip      big.iso.crdownload            unfinished download or temporary file
Skip      desktop.ini                   hidden or system file
Skip      notes.xyz                     no rule for '.xyz'

Count Name
----- ----
    1 Collision
    4 Ready
    3 Skip

If a category or a reason surprises you, change the rules in Step 2 and build the plan again. When you are happy, save the plan outside Downloads, so the record does not become one of the files being organised. This file is your record of every original path and its intended destination, written before anything moves:

$records = Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'Downloads reorganisation'
New-Item -ItemType Directory -Path $records -Force | Out-Null
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$planFile = Join-Path $records "downloads-plan-$stamp.csv"
$plan | Export-Csv -LiteralPath $planFile -NoTypeInformation -Encoding utf8
$planFile

You can open the CSV in Excel or Notepad. If you want to hold back particular files, delete their rows from the CSV and save it; the next steps read the plan from this file, not from memory.

Step 5: Preview the moves

-WhatIf makes Move-Item print what it would do and do nothing:

Import-Csv -LiteralPath $planFile | Where-Object Status -eq 'Ready' | ForEach-Object {
  Move-Item -LiteralPath $_.Source -Destination $_.Destination -WhatIf
}
What if: Performing the operation "Move File" on target "Item: C:\Users\you\Downloads\Invoice 2026.pdf Destination: C:\Users\you\Downloads\Documents\Invoice 2026.pdf".
What if: Performing the operation "Move File" on target "Item: C:\Users\you\Downloads\photo[1].jpg Destination: C:\Users\you\Downloads\Images\photo[1].jpg".

Note photo[1].jpg in the output: with -LiteralPath the bracketed name is taken exactly. -WhatIf is a preview, not a check. It does not test whether the destination folder exists or whether the name is free at the moment of the move; Step 6 does that for each file.

Step 6: Move the files, one confirmed move at a time

Warning

This step changes your files. Each file is moved only after you answer Y at its prompt, and nothing is overwritten or deleted, but read every prompt. Pressing Enter on its own answers Y, because that is PowerShell’s default. Answer N to keep a file where it is. Do not run this step while a download or a program is still writing into Downloads.

$journal = Join-Path $records "downloads-moves-$stamp.csv"

foreach ($move in Import-Csv -LiteralPath $planFile | Where-Object Status -eq 'Ready') {
  $result = 'Not moved'
  $detail = ''
  try {
    $current = Get-Item -LiteralPath $move.Source -Force -ErrorAction Stop
    if ($current.Length -ne [long]$move.Length -or
        $current.LastWriteTimeUtc.ToString('o') -ne $move.LastWriteUtc) {
      throw 'The file changed after the plan was made.'
    }
    if (Test-Path -LiteralPath $move.Destination) {
      throw 'The destination name is taken now.'
    }
    $targetFolder = Split-Path -Parent $move.Destination
    if (-not (Test-Path -LiteralPath $targetFolder)) {
      New-Item -ItemType Directory -Path $targetFolder -ErrorAction Stop | Out-Null
    }
    Move-Item -LiteralPath $move.Source -Destination $move.Destination -Confirm -ErrorAction Stop
    if ((Test-Path -LiteralPath $move.Destination) -and -not (Test-Path -LiteralPath $move.Source)) {
      $result = 'Moved'
    } else {
      $detail = 'Declined at the prompt.'
    }
  } catch {
    $detail = "$_"
  }
  [pscustomobject]@{
    Time        = (Get-Date).ToString('o')
    Result      = $result
    Source      = $move.Source
    Destination = $move.Destination
    Detail      = $detail
  } | Export-Csv -LiteralPath $journal -Append -NoTypeInformation -Encoding utf8
}

For each file PowerShell asks:

Confirm
Are you sure you want to perform this action?
Performing the operation "Move File" on target "Item: C:\Users\you\Downloads\setup.exe Destination: C:\Users\you\Downloads\Installers\setup.exe".
[Y] Yes  [A] Yes to All  [N] No  [L] No to All  [S] Suspend  [?] Help (default is "Y"):

What the loop does for each ready file, in order:

  1. Checks that the file still has the size and modification time recorded in the plan. A file that changed since you reviewed it is not moved.
  2. Checks that the destination name is still free, because something may have appeared there since Step 3.
  3. Creates the category folder if it does not exist yet.
  4. Moves the file only after your answer. Move-Item without -Force also refuses on its own to replace an existing file, so even a name taken in the last millisecond cannot be overwritten; the error “Cannot create a file when that file already exists” is recorded instead.
  5. Writes one line to the journal with the result: Moved, or Not moved with the reason.

With hundreds of files, work in batches. Add a condition to the Where-Object filter, for example Where-Object { $_.Status -eq 'Ready' -and $_.Category -eq 'Installers' }, and run the loop once per category. Every run appends to the same journal.

Common errors in the journal’s Detail column:

Detail Meaning
Declined at the prompt. You answered N. The file is where it was.
The file changed after the plan was made. Build a new plan (Step 3) and review it again.
The destination name is taken now. Something with that name appeared in the category folder after planning.
Cannot find path ... because it does not exist. The file was moved, renamed or deleted after planning.
The process cannot access the file because it is being used ... A program has the file open. Close it and run the step again for the remaining files.
Access to the path ... is denied. Your account cannot move this file; leave it.

Step 7: Verify the result

Every file the journal records as moved must be at its destination and gone from its source:

$journalRows = Import-Csv -LiteralPath $journal
foreach ($row in $journalRows | Where-Object Result -eq 'Moved') {
  $ok = (Test-Path -LiteralPath $row.Destination) -and -not (Test-Path -LiteralPath $row.Source)
  '{0,-5} {1}' -f $(if ($ok) { 'OK' } else { 'CHECK' }), $row.Destination
}

Then check that no file went missing: the files still at the top of Downloads plus the files moved must equal the number in the plan.

$planned = @(Import-Csv -LiteralPath $planFile).Count
$remaining = @(Get-ChildItem -LiteralPath $folder -File -Force).Count
$moved = @($journalRows | Where-Object Result -eq 'Moved').Count
"Planned: $planned  Still in Downloads: $remaining  Moved: $moved  Missing: $($planned - $remaining - $moved)"

Missing: 0 and no CHECK lines mean every file is accounted for. A non-zero number usually means a file was deleted or added by another program while you worked (a browser finishing a download, for example); look at the plan and the journal for the name.

Step 8: Undo, if you need to

The journal is enough to put files back. This moves each recorded file back to its original path, newest first, and refuses to overwrite anything:

Warning

This step moves files again. It asks for confirmation for each file and never replaces an existing one; a file whose original name has been taken again, or that is no longer where it was moved, is reported and left alone.

$moved = @(Import-Csv -LiteralPath $journal | Where-Object Result -eq 'Moved')
[array]::Reverse($moved)
foreach ($row in $moved) {
  if (Test-Path -LiteralPath $row.Source) {
    "Skipped, the original name is taken: $($row.Source)"
  } elseif (-not (Test-Path -LiteralPath $row.Destination)) {
    "Skipped, no longer where it was moved: $($row.Destination)"
  } else {
    Move-Item -LiteralPath $row.Destination -Destination $row.Source -Confirm
  }
}

The category folders stay behind, empty or with the files that were already in them. Delete empty ones in File Explorer once you have checked them. Keep the plan and journal files until you are sure you will not need to reverse anything.

This undo is only as good as the journal: if you rename, edit or move files after organising, the “Skipped” lines tell you which ones the journal can no longer handle. Running the same check on each file’s size and time before moving it back, as Step 6 does, makes it stricter.

Tidy: the same workflow, with the bookkeeping built in

The method above is safe because of its discipline: read-only planning, a written plan, collision checks, individually confirmed moves, a journal and an undo that refuses to overwrite. It is also a lot of script to keep correct. Tidy is a Utility Forge tool built around exactly that workflow, not a “cleaner” that decides for you.

Tidy scans one folder, classifies files by extension into the same kind of category folders, and shows the complete plan: what will move, what needs attention (a name already taken, a file in use) and what is left alone, with reasons. It applies only the plan you approved, and if the folder changed in the meantime it shows the new plan instead. Each file is checked again just before it moves and renamed with a call the file system refuses if the name is taken, every move is written to a private journal before and after it happens, and History can move files back while they are unchanged and their original names are free. It never deletes, copies or overwrites a file, and it refuses locations where it could not keep those promises, such as network shares and OneDrive folders.

Tidy is in development: the engine, command line and desktop app are implemented and tested internally, and there is no public download yet. The Tidy page tracks its status and explains where its guarantees stop.

FAQ

Frequently asked

Why not just select everything in File Explorer and drag it into folders?

For a dozen files that is fine. For hundreds it is easy to drop a selection in the wrong folder, and when a name already exists Explorer asks whether to replace it, skip it or keep both; a quick "Replace" destroys the older file. There is also no record afterwards of where each file came from, so the change cannot be reversed reliably.

Is Move-Item -Force a good idea to get past errors?

No. When the destination name already exists, Move-Item refuses with "Cannot create a file when that file already exists." That error is the protection. -Force makes Move-Item replace the existing file, which deletes it. Treat a collision as a decision for you, not an error to silence.

Does moving a file inside the same drive copy it?

No. Within one NTFS volume a move is a rename: the content stays where it is on disk and only the directory entry changes, so it is quick and the file's content, size and modification time stay the same. Moving to another drive is a copy followed by a delete, which this guide avoids by keeping the category folders inside Downloads.

Can I organise my OneDrive or a USB stick the same way?

The commands work, but be careful. Moving files inside OneDrive is synchronised to every device, and online-only files have no local content (the plan in this guide leaves them alone). FAT32 and exFAT drives, which most USB sticks use, cannot store some file metadata and give less protection against mistakes. Start with a local folder.