This is the 2026 rewrite of 3 ways to download files with PowerShell, my most popular post by a long way. It’s been read and referenced hundreds of thousands of times since 2015, and most of its advice no longer holds.

Back in 2015 I timed three ways to download a file in PowerShell 4 on Windows 8.1. The built-in cmdlet was slow, WebClient was the pick and BITS was a close second.

Eleven years on, PowerShell runs on Windows, macOS and Linux, and the slow cmdlet from 2015 is now as quick as anything else. I re-ran the benchmarks and tested every snippet below on Windows 11 and Ubuntu 24.04 with PowerShell 7.6.

Jump to what you need:

The short answer

Use Invoke-WebRequest with -OutFile. In PowerShell 7 it streams straight to disk and keeps pace with curl. It can also resume and retry. If you’re still on Windows PowerShell 5.1, turn the progress bar off first.

Download a file

$url = 'https://github.com/PowerShell/PowerShell/releases/download/v7.6.9/powershell-7.6.9-linux-x64.tar.gz'
Invoke-WebRequest -Uri $url -OutFile ./powershell.tar.gz
Recorded in PowerShell 7.6.9 on Ubuntu 24.04. The same commands work unchanged on Windows.

Forward slashes work on every OS, so ./powershell.tar.gz is fine on Windows too. In a script, use $PSScriptRoot or Join-Path to build the path.

To keep the file name from the URL:

$name = [System.IO.Path]::GetFileName(([uri]$url).AbsolutePath)
Invoke-WebRequest -Uri $url -OutFile (Join-Path $PWD $name)

If the download needs a token, pass it as a header. Keep the token in an environment variable or a secret store rather than in the script.

Invoke-WebRequest -Uri $url -OutFile ./file.zip -Headers @{ Authorization = "Bearer $env:API_TOKEN" }

Check the download

Most projects publish a SHA-256 hash next to each download. Checking it takes two lines and catches a truncated or tampered file before you run it.

$expected = '1DA6D6D6035D651F20E70DDEE7C4C15AC6AB8B9FB40EE92240D91263F28A884B'
$actual = (Get-FileHash ./powershell.tar.gz -Algorithm SHA256).Hash
if ($actual -ne $expected) { throw "Hash mismatch: got $actual" }

-ne ignores case, so it doesn’t matter whether the published hash is upper or lower case.

Resume a broken download

-Resume picks up a partial file where it stopped, and -MaximumRetryCount retries if the connection drops. Both arrived in PowerShell 6.1, so neither works in Windows PowerShell 5.1.

Invoke-WebRequest -Uri $url -OutFile ./powershell.tar.gz -Resume -MaximumRetryCount 5 -RetryIntervalSec 2
The download is cancelled with Ctrl+C at 10.6 MB, then -Resume carries on from there.

A few things I found while testing:

  • The server has to support range requests. GitHub, Azure Storage and most CDNs do. If it doesn’t, -Resume quietly starts again from zero.
  • If the file is already complete, the server returns 416 (Range Not Satisfiable) and Invoke-WebRequest errors once its retries run out. The file isn't touched.
  • The resumed file matched the published hash every time.

Download lots of files in parallel

ForEach-Object -Parallel runs each download in its own runspace. -ThrottleLimit sets how many run at once, and $using: passes in variables from outside the block.

$urls = @(
    'https://github.com/PowerShell/PowerShell/releases/download/v7.6.9/powershell-7.6.9-linux-x64.tar.gz'
    'https://github.com/PowerShell/PowerShell/releases/download/v7.6.9/powershell-7.6.9-linux-arm64.tar.gz'
    'https://github.com/PowerShell/PowerShell/releases/download/v7.6.9/powershell-7.6.9-osx-arm64.tar.gz'
)
$dest = New-Item ./downloads -ItemType Directory -Force
$urls | ForEach-Object -ThrottleLimit 8 -Parallel {
    $name = [System.IO.Path]::GetFileName(([uri]$_).AbsolutePath)
    Invoke-WebRequest -Uri $_ -OutFile (Join-Path $using:dest $name)
}
Twenty 2 MB files: 9.2 seconds one at a time, 1.6 seconds eight at a time.

Parallel helps most with lots of small files, where each request spends more time waiting on the server than moving data. On my Windows machine, the same 20 files took 5.7 seconds one at a time, 2.4 seconds four at a time and 1.8 seconds eight at a time. For a few large files on a slow connection, it won’t help much, because you’re already using all the bandwidth.

Download with curl

curl ships with Windows 10 (1803 and later), Windows 11, macOS and nearly every Linux distribution, so it’s handy when you want one command that works the same in PowerShell and bash.

curl -fL -o powershell.tar.gz $url       # macOS and Linux
curl.exe -fL -o powershell.tar.gz $url   # Windows

-L follows redirects (GitHub downloads always redirect) and -f makes curl fail on an HTTP error instead of saving the error page. curl doesn’t throw a PowerShell error, so check $LASTEXITCODE afterwards.

On Windows, type curl.exe rather than curl. In Windows PowerShell 5.1, curl is an alias for Invoke-WebRequest, which takes completely different parameters.

Still on Windows PowerShell 5.1?

Add one line before you download:

$url = 'https://github.com/PowerShell/PowerShell/releases/download/v7.6.9/PowerShell-7.6.9-win-x64.zip'
$ProgressPreference = 'SilentlyContinue'
Invoke-WebRequest -Uri $url -OutFile "$PSScriptRoot\PowerShell.zip" -UseBasicParsing

In 5.1, drawing the progress bar costs far more than the download itself. A 1 GB file from a local server took 7 minutes 36 seconds with the progress bar on and 1 second with it off. That’s very likely the slowness I measured in 2015 and blamed on the cmdlet.

Setting $ProgressPreference inside a script only affects that script. -UseBasicParsing stops 5.1 from depending on Internet Explorer, which matters on Server Core. PowerShell 7 ignores it.

If you get “Could not create SSL/TLS secure channel” on an older server, add this before the download:

[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

What about WebClient and BITS?

System.Net.WebClient was my pick in 2015. It still works in PowerShell 7 and it was quick in my tests, but .NET has marked it obsolete since .NET 6, and it can’t resume or retry. Now that Invoke-WebRequest is just as fast, there’s no reason to reach for it. If you really want the .NET class, System.Net.Http.HttpClient is the supported one, and it’s what Invoke-WebRequest uses under the hood anyway.

Start-BitsTransfer only exists on Windows, and it was the slowest method in both tests. It’s still the right tool for a big transfer that should keep going in the background, survive a reboot or stay under a bandwidth cap. For a script that needs a file now, use Invoke-WebRequest.

The benchmarks

I ran these on Windows 11 with PowerShell 7.6.9 (.NET 10) and Windows PowerShell 5.1.

  • Every run started a fresh PowerShell process in its own console window, so progress bars drew just as they do for you.
  • The local test downloads a 1 GB file from a small server on the same machine. That takes the network out of it and shows how much overhead each method adds.
  • The internet test downloads 50 MB from Cloudflare’s speed test over my connection, which manages about 220 Mbps.
  • Figures are medians of five runs (local) or three runs (internet and 5.1).
PowerShell 7.6 1 GB, local 50 MB, internet Peak memory
Invoke-WebRequest 0.77 s 1.87 s 125 MB
Invoke-WebRequest, progress off 0.89 s 107 MB
Invoke-RestMethod -OutFile 0.88 s 106 MB
HttpClient 1.53 s 1.72 s 106 MB
WebClient 0.85 s 2.00 s 103 MB
curl.exe 1.09 s 1.73 s separate process
Start-BitsTransfer 1.40 s 2.50 s separate service
Windows PowerShell 5.1 1 GB, local
Invoke-WebRequest, progress on 456 s
Invoke-WebRequest, progress off 1.00 s
WebClient 0.91 s
20 × 2 MB files, PowerShell 7.6 Internet
One at a time 5.74 s
-Parallel -ThrottleLimit 4 2.36 s
-Parallel -ThrottleLimit 8 1.76 s

What the numbers say:

  • On a real connection, every method except BITS finished within 0.3 seconds of the others. The network is the bottleneck, so pick the method for its features.
  • Locally, the gaps are a fraction of a second on a 1 GB file. That’s close to run-to-run noise.
  • In 2015 I wrote that Invoke-WebRequest buffered the whole file in memory. It doesn’t now. The whole PowerShell process peaked at 125 MB while downloading 1 GB.
  • Peak memory is for the whole PowerShell process. curl and BITS do the download in another process, so their numbers don’t compare.