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

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.

AT A GLANCEConvert-ZipTo7z.ps1
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

Part 3 of the thread Archive conversion workshop

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.)

Convert-ZipTo7z.ps1Download
<#
.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

ParameterTypeDefaultWhat it's for
-Pathstring[].ZIP files, or folders to search for them. Takes pipeline input.
-Recurseswitch—Search subfolders when Path is a folder.
-Destinationstring—Where the .7z files go. Defaults to next to each ZIP.
-CompressionLevelint97-Zip's -mx level, 1 to 9. 9 is slowest and smallest; 5 is a good compromise on a busy box.
-KeepLargerswitch—Keep the .7z even if it's no smaller than the ZIP. Handy when you want one format everywhere regardless.
-RemoveSourceswitch—Delete each ZIP once its .7z has been built, tested and kept. Honors -WhatIf.
-Forceswitch—Overwrite a .7z that already exists at the destination.
-SevenZipPathstring—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 -WhatIf

Convert, keep only the wins, and remove those ZIPs.

.\Convert-ZipTo7z.ps1 -Path E:\Archive -Recurse -RemoveSource

Total up what you saved.

.\Convert-ZipTo7z.ps1 -Path E:\Archive -Recurse | Where-Object Status -like 'Converted*' | Measure-Object ZipKB, SevenZipKB -Sum

Go easy on the CPU during business hours.

.\Convert-ZipTo7z.ps1 -Path E:\Archive -CompressionLevel 5

What you'll see

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

  1. Find 7-Zip. -SevenZipPath first, then 7z on PATH, then the Program Files folders.
  2. Skip what's already done. If a .7z with the same name already exists, the ZIP is reported as SkippedExists unless you pass -Force.
  3. 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 with 7z t.
  4. 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 says NoGainKeptZip.
  5. Swap in, then maybe delete. A keeper is moved to its real name, and -RemoveSource deletes the ZIP through ShouldProcess, so -WhatIf shows every delete before it happens. The temp folder is removed in a finally block.

Take it further

  • Log it. Pipe the results to Export-Csv and 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=256m to the a arguments can squeeze out more on huge log archives, at the cost of RAM on both ends.
  • Run the health check first. Part one's -TestOnly mode 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.