cURL Patterns and Gotchas: The Failure Modes Nobody Writes Down
Every team's wiki has a working curl example. Almost none has the list of ways it stops working at 2am. These are the patterns that recur across real debugging sessions — each one looks like a server bug until you see the mechanism.
1. Success that isn't: exit code 0 on a 500
curl exits 0 for any HTTP response, including 4xx/5xx. Pipelines built on curl URL && deploy ship on errors. The correct pattern is --fail-with-body (exit non-zero, keep the body) — details in best practices.
2. The redirect that ate your POST
-L follows redirects, but curl preserves the method only on 307/308. On 301/302 a POST becomes a GET, and your JSON body vanishes mid-flight. Symptoms: "works locally, 405 in prod" because prod sits behind a redirect (HTTP→HTTPS, trailing slash normalization). Fix: call the canonical URL directly; verify with curl -i first.
3. Gzip you didn't ask for (or asked for wrong)
--compressed sends Accept-Encoding: gzip, deflate, br. Some legacy servers ignore the negotiation and claim Content-Encoding: gzip when the body is plain, or vice versa. The tell is curl: (23) Failure writing or a body that prints as binary. Debug with -H 'Accept-Encoding: identity' and see whether the API is lying.
4. The URL that worked until it didn't
- Unquoted
&: bash backgrounds the command at the first ampersand. Quote URLs with query strings — always. - Trailing spaces from spreadsheets:
%20at the end of the path means a 404 nobody can explain. Pipe throughxargsor quote. - IPv6-first hosts: curl tries AAAA before A. A broken AAAA record causes a multi-second connect delay that looks like server latency.
-4is the diagnostic; the real fix is the DNS record.
5. HTTP/2 header casing confuses bad parsers
HTTP/2 transmits headers lowercase on the wire. A server middleware that string-matches Content-Type works over HTTP/1.1 (curl sends it capitalized) and fails over HTTP/2 — same command, different protocol version. Test with --http1.1 to confirm; the bug is in their parser, but you'll fix it in your headers.
6. Timeouts at the wrong layer
--max-time bounds the whole transfer, --connect-timeout only the TCP+TLS handshake. Neither bounds a stalled body (server sends headers, dribbles bytes forever). For strict per-read bounds you need --speed-limit + --speed-time, which is the flag combo nobody remembers exists.
7. Copy-as-cURL is a snapshot, not a contract
Browser "Copy as cURL" includes Cookie, Referer, Origin, and a UA your code will never match. Two consequences: the request may depend on a session header you can't reproduce after logout, and replaying it verbatim from a datacenter IP is how you discover their bot rules. Strip to the minimum set that still reproduces — the flags guide marks which ones change the response.
8. The empty-looking header
-H 'X-Forwarded-For: 10.0.0.1 ' with a trailing space is a different header value; some WAFs normalize one and not the other. And -H 'X-Remove-Me:' (colon, nothing after) means "remove this header" in curl — a feature that has broken more "identical" requests than any TLS issue.
FAQ
Why does my command work interactively and fail in cron? cron's PATH and absent HOME break .netrc and certificate lookups before the request even starts. Absolute paths plus --cacert make cron reproducible.
Why does -X POST + -G do nothing? -G forces the data into the query string and the method back to GET semantics; combining them is a contradiction the server resolves arbitrarily. Use one intent: see GET with a body.
Is --fail-with-body new? curl 7.76 (2021). On ancient distros, emulate with -f -o body.txt -w '%{http_code}' and print the file on failure.