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.
9. The retry that doubled the charge
--retry 3 on a POST that created an order is three orders when the first one timed out mid-flight. curl's retry logic is conservative about which errors it retries, but it cannot know whether your POST was processed. The safe pattern: retry only idempotent verbs, or send an idempotency key first and retry freely after. Same logic as --fail-with-body in best practices: the flag is only safe once the request is safe to repeat.
10. .netrc permissions and the silent fallback
-n/--netrc reads ~/.netrc for credentials, and curl skips the file if its permissions allow group/other reads — no warning, the request just goes out unauthenticated and you get a 401 that looks like a wrong password. chmod 600 ~/.netrc is the fix. In cron and containers, HOME is often unset, so curl looks in the wrong place entirely; --netrc-file /run/secrets/netrc makes the location explicit. The cron interaction is the FAQ below; the permission check is the part that costs an afternoon.
11. HTTP/2 stream limits and the 429 you can't attribute
One curl process with -Z/--parallel multiplexes many URLs over one HTTP/2 connection. Servers that count connections rather than streams for rate limiting see 50 requests as one client and answer with a blanket 429 — or, on badly-behaved edge software, a stream closed in error mid-transfer. --parallel-max 4 bounds concurrency; --no-alpn forces HTTP/1.1 and gives each URL its own connection, which is the diagnostic that proves the edge is counting wrong.
12. Certificate time bombs: notBefore and the clock you didn't check
curl: (60) certificate is not yet valid is not a broken CA bundle — it's a clock. A container that started before a renewed cert's notBefore (or a VM resumed from snapshot with a stale clock) fails TLS while every browser on the same network works. date -u on the failing host is the whole diagnosis. The mirror-image bomb: curl: (60) certificate has expired on a CA you pinned with --cacert years ago. Pinned bundles need a calendar reminder; system stores get updated by package managers, yours doesn't.
13. Expect 100-continue and the stalled PUT
For -T/--upload-file transfers over 1024 bytes, curl sends Expect: 100-continue and waits up to a second for the server to say "go". Servers that ignore the header (some object stores, badly-configured proxies) make every upload start one second late — a "slow upload" that is actually a fixed 1000 ms penalty. -H 'Expect:' (empty value) removes the header; if upload latency is a flat ~1s regardless of file size, that's the mechanism.
14. The proxy you forgot you had
http_proxy/https_proxy/ALL_PROXY in the environment silently reroute every request, and corporate CAs make the proxy the TLS terminator — which produces the classic pair: curl -x '' URL works while plain curl URL returns a 60. curl -v shows Connected to (proxy) instead of the real host. --noproxy '*' opts out per-command; the environment is the place to fix it. The same variable set is why a container works on your laptop and fails in CI: the CI image bakes a proxy in.
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.
Why does my upload always start exactly one second late? The Expect: 100-continue handshake timing out. Remove it with -H 'Expect:' — see gotcha 13.
curl works on my laptop, fails in CI with error 60. Same URL, same cert. Two candidates, both environment: a proxy set in the CI image terminating TLS (gotcha 14), or a pinned --cacert bundle containing an expired CA (gotcha 12). curl -v names which one in its first five lines.