curl

Transfer data to and from a server

Updated 2026-08-16

curl sends an HTTP (or FTP, or a dozen other protocols') request and prints or saves whatever comes back. It's the tool behind "let me just check what this API returns," behind download scripts, behind health checks, and behind debugging why a webhook isn't firing.

Build a request, read a response

Every curl invocation assembles a request from pieces (method, URL, headers, body), sends it, and by default prints the response body to stdout. Everything else (status line, response headers, timing, progress) is opt-in via flags:

curl https://example.com              # GET, print the body
curl -I https://example.com           # HEAD, print only the response headers
curl -i https://example.com           # GET, print headers AND body
curl -v https://example.com           # GET, print the whole conversation (connect, TLS, headers, body)

-i/-I/-v are about what curl shows you, not what request it sends. A common confusion is meaning "make a HEAD request" and typing -i.

GET is implicit; other methods usually aren't

Without -X, curl sends GET, unless you give it a request body with -d/--data or -F/--form, in which case it switches to POST automatically. This is why you'll see curl -X POST url -d "..." written both with and without the explicit -X POST. The flag is often redundant but harmless, and worth keeping for readability. -X is not redundant for PUT or DELETE, which curl never infers.

-d vs -F: two different request bodies

  • -d / --data sends application/x-www-form-urlencoded data (or literally whatever string you give it, if you set Content-Type yourself for a JSON body).
  • -F / --form sends multipart/form-data, the format browsers use for <form> uploads, and the one you need to upload a file (-F "file=@report.pdf").

Mixing these up is the most common reason for a "curl works but the server doesn't see the data" bug. Check whether the endpoint expects urlencoded or multipart.

A 404 still exits zero

By default, curl exits 0 even for a 404 or 500 response. As far as curl is concerned, it successfully transferred something. Two flags change that:

  • -f / --fail makes curl exit non-zero (22) on HTTP error status codes, instead of printing the error page and exiting 0.
  • -m / --max-time bounds the whole operation; on timeout curl exits 28.

A script that checks curl's exit code without --fail is almost always checking the wrong thing. See Exit codes and error handling.

Redirects don't follow themselves

By default curl prints whatever the server sends back for a 3xx, including a redirect's near-empty body, without ever requesting the new location. Add -L / --location to follow redirects automatically. This trips up more people than it should, because the failure mode looks like "the page is just... blank" rather than an obvious error.

Getting machine-readable facts out of a response

-w / --write-out prints values from a template string after the transfer completes: status code, total time, effective URL after redirects, and more, without you having to parse curl's normal output:

curl -s -o /dev/null -w "%{http_code}\n" https://example.com

-o /dev/null throws away the body, -s silences the progress meter, and -w prints just the one number you asked for. This pattern, discard the body and print one templated fact, is the backbone of most health-check and monitoring scripts built on curl.

Where the response body goes

-o file saves to a name you choose; -O saves using the remote URL's own filename (which means the URL needs an actual filename in its path; -O on https://example.com/ with no path fails, since there's nothing to name the file). -C - resumes an interrupted download instead of restarting from zero, when the server supports range requests.

Sample files used on this page

Every example below was run against these files. Recreate them to follow along.

the local test server start it with python3 http-mock.py before running the examples - the script is in the site repo at scripts/fixtures/http-mock.py

Local test server for the curl and wget pages.

GET  /get                     echo the query string and request headers
POST /post                    echo the body, as form fields or JSON
PUT  /put                     echo the body
DELETE /delete                echo the request
GET  /headers                 echo the request headers only
GET  /user-agent              echo the User-Agent header only
GET  /basic-auth/user/pass    401 until the right credentials arrive
GET  /cookies/set/NAME/VALUE  set a cookie, then redirect to /cookies
GET  /cookies                 echo the cookies the request carried
GET  /redirect/N              redirect N times, then land on /get
GET  /status/CODE             answer with that status code
GET  /delay/SECONDS           wait, then answer
GET  /robots.txt              a small static file
GET  /page.html               a small static HTML page
GET  /site/index.html         a small linked site, for recursive download
GET  /top.html                one level above /site/, for --no-parent
GET  /large.bin               64KB, supports Range so a download can be resumed

Run a second copy with --tls to serve all of the above over HTTPS, with a self-signed
certificate that nothing trusts.
29 outputs, collapsed by default

Basic requests

GET is the default. This section covers what curl shows you by default and how to see more.

Fetch a URL and print the body

curl http://127.0.0.1:8080/page.html

With no flags, curl sends a GET request and prints the response body to stdout.

Show output
<!doctype html>
<title>Test page</title>
<h1>Test page</h1>
<p>A small static page served by the local test server.</p>

Fetch only the response headers

curl -I http://127.0.0.1:8080/page.html

-I (--head) sends a HEAD request, same as GET but the server doesn't send a body, so you get headers only, fast.

Show output
HTTP/1.0 200 OK
Server: mockhttp/1.0
Date: Sun, 05 Jul 2026 15:40:00 GMT
Content-Type: text/html
Content-Length: 120

Print response headers AND body

curl -i http://127.0.0.1:8080/get

-i (--include) keeps the GET request but includes the response headers above the body in the output.

Show output
HTTP/1.0 200 OK
Server: mockhttp/1.0
Date: Sun, 05 Jul 2026 15:40:00 GMT
Content-Type: application/json
Content-Length: 158

{
  "args": {},
  "headers": {
    "Accept": "*/*",
    "Host": "127.0.0.1:8080",
    "User-Agent": "curl/8.14.1"
  },
  "url": "http://127.0.0.1:8080/get"
}

Silence the progress meter

curl -s http://127.0.0.1:8080/page.html -o page.html

-s (--silent) suppresses the progress bar, useful in scripts where you don't want that noise in logs.

Show both a response and any error, quietly

curl -sS http://127.0.0.1:8080/page.html -o page.html

-sS keeps -s's quiet progress but restores error messages -s alone would also hide. The combination you want in almost every script.

Saving output

Choosing a filename, or letting curl pick one.

Save using the remote file's own name

curl -O http://127.0.0.1:8080/robots.txt && cat robots.txt

-O (--remote-name) names the local file after the last path segment of the URL: here, robots.txt.

Show output
User-agent: *
Disallow: /site/b.html

Resume an interrupted download

curl -C - -o robots.txt http://127.0.0.1:8080/robots.txt

-C - asks curl to figure out how much of the local file already exists and resume from there, if the server supports range requests. Avoids re-downloading a large file from scratch after a dropped connection.

Status codes and scripting

Making curl's exit code and output actually reflect success or failure.

Print only the HTTP status code

curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/page.html

Discard the body (-o /dev/null), silence the progress meter (-s), print just the status code (-w): the standard one-liner for a health check.

Show output
200

Get a 404 for a page that doesn't exist

curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/missing.html

Without --fail, curl treats a 404 as a successfully completed transfer. It just happens to contain an error page.

Show output
404

Make curl itself fail on an HTTP error status

curl --fail -s http://127.0.0.1:8080/missing.html
echo "exit=$?"

--fail (-f) turns a 4xx/5xx response into a non-zero exit code (22) instead of curl reporting success. This is the flag a script's error handling almost always needs.

Show output
exit=22

Print several facts about a transfer at once

curl -sS -w "time_total=%{time_total}s\n" -o /dev/null http://127.0.0.1:8080/page.html

-w accepts any combination of %{...} variables in one template string: status code, timing, sizes, effective URL, and more.

Show output

Your output will differ: the timing is your connection's, not this one's

time_total=0.230857s

Redirects

curl doesn't follow redirects unless you tell it to.

See a redirect without following it

curl -s http://127.0.0.1:8080/redirect/1 -o /dev/null -w "%{http_code}\n"

Without -L, curl reports the redirect's own status code (302) and doesn't request the new location.

Show output
302

Follow redirects to the final destination

curl -Ls http://127.0.0.1:8080/redirect/1 -o /dev/null -w "%{http_code} %{url_effective}\n"

-L (--location) follows the Location header automatically; %{url_effective} shows where curl actually ended up.

Show output
200 http://127.0.0.1:8080/get

Sending data: POST, PUT, DELETE

GET is implicit; a request body usually switches curl to POST automatically.

Send form data with POST

curl -X POST http://127.0.0.1:8080/post -d "name=deb1"

-d sends application/x-www-form-urlencoded data; -X POST is technically redundant here since -d already implies POST, but it's clearer to read.

Show output
{
  "args": {},
  "data": "",
  "form": {
    "name": "deb1"
  },
  "headers": {
    "Accept": "*/*",
    "Content-Length": "9",
    "Content-Type": "application/x-www-form-urlencoded",
    "Host": "127.0.0.1:8080",
    "User-Agent": "curl/8.14.1"
  },
  "json": null,
  "url": "http://127.0.0.1:8080/post"
}

POST without spelling out -X

curl http://127.0.0.1:8080/post -d "name=deb1"

Confirms the point above: -d alone is enough to make curl send POST instead of GET.

Show output
{
  "args": {},
  "data": "",
  "form": {
    "name": "deb1"
  },
  "headers": {
    "Accept": "*/*",
    "Content-Length": "9",
    "Content-Type": "application/x-www-form-urlencoded",
    "Host": "127.0.0.1:8080",
    "User-Agent": "curl/8.14.1"
  },
  "json": null,
  "url": "http://127.0.0.1:8080/post"
}

Send a JSON body

curl -X POST -H "Content-Type: application/json" -d '{"a":1}' http://127.0.0.1:8080/post

Set Content-Type yourself and pass raw JSON to -d. curl doesn't validate or reformat it, so make sure it's well-formed.

Show output
{
  "args": {},
  "data": "{\"a\":1}",
  "form": {},
  "headers": {
    "Accept": "*/*",
    "Content-Length": "7",
    "Content-Type": "application/json",
    "Host": "127.0.0.1:8080",
    "User-Agent": "curl/8.14.1"
  },
  "json": {
    "a": 1
  },
  "url": "http://127.0.0.1:8080/post"
}

Send a query string alongside a GET request

curl -G --data-urlencode "q=hello world" http://127.0.0.1:8080/get

-G forces curl back to GET while still using --data-urlencode to build and properly escape the query string. Otherwise -d's data would go in the request body instead of the URL.

Show output
{
  "args": {
    "q": "hello world"
  },
  "headers": {
    "Accept": "*/*",
    "Host": "127.0.0.1:8080",
    "User-Agent": "curl/8.14.1"
  },
  "url": "http://127.0.0.1:8080/get?q=hello+world"
}

Update a resource with PUT

curl -X PUT http://127.0.0.1:8080/put -d "status=updated"

Unlike POST, curl never infers PUT. -X PUT is required.

Show output
{
  "args": {},
  "data": "",
  "form": {
    "status": "updated"
  },
  "headers": {
    "Accept": "*/*",
    "Content-Length": "14",
    "Content-Type": "application/x-www-form-urlencoded",
    "Host": "127.0.0.1:8080",
    "User-Agent": "curl/8.14.1"
  },
  "json": null,
  "url": "http://127.0.0.1:8080/put"
}

Upload a file as multipart form data

curl -F "file=@robots.txt" http://127.0.0.1:8080/post

-F (--form) sends multipart/form-data, the format needed to upload a file. -d can't do this. The @ prefix tells curl to read the named file's contents rather than sending the literal string.

Headers and identity

Adding request headers, and reading back what the server received.

Add a custom request header

curl -H "X-Test: yes" http://127.0.0.1:8080/headers

-H adds one header per flag; repeat it for multiple headers.

Show output
{
  "headers": {
    "Accept": "*/*",
    "Host": "127.0.0.1:8080",
    "User-Agent": "curl/8.14.1",
    "X-Test": "yes"
  }
}

Set a custom User-Agent

curl -A "MyScript/1.0" http://127.0.0.1:8080/user-agent

-A (--user-agent) is shorthand for -H "User-Agent: ...". Some APIs reject requests with curl's default user agent, so this comes up more often than you'd expect.

Show output
{
  "user-agent": "MyScript/1.0"
}

Authentication and cookies

Basic auth and session cookies.

Authenticate with HTTP Basic auth

curl -u user:pass http://127.0.0.1:8080/basic-auth/user/pass

-u user:pass sends the Authorization: Basic ... header curl builds from the credentials. Never hardcode real credentials in a script; read them from an environment variable or a credentials file instead.

Show output
{
  "authenticated": true,
  "user": "user"
}

See what an unauthenticated request gets

curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/basic-auth/user/pass

Without -u, the same endpoint returns 401 Unauthorized.

Show output
401

Save cookies from a response

curl -c cookies.txt http://127.0.0.1:8080/cookies/set/session/abc123 && cat cookies.txt

-c (--cookie-jar) writes any Set-Cookie headers to a file in Netscape cookie-file format.

Show output
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.

127.0.0.1	FALSE	/	FALSE	0	session	abc123

Send cookies from a saved file

curl -b cookies.txt http://127.0.0.1:8080/cookies

-b (--cookie) reads a cookie jar and sends its cookies with the request. Pair with -c to persist a session across multiple curl calls, the way a browser would.

Show output
{
  "cookies": {}
}

Timeouts, retries, and TLS

Making curl behave well against a slow, flaky, or self-signed endpoint.

Give up after a fixed time

curl -m 2 http://127.0.0.1:8080/delay/5 -o /dev/null
echo "exit=$?"

-m (--max-time) bounds the entire request; curl exits 28 on timeout. A script should treat that distinctly from a clean HTTP error.

Show output

Your output will differ: the elapsed figure is however long your machine waited before the timeout fired

curl: (28) Operation timed out after 2007 milliseconds with 0 bytes received
exit=28

Retry a failing request automatically

curl --retry 2 --retry-delay 1 -o /dev/null -s -w "%{http_code}\n" http://127.0.0.1:8080/status/500

--retry retries on transient failures and select 5xx responses, waiting --retry-delay seconds between attempts. Useful against flaky upstreams, not a substitute for --fail in the exit-code check.

Show output
500

What an untrusted certificate looks like

curl -sS https://127.0.0.1:8443/page.html -o /dev/null
echo "exit=$?"

Exit 60 is curl declining to trust the certificate, not the server refusing the request. The test server generates a self-signed certificate for --tls, which is exactly what a freshly configured internal service looks like.

Show output
curl: (60) SSL certificate problem: self-signed certificate
More details here: https://curl.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the webpage mentioned above.
exit=60

Skip TLS certificate verification

curl -sk https://127.0.0.1:8443/page.html -o /dev/null -w "%{http_code}\n"

-k (--insecure) turns that refusal off and the same request succeeds. Fine for a self-signed cert on a box you control during setup, never for anything handling real traffic or credentials - it disables the check that would tell you someone is in the middle.

Show output
200

Watch the full TLS handshake and request

curl -kv https://127.0.0.1:8443/page.html -o /dev/null 2>&1 | grep -E "^[*>]" | head -22

-v (--verbose) prints the connection, the TLS handshake, the certificate the server presented and every header sent. The first thing to try when a request behaves unexpectedly. Lines starting * are curl's own commentary and > is what it sent.

Show output

Your output will differ: the certificate dates are when the test server generated its key, and the cipher suite depends on your curl and OpenSSL versions

* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519MLKEM768 / RSASSA-PSS
* ALPN: server did not agree on a protocol. Uses default.
* Server certificate:
*  subject: CN=localhost
*  start date: Aug 17 11:25:46 2026 GMT
*  expire date: Aug 17 11:25:46 2027 GMT
*  issuer: CN=localhost
*  SSL certificate verify result: self-signed certificate (18), continuing anyway.
*   Certificate level 0: Public key type RSA (2048/112 Bits/secBits), signed using sha256WithRSAEncryption
* Connected to 127.0.0.1 (127.0.0.1) port 8443
* using HTTP/1.x
> GET /page.html HTTP/1.1

Check whether a site is up before proceeding

if curl --fail -s -o /dev/null http://127.0.0.1:8080/page.html; then
  echo "site is up"
fi

--fail plus -s -o /dev/null reduces the whole request to a plain yes/no exit code, ready for an if.

Show output
site is up

Extract one field from a JSON response

curl -s http://127.0.0.1:8080/get | python3 -c "import json,sys; print(json.load(sys.stdin)['url'])"

curl doesn't parse JSON itself. Pipe into python3 -c, or into jq, which is built for exactly this but is not installed by default.

Show output
http://127.0.0.1:8080/get

See the public IP a request would come from

curl -s https://icanhazip.com

A quick way to confirm which outbound IP a script, container, or VPN is actually using. Hostnames/IPs shown here are sanitised.

Show output
203.0.113.42