SCRIPT LIBRARY · POWERSHELL
Convert ZIP Files to 7z, but Only When It Actually Saves Space
Repack ZIPs as 7z, see exactly how much space each one saved, and keep the ZIP when the 7z wouldn't be any smaller.
- What it does
- Extracts each ZIP, builds a .7z from the contents, tests it, and reports the size before and after. If the 7z isn't smaller, it's discarded and the ZIP stays.
- Requires
- PowerShell 7+ or Windows PowerShell 5.1
- 7-Zip (7z.exe)
- Permissions
- Read access to the ZIPs and write access to the destination. No admin rights.
- Runs on
- Windows 10/11, Windows Server 2016+
- Tested
- Parse-checked and run against real compressible, incompressible and corrupt ZIPs with 7-Zip 23.01 in PowerShell 7.4, including -WhatIf, -KeepLarger and -RemoveSource
If you've got a pile of old ZIP files eating an archive drive, repacking them as 7z can win back a surprising amount of space. Text, logs, CSV exports, source code, VM configs: LZMA2 chews through that stuff far better than Deflate does.
Photos, videos, and anything already compressed? Not so much. Repack a ZIP full of JPEGs and you'll spend ten minutes of CPU time to save 0.4 percent, or end up with a slightly bigger file. So this script, part three of the archive series, measures every result. If the 7z isn't smaller than the ZIP it came from, the 7z gets thrown away and the ZIP stays put. You get a report that tells you what you actually saved.
The original version of this post deleted every ZIP as soon as the 7z command finished, whether it had worked or not, and dropped the output next to the script instead of next to the ZIP. This one tests before it deletes, and only deletes when you pass -RemoveSource. (Need to go the other direction? That's part two. Got a folder of mixed formats? Part one.)
<#
.SYNOPSIS
Repacks ZIP archives as 7z and tells you how much space it actually saved.
.DESCRIPTION
Extracts each ZIP to a private temp folder, builds a .7z from the contents, tests the new
archive with 7-Zip, and only then moves it into place. If the 7z isn't smaller than the ZIP,
it's thrown away and the ZIP is kept (unless you say otherwise). The original ZIP is only
deleted when you ask for it with -RemoveSource. Supports -WhatIf.
.PARAMETER Path
ZIP files, or folders to search for ZIP files. Accepts pipeline input.
.PARAMETER Recurse
Search subfolders when Path is a folder.
.PARAMETER Destination
Folder for the new .7z files. Defaults to the same folder as each ZIP.
.PARAMETER CompressionLevel
7-Zip -mx level: 1 (fastest) to 9 (smallest). Default 9.
.PARAMETER KeepLarger
Keep the .7z even when it's the same size or bigger than the ZIP.
.PARAMETER RemoveSource
Delete each ZIP after its .7z has been built and tested.
.PARAMETER Force
Overwrite a .7z that already exists at the destination.
.PARAMETER SevenZipPath
Full path to 7z.exe. If omitted, the script checks PATH, then the usual Program Files folders.
.EXAMPLE
.\Convert-ZipTo7z.ps1 -Path D:\Downloads -WhatIf
.EXAMPLE
.\Convert-ZipTo7z.ps1 -Path D:\Archive -Recurse -RemoveSource
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
[Alias('FullName')]
[string[]]$Path = '.',
[switch]$Recurse,
[string]$Destination,
[ValidateSet(1, 3, 5, 7, 9)][int]$CompressionLevel = 9,
[switch]$KeepLarger,
[switch]$RemoveSource,
[switch]$Force,
[string]$SevenZipPath
)
begin {
if (-not $SevenZipPath) {
$SevenZipPath = @(
Get-Command -Name 7z.exe, 7z -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
if ($env:ProgramFiles) { Join-Path $env:ProgramFiles '7-Zip\7z.exe' }
if (${env:ProgramFiles(x86)}) { Join-Path ${env:ProgramFiles(x86)} '7-Zip\7z.exe' }
) | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -First 1
}
if (-not $SevenZipPath -or -not (Test-Path -LiteralPath $SevenZipPath)) {
throw '7-Zip not found. Install it from 7-zip.org or pass -SevenZipPath.'
}
Write-Verbose "Using 7-Zip at $SevenZipPath"
function Invoke-SevenZip([string[]]$Arguments) {
$out = & $SevenZipPath @Arguments 2>&1
if ($LASTEXITCODE -ge 2) {
$errText = $out | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] } | ForEach-Object { "$_".Trim() } | Where-Object { $_ -match '\w' -and $_ -notmatch 'RemoteException|^ERRORS:$' -and $_ -ne $Arguments[-1] }
throw "7-Zip exit code ${LASTEXITCODE}: $(($errText | Select-Object -Unique) -join '; ')"
}
if ($LASTEXITCODE -eq 1) { Write-Warning "7-Zip reported warnings for $($Arguments[-1])" }
}
}
process {
$zips = foreach ($item in $Path) {
if (Test-Path -LiteralPath $item -PathType Container) {
Get-ChildItem -LiteralPath $item -Filter *.zip -File -Recurse:$Recurse
}
else { Get-Item -LiteralPath $item }
}
foreach ($zip in $zips) {
$outDir = if ($Destination) { $Destination } else { $zip.DirectoryName }
$target = Join-Path $outDir ($zip.BaseName + '.7z')
$result = [ordered]@{ Name = $zip.Name; ZipKB = [math]::Round($zip.Length / 1KB); SevenZipKB = $null; SavedPercent = $null; Status = $null; Target = $target }
if ((Test-Path -LiteralPath $target) -and -not $Force) {
$result.Status = 'SkippedExists'
[pscustomobject]$result
continue
}
if (-not $PSCmdlet.ShouldProcess($zip.FullName, "Repack as $target")) { continue }
$work = Join-Path ([IO.Path]::GetTempPath()) ('zip2-7z-' + [guid]::NewGuid().ToString('N'))
try {
$files = Join-Path $work 'files'
$null = New-Item -ItemType Directory -Path $files -Force
Invoke-SevenZip @('x', '-y', '-bd', "-o$files", $zip.FullName)
if (-not (Get-ChildItem -LiteralPath $files -Force)) { throw 'ZIP is empty.' }
$temp7z = Join-Path $work 'out.7z'
Invoke-SevenZip @('a', '-t7z', "-mx=$CompressionLevel", '-y', '-bd', $temp7z, (Join-Path $files '*'))
Invoke-SevenZip @('t', '-bd', $temp7z)
$newSize = (Get-Item -LiteralPath $temp7z).Length
$result.SevenZipKB = [math]::Round($newSize / 1KB)
$result.SavedPercent = if ($zip.Length) { [math]::Round((1 - $newSize / $zip.Length) * 100, 1) } else { 0 }
if ($newSize -ge $zip.Length -and -not $KeepLarger) {
$result.Status = 'NoGainKeptZip'
}
else {
if (-not (Test-Path -LiteralPath $outDir)) { $null = New-Item -ItemType Directory -Path $outDir }
Move-Item -LiteralPath $temp7z -Destination $target -Force -WhatIf:$false
$result.Status = 'Converted'
if ($RemoveSource -and $PSCmdlet.ShouldProcess($zip.FullName, 'Delete original ZIP')) {
Remove-Item -LiteralPath $zip.FullName -Force
$result.Status = 'ConvertedRemovedZip'
}
}
}
catch {
$result.Status = "Failed: $($_.Exception.Message)"
Write-Warning "$($zip.Name): $($_.Exception.Message)"
}
finally {
Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue -WhatIf:$false
}
[pscustomobject]$result
}
}
Parameters
| Parameter | Type | Default | What it's for |
|---|---|---|---|
-Path | string[] | . | ZIP files, or folders to search for them. Takes pipeline input. |
-Recurse | switch | — | Search subfolders when Path is a folder. |
-Destination | string | — | Where the .7z files go. Defaults to next to each ZIP. |
-CompressionLevel | int | 9 | 7-Zip's -mx level, 1 to 9. 9 is slowest and smallest; 5 is a good compromise on a busy box. |
-KeepLarger | switch | — | Keep the .7z even if it's no smaller than the ZIP. Handy when you want one format everywhere regardless. |
-RemoveSource | switch | — | Delete each ZIP once its .7z has been built, tested and kept. Honors -WhatIf. |
-Force | switch | — | Overwrite a .7z that already exists at the destination. |
-SevenZipPath | string | — | Full path to 7z.exe. Leave it off and the script checks PATH, then Program Files and Program Files (x86). |
Run it
See what it would do. No files are written.
.\Convert-ZipTo7z.ps1 -Path E:\Archive -Recurse -WhatIfConvert, keep only the wins, and remove those ZIPs.
.\Convert-ZipTo7z.ps1 -Path E:\Archive -Recurse -RemoveSourceTotal up what you saved.
.\Convert-ZipTo7z.ps1 -Path E:\Archive -Recurse | Where-Object Status -like 'Converted*' | Measure-Object ZipKB, SevenZipKB -SumGo easy on the CPU during business hours.
.\Convert-ZipTo7z.ps1 -Path E:\Archive -CompressionLevel 5What you'll see
Name ZipKB SevenZipKB SavedPercent Status Target
---- ----- ---------- ------------ ------ ------
iis-logs-2024.zip 184320 21870 88.1 ConvertedRemovedZip E:\Archive\iis-logs-2024.7z
crm-export.zip 40960 9214 77.5 ConvertedRemovedZip E:\Archive\crm-export.7z
event-photos.zip 512000 511488 0.1 ConvertedRemovedZip E:\Archive\event-photos.7z
training-video.zip 901120 903300 -0.2 NoGainKeptZip E:\Archive\training-video.7z
old-sources.zip 2048 SkippedExists E:\Archive\old-sources.7z
How it works
- Find 7-Zip.
-SevenZipPathfirst, then7zon PATH, then the Program Files folders. - Skip what's already done. If a .7z with the same name already exists, the ZIP is reported as
SkippedExistsunless you pass-Force. - Extract, repack, test. Each ZIP goes into its own temp folder, gets repacked with
7z a -t7z -mx=<level>, and the new file is checked with7z t. - Measure. The script compares the new size with the ZIP's size and works out the percentage saved. If there's no saving (and you didn't pass
-KeepLarger), the 7z is discarded and the status saysNoGainKeptZip. - Swap in, then maybe delete. A keeper is moved to its real name, and
-RemoveSourcedeletes the ZIP throughShouldProcess, so-WhatIfshows every delete before it happens. The temp folder is removed in afinallyblock.
Take it further
- Log it. Pipe the results to
Export-Csvand you've got a record of what was converted and how much it saved, which is nice to have when someone asks where the ZIP went. - Try -mx=9 with a bigger dictionary. Adding
-md=256mto theaarguments can squeeze out more on huge log archives, at the cost of RAM on both ends. - Run the health check first. Part one's
-TestOnlymode will find the corrupt ZIPs before this script trips over them.
Things that'll trip you up
- "Smaller" means smaller by any amount. The event-photos row above saved 0.1 percent, which technically counts. If you want a real threshold, filter the results on SavedPercent before you pass -RemoveSource, or run once without it and remove the ZIPs you care about afterwards.
- Level 9 is slow and hungry. -mx=9 uses a big dictionary, which means lots of RAM for large archives and long run times. For a one-off overnight job it's fine; on a shared server, drop to 5 or 7.
- You need temp space for the biggest ZIP, uncompressed, plus the new 7z. Everything is extracted under %TEMP% before it's repacked. Set TEMP to a roomier drive for the session if your C drive is tight.
- Solid archives are slower to pull single files from. 7-Zip builds solid archives by default, which is a big part of why they're small. The trade-off is that extracting one file from the middle means decompressing everything before it. For a cold archive that's a fine deal.
- Encrypted ZIPs will prompt. If a ZIP has a password, 7-Zip asks for it and a scheduled run will fail that file. The 7z-to-ZIP script in part two shows how to pass a password as a secure string if you need to add it here.