# Downloading files with PowerShell in 2026

- URL: https://blog.jourdant.me/post/download-files-with-powershell
- Author: Jourdan Templeton
- Published: 2026-10-27T00:00:00.000Z
- Updated: 2026-10-27T00:00:00.000Z
- Category: PowerShell
- Tags: PowerShell, PowerShell 7, Invoke-WebRequest, curl, Cross-platform
- Summary: Tested PowerShell 7 snippets for downloading files on Windows, macOS and Linux, with resume, parallel downloads, hash checks and fresh benchmarks.

<p class="note">This is the 2026 rewrite of <a href="https://blog.jourdant.me/post/3-ways-to-download-files-with-powershell">3 ways to download files with PowerShell</a>, 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.</p>

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:

<ul class="jump">
<li class="key"><a href="#download-a-file">Download a file</a> <span>- most likely what you want</span></li>
<li class="key"><a href="#download-lots-of-files-in-parallel">Download lots of files in parallel</a></li>
<li><a href="#resume-a-broken-download">Resume a broken download</a> <span>- with retries</span></li>
<li><a href="#check-the-download">Check the download</a> <span>- compare the SHA-256 hash</span></li>
<li><a href="#download-with-curl">Download with curl</a> <span>- built into Windows, macOS and Linux</span></li>
<li><a href="#still-on-windows-powershell-51">Still on Windows PowerShell 5.1?</a> <span>- one line makes it 450 times faster</span></li>
<li><a href="#what-about-webclient-and-bits">What about WebClient and BITS?</a></li>
<li><a href="#the-benchmarks">The benchmarks</a></li>
</ul>

## 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

```powershell
$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
```

<figure>
<video class="clip" muted loop playsinline disablepictureinpicture disableremoteplayback preload="none" width="652" height="238" poster="/img/post/powershell-download-basic.webp" aria-label="Terminal recording: Invoke-WebRequest downloads the 70 MB PowerShell release with a progress bar, then Get-FileHash prints its SHA-256 hash."><source src="https://blog.jourdant.me/img/post/powershell-download-basic.mp4" type="video/mp4"></video>
<figcaption>Recorded in PowerShell 7.6.9 on Ubuntu 24.04. The same commands work unchanged on Windows.</figcaption>
</figure>

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:

```powershell
$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.

```powershell
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.

```powershell
$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.

```powershell
Invoke-WebRequest -Uri $url -OutFile ./powershell.tar.gz -Resume -MaximumRetryCount 5 -RetryIntervalSec 2
```

<figure>
<video class="clip" muted loop playsinline disablepictureinpicture disableremoteplayback preload="none" width="652" height="282" poster="/img/post/powershell-download-resume.webp" aria-label="Terminal recording: a download is cancelled with Ctrl+C at 10.6 MB, then Invoke-WebRequest -Resume finishes it at the full 70 MB."><source src="https://blog.jourdant.me/img/post/powershell-download-resume.mp4" type="video/mp4"></video>
<figcaption>The download is cancelled with Ctrl+C at 10.6 MB, then <code>-Resume</code> carries on from there.</figcaption>
</figure>

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.

```powershell
$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)
}
```

<figure>
<video class="clip" muted loop playsinline disablepictureinpicture disableremoteplayback preload="none" width="652" height="282" poster="/img/post/powershell-download-parallel.webp" aria-label="Terminal recording: downloading 20 files one at a time takes 9.2 seconds, and with ForEach-Object -Parallel it takes 1.6 seconds."><source src="https://blog.jourdant.me/img/post/powershell-download-parallel.mp4" type="video/mp4"></video>
<figcaption>Twenty 2 MB files: 9.2 seconds one at a time, 1.6 seconds eight at a time.</figcaption>
</figure>

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.

```powershell
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:

```powershell
$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:

```powershell
[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.

