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

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.

AT A GLANCECompress-OldFolder.ps1
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

Part 2 of the thread File wrangling

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.

Compress-OldFolder.ps1Download
<#
.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

ParameterTypeDefaultWhat it's for
-Pathstring—The parent folder. Each folder directly under it is judged on its own.
-Destinationstring—Where the ZIP files go. Created if it doesn't exist. Keep it outside -Path.
-OlderThanDaysint180How long a folder has to sit untouched before it's archived.
-RemoveSourceswitch—Delete each folder after its ZIP has been written and verified. Honors -WhatIf.
-Forceswitch—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 -WhatIf

Archive anything idle for a year and clear out the originals.

.\Compress-OldFolder.ps1 -Path D:\Projects -Destination E:\Archive\Projects -OlderThanDays 365 -RemoveSource

Archive 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 -NoTypeInformation

See why a folder wasn't picked up.

.\Compress-OldFolder.ps1 -Path D:\Projects -Destination E:\Archive\Projects -Verbose -WhatIf

What you'll see

Example outputvalues are illustrative
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

  1. Look at each subfolder. Only folders directly under -Path are candidates. Each is judged by the newest LastWriteTime of any file inside it, hidden files included. Empty folders are skipped.
  2. Compare with the cutoff. Anything newer than -OlderThanDays is left alone (run with -Verbose to see why).
  3. Build the ZIP under a temporary name. It's written as name.zip.partial with ZipFile.CreateFromDirectory, so a crash halfway through never leaves a ZIP that looks finished.
  4. 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.
  5. Rename, then maybe delete. The verified ZIP gets its real name. With -RemoveSource, the folder is deleted through ShouldProcess, so -WhatIf shows 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 -t7z call 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.