SCRIPT LIBRARY · POWERSHELL
Zip Up Folders Nobody Has Touched in Months
Find project folders that haven't changed in N days, zip each one to an archive location with the date of its last change, verify it, and optionally clear out the original.
- What it does
- Checks each folder under a parent path, finds the newest file inside it, and zips any folder that's been idle longer than -OlderThanDays. The ZIP's file count is verified before -RemoveSource is allowed to delete anything.
- Requires
- PowerShell 7+ or Windows PowerShell 5.1
- No modules (uses .NET's System.IO.Compression)
- Permissions
- Read access to the source folders, write access to the destination, and delete rights on the sources if you use -RemoveSource.
- Runs on
- Windows 10/11, Windows Server 2016+
- Tested
- Parse-checked and run against test folders (old, recent, empty, with hidden files) in PowerShell 7.4, including -WhatIf, -RemoveSource and a re-run over existing ZIPs
Every busy drive has a Projects folder where half the subfolders haven't been opened since the job wrapped up. They're not trash, so nobody deletes them, but they're taking up space on fast storage and cluttering the view for everyone who's still working.
This script takes care of the boring part. It looks inside each subfolder, finds the most recent file anywhere in it, and if nothing's changed for, say, six months, it zips the folder off to an archive location. The ZIP is named after the folder and the date of its last change, like Kitchen-Remodel_2025-03-14.zip, so you can see at a glance how stale it was.
The original version of this post copied one hard-coded folder to a dated backup folder. Useful, but it wasn't really archiving; nothing got smaller and nothing got cleaned up. This one works on any number of folders, checks the ZIP before it trusts it, and only deletes originals when you tell it to.
<#
.SYNOPSIS
Zips up folders that haven't been touched in N days, and optionally removes the originals.
.DESCRIPTION
Looks at each subfolder of -Path and finds the newest file anywhere inside it. If nothing
in the folder has changed for -OlderThanDays, the folder is zipped to -Destination as
<FolderName>_<yyyy-MM-dd>.zip (the date of the last change). The ZIP is checked against
the folder's file count before -RemoveSource is allowed to delete anything. Empty folders
are skipped.
Supports -WhatIf.
.PARAMETER Path
The parent folder. Each folder directly under it is considered separately.
.PARAMETER Destination
Where the ZIP files go. Created if it doesn't exist.
.PARAMETER OlderThanDays
Archive a folder only if its newest file is older than this. Default 180.
.PARAMETER RemoveSource
Delete each folder after its ZIP has been written and verified.
.PARAMETER Force
Overwrite a ZIP with the same name that's already in the destination.
.EXAMPLE
.\Compress-OldFolder.ps1 -Path D:\Projects -Destination E:\Archive\Projects -WhatIf
.EXAMPLE
.\Compress-OldFolder.ps1 -Path D:\Projects -Destination E:\Archive\Projects -OlderThanDays 365 -RemoveSource
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)][ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })][string]$Path,
[Parameter(Mandatory)][string]$Destination,
[ValidateRange(1, 36500)][int]$OlderThanDays = 180,
[switch]$RemoveSource,
[switch]$Force
)
Add-Type -AssemblyName System.IO.Compression, System.IO.Compression.FileSystem
$cutoff = (Get-Date).AddDays(-$OlderThanDays)
$Destination = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Destination)
foreach ($folder in Get-ChildItem -LiteralPath $Path -Directory) {
$files = @(Get-ChildItem -LiteralPath $folder.FullName -File -Recurse -Force -ErrorAction SilentlyContinue)
if (-not $files) {
Write-Verbose "$($folder.Name): no files inside, skipped."
continue
}
$newest = ($files | Sort-Object LastWriteTime -Descending | Select-Object -First 1).LastWriteTime
if ($newest -ge $cutoff) {
Write-Verbose "$($folder.Name): last change $($newest.ToString('yyyy-MM-dd')), too recent."
continue
}
$zipPath = Join-Path $Destination ('{0}_{1:yyyy-MM-dd}.zip' -f $folder.Name, $newest)
$result = [ordered]@{ Folder = $folder.Name; LastChange = $newest.Date; Files = $files.Count; SizeMB = [math]::Round(($files | Measure-Object Length -Sum).Sum / 1MB, 1); ZipMB = $null; Status = $null; Zip = $zipPath }
if ((Test-Path -LiteralPath $zipPath) -and -not $Force) {
$result.Status = 'SkippedExists'
[pscustomobject]$result
continue
}
if (-not $PSCmdlet.ShouldProcess($folder.FullName, "Archive to $zipPath")) { continue }
$partial = "$zipPath.partial"
try {
if (-not (Test-Path -LiteralPath $Destination)) { $null = New-Item -ItemType Directory -Path $Destination }
if (Test-Path -LiteralPath $partial) { Remove-Item -LiteralPath $partial -Force }
# CreateFromDirectory includes hidden files and handles big archives, unlike Compress-Archive in 5.1.
[System.IO.Compression.ZipFile]::CreateFromDirectory($folder.FullName, $partial, [System.IO.Compression.CompressionLevel]::Optimal, $true)
$zip = [System.IO.Compression.ZipFile]::OpenRead($partial)
try { $zipped = @($zip.Entries | Where-Object { $_.Name }).Count }
finally { $zip.Dispose() }
if ($zipped -ne $files.Count) { throw "ZIP holds $zipped files but the folder has $($files.Count)." }
Move-Item -LiteralPath $partial -Destination $zipPath -Force -WhatIf:$false
$result.ZipMB = [math]::Round((Get-Item -LiteralPath $zipPath).Length / 1MB, 1)
$result.Status = 'Archived'
if ($RemoveSource -and $PSCmdlet.ShouldProcess($folder.FullName, 'Delete folder')) {
Remove-Item -LiteralPath $folder.FullName -Recurse -Force
$result.Status = 'ArchivedRemoved'
}
}
catch {
Remove-Item -LiteralPath $partial -Force -ErrorAction SilentlyContinue -WhatIf:$false
$result.Status = "Failed: $($_.Exception.Message)"
Write-Warning "$($folder.Name): $($_.Exception.Message)"
}
[pscustomobject]$result
}
Parameters
| Parameter | Type | Default | What it's for |
|---|---|---|---|
-Path | string | — | The parent folder. Each folder directly under it is judged on its own. |
-Destination | string | — | Where the ZIP files go. Created if it doesn't exist. Keep it outside -Path. |
-OlderThanDays | int | 180 | How long a folder has to sit untouched before it's archived. |
-RemoveSource | switch | — | Delete each folder after its ZIP has been written and verified. Honors -WhatIf. |
-Force | switch | — | Overwrite a ZIP with the same name in the destination. |
Run it
See which folders qualify, and what the ZIPs would be called.
.\Compress-OldFolder.ps1 -Path D:\Projects -Destination E:\Archive\Projects -WhatIfArchive anything idle for a year and clear out the originals.
.\Compress-OldFolder.ps1 -Path D:\Projects -Destination E:\Archive\Projects -OlderThanDays 365 -RemoveSourceArchive to a file server and keep a log of what happened.
.\Compress-OldFolder.ps1 -Path D:\Projects -Destination \\fs01\Archive\Projects | Export-Csv .\archive-log.csv -Append -NoTypeInformationSee why a folder wasn't picked up.
.\Compress-OldFolder.ps1 -Path D:\Projects -Destination E:\Archive\Projects -Verbose -WhatIfWhat you'll see
Folder LastChange Files SizeMB ZipMB Status Zip
------ ---------- ----- ------ ----- ------ ---
Kitchen-Remodel 2025-03-14 412 880.4 612.9 ArchivedRemoved E:\Archive\Projects\Kitchen-Remodel_2025-03-14.zip
Website-2024 2024-11-02 2210 145.2 38.7 ArchivedRemoved E:\Archive\Projects\Website-2024_2024-11-02.zip
Budget-Model 2025-01-20 18 4.1 SkippedExists E:\Archive\Projects\Budget-Model_2025-01-20.zip
Old-Scans 2024-08-30 96 2048.0 Failed: The process cannot access the file ... because it is being used by another process.
How it works
- Look at each subfolder. Only folders directly under
-Pathare candidates. Each is judged by the newestLastWriteTimeof any file inside it, hidden files included. Empty folders are skipped. - Compare with the cutoff. Anything newer than
-OlderThanDaysis left alone (run with-Verboseto see why). - Build the ZIP under a temporary name. It's written as
name.zip.partialwithZipFile.CreateFromDirectory, so a crash halfway through never leaves a ZIP that looks finished. - Verify it. The script opens the new ZIP, counts the file entries, and compares that with the number of files in the folder. A mismatch counts as a failure and the partial file is removed.
- Rename, then maybe delete. The verified ZIP gets its real name. With
-RemoveSource, the folder is deleted throughShouldProcess, so-WhatIfshows every delete first.
Take it further
- Schedule it monthly. Run it as a scheduled task with a service account that has rights to both locations, and append the output to a CSV so there's a trail.
- Use 7-Zip for better compression. If the folders are mostly text, swapping the ZipFile call for a
7z a -t7zcall will shrink them a lot more. The archive conversion series has the 7-Zip plumbing you'd need. - Leave a breadcrumb. Drop a small text file where the folder used to be, pointing at the ZIP, so nobody panics when their old project "disappears."
Things that'll trip you up
- A folder is only as old as its newest file. One person saving one file anywhere in the tree resets the clock for the whole folder. That's usually what you want, but it can make a folder look busier than it really is.
- Locked files fail the whole folder. If someone has a file open, the ZIP can't be built and that folder is reported as Failed. Nothing is deleted. Run it again after hours.
- Don't put -Destination inside -Path. The archive folder would become one of the folders being judged. Keep them on separate paths, ideally separate drives.
- Why not Compress-Archive? In Windows PowerShell 5.1 it skips hidden files and struggles with archives over 2 GB. The .NET ZipFile class the script uses doesn't have either problem and works the same on 5.1 and 7.
- NTFS permissions don't come along. A ZIP keeps file names and modified dates, not ACLs. If the folder had special permissions, lock down the archive location instead, or use a tool that keeps ACLs if you need them back exactly.