I have typed curl more than any other command in my terminal. It is the first thing I run when an API misbehaves, the tool I use to prove what actually went over the wire, and the fastest way to stop guessing what a header, a redirect, or a timeout is doing. This is a practical curl tutorial — not the whole manual page (which is genuinely enormous), but the curl flags I actually use, what each one prints, and how to read the output when a request goes wrong. When the problem turns out to be below HTTP, the same request shows up in a packet capture: that is the tcpdump and Wireshark walkthrough.
Every example below was run against a tiny Python server on 127.0.0.1:8731, so the numbers are real and the output is trimmed rather than invented.
Seeing the whole request: -v, -i, and -D
If you learn one flag, learn -v. It prints the connection, the request headers, and the response headers, each tagged so you always know which direction a line travelled:
$ curl -sv http://127.0.0.1:8731/
* Trying 127.0.0.1:8731...
* Established connection to 127.0.0.1 (127.0.0.1 port 8731)
* using HTTP/1.x
> GET / HTTP/1.1
> Host: 127.0.0.1:8731
> User-Agent: curl/8.22.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Type: text/plain
< Content-Length: 36
< X-Demo: curl-cookbook
<
hello from the curl cookbook server
* Connection #0 to host 127.0.0.1:8731 left intact
Read the three prefixes: * is curl talking to you (connection setup, protocol, connection reuse), > is what curl sent, and < is what the server sent back. The blank > and < lines end each header block. Nine times out of ten the bug is visible here — a missing Content-Type, a Host that does not match your vhost, a 302 where you wanted a 200.
When you only want the response, -i includes headers with the body, and -D writes headers to a file and leaves the body clean:
# -i: headers then body, straight to the terminal
curl -si http://127.0.0.1:8731/ | head -6
# -D: save headers to a file, body to another — easy to grep later
curl -sS -o body.txt -D headers.txt http://127.0.0.1:8731/
-D is my default in scripts: grep -i 'cache-control\|etag' headers.txt tells you instantly whether a response is cacheable and whether it has a validator, which is the whole game in HTTP caching.
curl -w timing: make curl report where the time went
-w (“write out”) turns curl into a timing instrument. %{time_total} is the headline, but the split matters more: time_namelookup (DNS), time_connect (TCP), time_starttransfer (time to first byte), and the total.
$ curl -sS -o /dev/null \
-w 'code=%{http_code} ttfb=%{time_starttransfer}s total=%{time_total}s\n' \
http://127.0.0.1:8731/slow
code=200 ttfb=0.400861s total=0.400899s
The server deliberately sleeps 400 ms, and the split tells the story: TTFB and total are nearly identical, so all the time is server-side, not transport. If instead time_connect were large, you would suspect the network; if time_namelookup were large, DNS. That one line separates “my code is slow” from “the network is slow” before you reach for anything else.
Keep a reusable format string so you never retype it:
# -w @file reads the format from a file
printf 'http_code=%%{http_code} ttfb=%%{time_starttransfer}s total=%%{time_total}s\n' > wfmt.txt
curl -sS -o /dev/null -w @wfmt.txt http://127.0.0.1:8731/
Redirects: -L and --max-redirs
curl does not follow redirects by default. Without -L you get the 3xx and a Location header and nothing else:
$ curl -sS -o /dev/null -D - http://127.0.0.1:8731/redirect
HTTP/1.1 302 Found
Location: /
Add -L to follow, and %{num_redirects} to count the hops. The same URL, with and without:
$ curl -sSL -o /dev/null -w 'code=%{http_code} redirects=%{num_redirects}\n' \
http://127.0.0.1:8731/redirect
code=200 redirects=1
--max-redirs caps the chain — useful when a redirect loop is eating your time, and it gives a clean exit code instead of hammering forever:
$ curl -sSL --max-redirs 0 -o /dev/null http://127.0.0.1:8731/redirect
curl: (47) Maximum (0) redirects followed
$ echo $?
47
Errors and exit codes: --fail, -o, and -sS
By default curl exits 0 even on a 404 — it delivered bytes successfully, so from curl’s point of view nothing failed. That is exactly wrong for a script. --fail makes a 4xx/5xx a non-zero exit:
$ curl -sS -o /dev/null -w 'code=%{http_code}\n' http://127.0.0.1:8731/missing
code=404
$ echo $? # curl is happy: it got a response
0
$ curl -sS --fail -o /dev/null http://127.0.0.1:8731/missing
curl: (22) The requested URL returned error: 404
$ echo $?
22
Exit codes are a whole API: 6 is “couldn’t resolve host”, 7 is “couldn’t connect”, 22 is “HTTP error with --fail”, 28 is timeout, 47 is too many redirects. When a connection is refused you get it plainly:
$ curl -sS -o /dev/null http://127.0.0.1:9/
curl: (7) Failed to connect to 127.0.0.1 port 9: Could not connect to server
$ echo $?
7
Two habits make scripts safe. -o FILE sends the body to a file so you never mix progress or payload into stdout, and -sS means silent but show errors — no progress meter, but real failures still print. -s alone hides the error message too, which is how people end up staring at a 0-byte file with no idea why.
Methods and bodies: -X, -d, --json, and -F
-d sends a body and implies POST. For JSON, --json is shorter than hand-writing headers and it gets both parts right: it sets Content-Type: application/json and Accept: application/json.
$ curl -sS --json '{"hello":"world"}' http://127.0.0.1:8731/echo
{
"received": "{\"hello\":\"world\"}",
"content_type": "application/json",
"user_agent": "curl/8.22.0"
}
For form uploads, -F builds a multipart/form-data body, including files:
curl -sS -F 'file=@report.pdf' -F 'note=hello' https://api.example.com/upload
Use -X only to override the method (-X DELETE, -X PUT). Do not write -X POST with -d — -d already implies it, and a manual -X can accidentally send a GET with a body.
Headers: -H, -A, and -e
-H adds or replaces any header, and it is repeatable. -A sets the user agent and -e sets the Referer, both as short forms of -H:
$ curl -sv -H 'X-Trace: abc123' -A 'cookbook/1.0' \
-e 'https://blog.awin-x.top/' http://127.0.0.1:8731/ 2>&1 | grep '^> '
> GET / HTTP/1.1
> Host: 127.0.0.1:8731
> User-Agent: cookbook/1.0
> Accept: */*
> Referer: https://blog.awin-x.top/
> X-Trace: abc123
To send an empty header (some servers need Accept-Encoding: with no value), use -H 'Name;' with a trailing semicolon.
Auth: -u and --netrc
-u user:pass does HTTP Basic auth. curl turns it into a base64 Authorization header — which you can watch happen with -v:
$ curl -sv -u alice:s3cret http://127.0.0.1:8731/ 2>&1 | grep -i authorization
> Authorization: Basic YWxpY2U6czNjcmV0
Typing secrets on the command line leaks them into your shell history and ps. --netrc reads credentials from a file instead, and --netrc-file points at a specific one:
# ~/.netrc — chmod 600
machine api.example.com
login alice
password s3cret
curl -sS --netrc-file ~/.netrc https://api.example.com/private
TLS, HTTP/2, and HTTP/3: --http2, --http3, -k, --cert
curl -V lists what your build can speak; mine advertises HTTP2 HTTP3:
Features: alt-svc AsynchDNS brotli ... HTTP2 HTTP3 HTTPS-proxy ... TLS-SRP
--http2 offers HTTP/2 via ALPN and falls back to HTTP/1.1 if the server declines; --http3 uses QUIC over UDP. Both depend on the server, so against my HTTP/1.1 test server the negotiation simply lands on HTTP/1.x — noted in the * using HTTP/1.x line above. The flags change what curl offers, not what the server accepts.
-k (or --insecure) skips certificate verification. It is a debugging tool only: use it once to confirm a cert problem, then fix the trust chain. For client certificates, --cert and --key:
# Diagnose: is it TLS or is it the app?
curl -vk https://self-signed.local:8443/health
# Mutual TLS with a client cert
curl --cert client.pem --key client.key https://mtls.example.com/api
Connection reuse and --resolve for testing
curl reuses connections within a single invocation. --resolve host:port:ip lets you point a hostname at an IP without editing DNS or /etc/hosts — the single most useful trick for testing a new backend before the record flips:
$ curl -sS --resolve demo.local:8731:127.0.0.1 \
-w 'code=%{http_code} ip=%{remote_ip}:%{remote_port}\n' \
http://demo.local:8731/
code=200 ip=127.0.0.1:8731
%{remote_ip} confirms curl connected where you told it to. This is how I verify TLS, vhosts, and a fresh deploy behind the real hostname while production traffic still goes elsewhere.
Debug HTTP requests: why -v beats guessing
The reason to reach for -v first is that HTTP is a text protocol in a way that survives TLS: curl decrypts its own connection, so -v shows you the exact bytes of the request and response even over HTTPS. Guessing (“maybe it’s the auth header”, “maybe it cached”) costs minutes; one -v costs seconds.
A rhythm that finds most bugs:
curl -v— is the request what you think it is? Right URL, method, headers, body?- Check the response line (
200,302,403,500) and then the headers, especiallyLocation,Content-Type,Cache-Control. - Add
-w '%{http_code} %{time_total}s\n'— is it slow, and where? - Add
-Lif a redirect is involved; add--failif you need scripts to care. - Only then reach for a packet capture, if the wire itself looks wrong.
The short list
-vfor the request/response,-ifor headers with the body,-Dfor headers to a file.-w '%{time_total}s'and friends to split DNS, connect, and TTFB.-Lto follow redirects,--max-redirsto cap them.--failso HTTP errors become non-zero exit codes;-sSfor quiet-but-loud-on-error.--jsonfor JSON,-Ffor multipart,-dfor the rest.-uin a pinch,--netrc-filefor anything real.--resolveto test a hostname against a specific IP without touching DNS.
None of these are exotic. They are the twenty flags that turn curl from “make a request” into “tell me exactly what happened” — and that is the whole point of reaching for it.