DEV Community

Cover image for PowerShell Move-Item: Sort a Messy Downloads Folder by File Type in One Script
arnostorg
arnostorg

Posted on

PowerShell Move-Item: Sort a Messy Downloads Folder by File Type in One Script

Every Downloads folder ends up the same way: 400 files, three of them named invoice (7).pdf, a dozen half-unzipped archives, and screenshots from a meeting nobody remembers. Dragging them into folders by hand takes twenty minutes. One Move-Item script does it in a second, and it can show you a full preview before it touches anything.

This post is that script, built up in small steps, plus the three Move-Item behaviours that bite people the first time they automate it.

The 10-second refresher

Move-Item (aliases move, mv, mi) moves a file or folder to a new location. If the destination is an existing folder, the item lands inside it with its name unchanged:

Move-Item -Path "$HOME\Downloads\report.pdf" -Destination "$HOME\Documents"
Enter fullscreen mode Exit fullscreen mode

Two switches make it safe to automate:

Switch What it does
-WhatIf Prints what would move and moves nothing
-PassThru Outputs the moved item at its new location, so you can log it

Full parameter list is in the official Move-Item documentation on Microsoft Learn.

Step 1: see what you are dealing with

Before sorting anything, group the files by extension so you know which folders you will get:

Get-ChildItem "$HOME\Downloads" -File |
    Group-Object Extension |
    Sort-Object Count -Descending |
    Select-Object Count, Name
Enter fullscreen mode Exit fullscreen mode

Typical output:

Count Name
----- ----
  142 .pdf
   61 .png
   38 .zip
   17 .xlsx
    9 .msi
    3
Enter fullscreen mode Exit fullscreen mode

That last row with no name is files that have no extension at all. The script needs to handle those too.

Step 2: the sort script, in dry-run mode

$src    = "$HOME\Downloads"
$dryRun = $true   # flip to $false when the preview looks right

$files = Get-ChildItem -Path $src -File

foreach ($file in $files) {
    $ext = $file.Extension.TrimStart('.').ToLower()
    if (-not $ext) { $ext = 'no-extension' }

    $dest = Join-Path $src $ext

    if (-not (Test-Path -LiteralPath $dest)) {
        New-Item -ItemType Directory -Path $dest -WhatIf:$dryRun | Out-Null
    }

    Move-Item -LiteralPath $file.FullName -Destination $dest -WhatIf:$dryRun
}
Enter fullscreen mode Exit fullscreen mode

Run it as is and you get one What if: line per file and nothing changes on disk. Read the list. When it looks right, set $dryRun = $false and run it again.

A few choices in there are deliberate:

  • $files = Get-ChildItem ... first, then loop. Collecting the list up front means the script works from a fixed snapshot instead of reading the folder while it is changing underneath it.
  • -WhatIf:$dryRun on New-Item too. If you only put -WhatIf on Move-Item, the dry run still creates a pile of empty pdf, png, zip folders. Passing the same switch to both keeps the preview truly side-effect free.
  • .ToLower(). Otherwise .PDF and .pdf would compete for the same folder name. Windows treats them as one folder, but the output looks messy and confusing.
  • -LiteralPath, not -Path. More on that in the gotchas below.

If you want the helpers explained in depth, I covered building folder paths with Join-Path and checking a path exists with Test-Path in earlier tips.

Step 3: do not lose invoice.pdf number two

Here is the first real-world failure. If pdf\invoice.pdf already exists and you move another invoice.pdf into the same folder, Move-Item stops with:

Move-Item: Cannot create a file when that file already exists.
Enter fullscreen mode Exit fullscreen mode

-Force makes the error go away by overwriting the existing file, which is almost never what you want in a Downloads cleanup. Instead, pick a free name the way File Explorer does:

$target = Join-Path $dest $file.Name
$n = 1
while (Test-Path -LiteralPath $target) {
    $target = Join-Path $dest ('{0} ({1}){2}' -f $file.BaseName, $n, $file.Extension)
    $n++
}

Move-Item -LiteralPath $file.FullName -Destination $target -WhatIf:$dryRun
Enter fullscreen mode Exit fullscreen mode

Swap that block in for the single Move-Item line from Step 2. Now the second invoice becomes invoice (1).pdf and nothing gets silently replaced.

Step 4: keep a log of what moved

When someone asks "where did my installer go?", you want an answer. -PassThru returns the item at its new location, so you can write it straight to a log file:

Move-Item -LiteralPath $file.FullName -Destination $target -PassThru |
    ForEach-Object { '{0}  {1}  ->  {2}' -f (Get-Date -Format s), $file.FullName, $_.FullName } |
    Add-Content -Path "$src\_moves.log"
Enter fullscreen mode Exit fullscreen mode

Run this only after the dry run, because nothing is passed through while -WhatIf is on. The log gives you a timestamped line for every move, which is also everything you need to undo the cleanup by hand.

The three Move-Item gotchas

1. A missing destination folder turns your move into a rename.

# C:\Reports\Archive does NOT exist yet
Get-ChildItem C:\Reports -File | Move-Item -Destination C:\Reports\Archive
Enter fullscreen mode Exit fullscreen mode

The first file is moved and renamed to a file called Archive with no extension. The second file then fails, because Archive now exists as a file. You end up with one mystery file and a screen of red errors. Always create the destination folder first, which is exactly why Step 2 runs Test-Path and New-Item before moving.

2. Square brackets in file names break -Path.

-Path treats [ and ] as wildcard characters, so this finds nothing:

Move-Item -Path "C:\Temp\report [final].pdf" -Destination C:\Archive
Enter fullscreen mode Exit fullscreen mode

Downloads are full of names like photo [1].jpg. Use -LiteralPath whenever the name comes from real files rather than a pattern you typed. Piping Get-ChildItem output into Move-Item is safe as well, because the pipeline binds the exact path for you.

3. Moving folders to another drive depends on your PowerShell version.

Moving a file from C: to D: works everywhere. Moving a folder across drives in Windows PowerShell 5.1 fails with Source and destination path must have identical roots. Current PowerShell 7 releases handle it by copying and then deleting the source. On a locked work laptop stuck on 5.1, use Copy-Item -Recurse, check the copy, and only then remove the original (with a dry run first, as in the Remove-Item -WhatIf tip).

Bonus: move by age instead of type

The same pattern archives old files from a reports folder. This one moves anything not touched in 30 days:

$archive = 'C:\Reports\Archive'
$cutoff  = (Get-Date).AddDays(-30)

if (-not (Test-Path -LiteralPath $archive)) {
    New-Item -ItemType Directory -Path $archive | Out-Null
}

Get-ChildItem C:\Reports -File |
    Where-Object LastWriteTime -lt $cutoff |
    Move-Item -Destination $archive -WhatIf
Enter fullscreen mode Exit fullscreen mode

Notice the folder gets created before the pipeline runs, so gotcha number one never gets a chance.

Practice the moves before you run them on real files

Typing these out on your actual Downloads folder for the first time is a bit nerve-wracking. In CMD Master's free interactive PowerShell lessons you can practise Move-Item, -WhatIf, and folder creation in a live browser terminal with instant feedback, with no install and nothing real to lose. The same hands-on Windows command line course covers CMD and Bash too, if you switch between shells at work.

Your turn: what does your Downloads folder look like right now? Run the Step 1 command and share your top extension in the comments. I am betting on .pdf.

Top comments (0)