Our angle, declared: we are Geonode and we sell proxies, so the honest thing to note about downloading files through one is that bandwidth is the whole cost and files are large. Routing a 4 GB download through metered residential traffic at $0.79/GB is roughly $3.16 for one file, versus $0.56 on datacentre traffic at $0.14/GB. If your download does not need to look like it came from a consumer connection — and most downloads do not — residential bandwidth is money set on fire. Prices are from our pricing page, checked September 2026. Better still: if you can download directly, do that, and pay nothing.
With that noted, here is how to do it properly.
The Three Ways to Name the Output File
By default curl writes to standard output, which is why a naive curl https://example.com/file.zip fills your terminal with binary. Three options change that.
-o filename writes to a name you choose:
curl -o archive.zip https://example.com/download?id=1234
Use this whenever you know what you want the file called, and particularly when the URL has no useful filename in it.
-O uses the remote name. The curl manual describes it as: "Write output to a file named as the remote file. Only the file part of the remote file is used, the path is cut off."
curl -O https://example.com/files/report.pdf
# saves as report.pdf
Note the caveat in that sentence. The path is discarded, so two files with the same basename in different directories overwrite each other. And if the URL ends in a slash or has no filename component, -O fails.
-J takes the name from the server's Content-Disposition header instead. It is documented as telling -O "to use the server-specified Content-Disposition filename instead of extracting one from the URL". Useful for download endpoints where the URL is an opaque identifier.
It also carries the most emphatic warning in the manual, and it is worth reproducing:
WARNING: The Content-Disposition filename is not validated or sanitized. It may contain path traversal sequences (..), do not save the output to an untrusted location or with this option active without notice.
In other words, -OJ lets a remote server choose a path on your filesystem. Against a source you control, fine. In a script pulling from arbitrary URLs, it is a vulnerability. If you need server-supplied names from untrusted sources, fetch the headers, sanitise the name yourself, and use -o.
Redirects: Why -L Is Almost Always Required
curl does not follow redirects by default. -L is documented as "Follow HTTP redirects and repeat requests with the method originally specified."
This is the most common reason a download produces a tiny file containing HTML instead of the content you wanted. Download URLs redirect constantly — to CDNs, to signed URLs, to mirrors, to regional endpoints. Without -L you save the redirect page.
curl -L -O https://example.com/latest.tar.gz
Two related points.
Order matters with -o. When following redirects with multiple URLs, curl matches -o arguments to URLs positionally. With a single URL this is not an issue, but it surprises people writing multi-URL commands.
Cap the redirects. --max-redirs limits how many curl will follow. The default is generous and a redirect loop against an unbounded limit is not a failure mode you want in a cron job.
The Silent Failure: Saving Error Pages as Files
This is the single most important thing in this article.
By default, curl treats an HTTP error response as a successful transfer. A 404 has a body; curl downloads the body; curl exits 0. You now have a file named installer.dmg containing an HTML page saying "Not Found", and your script proceeded as though everything worked.
--fail fixes it. The manual: "Fail with error code 22 for HTTP responses with status codes 400 or greater, with no response body output."
curl --fail -L -O https://example.com/installer.dmg
Now a 404 produces exit code 22 and no file. Your script can check.
Two refinements:
--fail-with-body does the same but keeps the response body, which is valuable when APIs return useful JSON error messages you want to log.
--fail is not perfect. It does not catch a server returning 200 with an error page, which some do. For anything important, verify the result — check the file size is plausible, check a checksum if one is published, or check the magic bytes:
curl --fail -L -o pkg.tar.gz "$URL" || exit 1
file pkg.tar.gz | grep -q gzip || { echo "not a gzip archive"; exit 1; }
The canonical download line, for scripts, is therefore:
curl --fail --silent --show-error --location -o output.bin "$URL"
-sS suppresses the progress meter while keeping error messages, which is what you want in automation. For interactive use, -# gives you the simple progress bar rather than the default meter.
Resuming Interrupted Downloads
Large files and unreliable connections make this essential.
-C - is documented as: "Resume a previous transfer from the given byte offset. Use '-C -' to instruct curl to automatically find out where/how to resume the transfer."
curl -C - -L -O https://example.com/large-file.iso
curl checks the size of the local file and requests only the remainder using a Range header. Combined with retries, this makes long downloads survivable:
curl --fail -L -C - --retry 5 --retry-delay 5 -O https://example.com/large-file.iso
Three caveats.
The server must support range requests. If it does not, curl cannot resume and will either restart or fail. Check for Accept-Ranges: bytes in the response headers.
Resuming a corrupted file gives you a longer corrupted file. -C - trusts the local bytes. If the partial file was truncated mid-write or the source changed, the result is silently wrong. Verify against a checksum when the source publishes one.
A changed source invalidates the resume. If the file was replaced between attempts, you get a hybrid of two versions with no error.
Downloading Many Files in Parallel
Since curl 7.66, -Z performs "transfers in parallel instead of sequentially", with --parallel-max setting the "Maximum number of transfers to perform in parallel".
curl -Z --parallel-max 8 --fail -L \
-O https://example.com/a.zip \
-O https://example.com/b.zip \
-O https://example.com/c.zip
From a file of URLs:
xargs -a urls.txt curl -Z --parallel-max 8 --fail -L --remote-name-all
--remote-name-all applies -O behaviour to every URL, which saves repeating the flag.
Choose the parallelism deliberately. More is not better past a point: servers rate-limit, your own connection saturates, and beyond the plateau you generate errors instead of throughput. Eight is a reasonable starting figure for a well-provisioned server; against a small host, four or fewer is more polite and often faster overall. The behaviour here is the same throughput curve we described in concurrency vs parallelism — it rises, plateaus, then declines.
Directories, Timestamps and Rate Limits
Three options that eliminate wrapper scripts people commonly write.
--output-dir — "Specify the directory in which to save files. This option works with --remote-name or --output options." No more cd before and after.
--create-dirs — with -o, "curl creates necessary local directory hierarchy. Created directories use mode 0750 on Unix systems." Note the mode: 0750, not 0755. If another user or service needs to read those directories, this will surprise you.
curl --fail -L --create-dirs -o data/2026/09/report.pdf https://example.com/report.pdf
--remote-time — "sets the system's file modification date and time to match the remote file's timestamp". Genuinely useful for mirroring, since it lets subsequent tooling reason about freshness.
--limit-rate — "Limit the transfer speed to the specified rate", accepting k, M and G suffixes:
curl --limit-rate 2M -L -O https://example.com/large.iso
Worth using more than people do. Saturating your uplink makes everything else on the network unusable, and on a shared or metered connection a rate cap is simple courtesy. It also reduces the chance a server treats you as abusive.
Putting the practical ones together:
curl --fail --location --continue-at - --retry 5 \
--remote-time --create-dirs \
--output downloads/archive.tar.gz \
"$URL"
Downloading Through a Proxy
Add -x and everything above still applies, with three additions.
curl -x http://user:pass@proxy.example.com:9000 \
--fail -L -O https://example.com/file.zip
Timeouts need rethinking. --max-time is the wrong instrument for downloads of unpredictable size, because a legitimate large transfer will exceed any fixed limit. Use the speed-based stall detector instead:
curl -x "$PROXY" --fail -L \
--connect-timeout 10 --speed-limit 1000 --speed-time 30 \
-O https://example.com/large.iso
That aborts if throughput stays below 1000 bytes per second for 30 seconds, while leaving a slow-but-progressing multi-hour download alone. We covered the full set of timeout options in setting a timeout with curl.
Resume behaviour interacts with rotation. If your proxy rotates exit addresses per connection, a resumed transfer arrives from a different address than the original. Some servers accept this; some serve a different mirror; some reject the range request. For long downloads, use a session that holds the same exit address.
Bandwidth is billed both ways in your head, once in reality. You pay for every byte that traverses the proxy. Combine that with --retry and a failure at 90% of a large file and the arithmetic gets unpleasant quickly. Always use -C - with proxied downloads so a retry resumes rather than restarts.
Verifying What You Actually Downloaded
An exit code of zero means the transfer completed. It does not mean you received the right bytes, and for anything you are going to execute, install or archive, that gap is worth closing.
Check the size is plausible. The cheapest possible check and it catches truncation, error pages and empty responses:
SIZE=$(stat -c%s pkg.tar.gz 2>/dev/null || stat -f%z pkg.tar.gz)
[ "$SIZE" -gt 100000 ] || { echo "suspiciously small: $SIZE bytes"; exit 1; }
Check the file type. An HTML error page saved with a .tar.gz extension is invisible to a size check if the page is large enough, and obvious to file:
file pkg.tar.gz | grep -q 'gzip compressed' || exit 1
Check a published checksum. Where the source publishes one, this is the only check that verifies content rather than shape:
curl --fail -sL -O https://example.com/pkg.tar.gz
curl --fail -sL -O https://example.com/pkg.tar.gz.sha256
sha256sum -c pkg.tar.gz.sha256 || exit 1
Note the limitation, because it is frequently misunderstood: fetching the checksum from the same server over the same connection protects you against corruption and truncation, not against a compromised source. If the server is serving a bad file it will serve a matching bad checksum. Signature verification with GPG is what addresses that, and it is worth the extra step for anything that will run with privileges.
Compare against Content-Length where present. curl exits non-zero with error 18 when a transfer ends before the announced length, which catches a class of truncation automatically — but only when the server announced a length, which chunked responses do not.
For proxied downloads, verify more rather than less. An extra hop is an extra place for a transfer to be cut short or, in the case of a filtering intermediary, altered. The checks above cost milliseconds and remove an entire category of confusing bug reports.
When curl Is the Wrong Tool
curl is excellent at fetching a URL. Several adjacent jobs have better tools.
Recursive downloads and mirroring. curl fetches URLs you name. It does not crawl. For mirroring a directory or a site, wget -r or a dedicated mirroring tool is the right choice — a comparison we went into in curl vs wget.
Very large files over unreliable links. Purpose-built download managers handle segmented transfers and aggressive resume better than curl does. curl with -C - and retries is adequate; specialised tools are better.
Torrents, rsync, S3 and similar. Different protocols with their own clients that handle integrity, deduplication and permissions natively. aws s3 cp is not just curl with extra steps.
When a package manager exists. curl | sh for software installation is convenient and gives a remote server arbitrary execution on your machine with no verification. Use the package manager where one exists.
Repeated downloads of the same resource. If you fetch a file on a schedule to check for changes, conditional requests with -z or If-None-Match avoid re-downloading unchanged content. A cache is better than a fast download.
People Also Ask
How do I download a file with curl?
curl -O https://example.com/file.zip saves it under the remote name, and -o name lets you choose the name. In practice add --fail and -L: without them curl will follow no redirects and will save error pages as if they were the file you asked for.
Why does curl download an empty or HTML file?
Almost always a redirect you did not follow — add -L — or an HTTP error saved as a file, which --fail prevents. Check what you got with file downloaded.zip or by opening it in a text editor; an HTML error page is immediately recognisable.
How do I resume an interrupted download with curl?
curl -C - -O <url>. curl checks the local file size and requests the remainder via a range request. The server must support ranges — look for Accept-Ranges: bytes — and be aware that resuming a corrupted partial file produces a longer corrupted file with no warning.
How do I download multiple files with curl?
Use -Z for parallel transfers with --parallel-max to cap concurrency, and --remote-name-all so every URL gets remote-name behaviour. For a list in a file, pipe it through xargs. Keep parallelism modest — throughput plateaus and then falls as servers begin rate-limiting.
How do I limit download speed in curl?
--limit-rate 2M caps the transfer at 2 megabytes per second, and the option accepts k, M and G suffixes. Worth using on shared connections and against small servers, both to keep the rest of your network usable and to avoid being treated as abusive.
What is the difference between -O and -o in curl?
-O uses the filename from the URL, discarding the path. -o writes to a name you specify. Use -o when the URL has no usable filename, when you need a specific name, or when you want to avoid collisions between same-named files from different paths.
Is curl -OJ safe?
Not with untrusted sources. The manual warns explicitly that the Content-Disposition filename "is not validated or sanitized" and "may contain path traversal sequences". That means a remote server chooses where the file lands. Fetch the header, sanitise the name yourself, and use -o.
How do I check whether a curl download succeeded?
Use --fail so HTTP errors produce exit code 22 rather than a saved error page, then check the exit code. For anything important, verify further: check the file size is plausible, run file to confirm the type, and compare a checksum where the source publishes one.
Wrapping Up
The bare curl -O you find in most examples works right up until it does not, and its failure mode is the worst kind — an exit code of zero and a file containing something other than what you asked for.
Two flags fix that. -L follows the redirects that essentially every real download URL uses, and --fail stops curl from saving HTTP error responses as though they were content. Add -C - for anything large, since a resumable download is the difference between a transient network problem and starting again.
For scripts, curl --fail --silent --show-error --location is the line worth memorising, with --continue-at - and --retry when files are big. And whatever the flags, verify the result rather than trusting the exit code alone — check the size, check the type, check a checksum where one exists. Downloads fail quietly more often than they fail loudly.