cURL Best Practices: Writing HTTP Requests You Won't Have to Debug Twice
curl is the only HTTP client that ships with every server on earth. That also makes it the most copy-pasted and most mis-configured one. These are the practices that turn it from folklore into tooling.
Make failures loud
The default curl succeeds on a 500. curl https://api.example.com exiting 0 after receiving {"error":"db down"} has burned entire dashboards. The fix is a flag set, not a habit:
curl -sS --fail-with-body \
--connect-timeout 5 --max-time 30 \
-w '\n%{http_code}\n' \
'https://api.example.com/health'
-sS: quiet progress bar, but real errors still print. (-salone is how people lose DNS errors.)--fail-with-body: non-zero exit on 4xx/5xx and the error body still prints. Prefer it over-f, which hides the body — the only interesting part.--connect-timeout+--max-time: without both, a blackholed TCP handshake waits ~130s and your CI watchdog kills the wrong thing.
Quote like the shell is adversarial
Single quotes around every header and payload that contains no $variables. Double quotes only when you deliberately want interpolation — and never around JSON you didn't write, because $(...) inside double quotes is a command, not a string. The full taxonomy of how this breaks per shell is in quoting hell.
Be explicit about the body's identity
-d silently implies two things: the method becomes POST, and Content-Type becomes application/x-www-form-urlencoded. If you meant JSON, say so — -H 'Content-Type: application/json' — or your "JSON API call" is a form POST wearing a costume. This exact mismatch is the #1 reason converted code fails.
Read the response, not just the output
Add -i (or -D -) until the request is stable. Half of "it worked in curl" reports are people who never looked at the status line and saw a plausible-looking error body. -w '%{http_code}\n' is the assertion-friendly version for scripts.
Convert before you hand-write
When you move a working curl command into Python/Node/Go, don't rewrite from memory — convert it and read the diff. Our converter maps headers, body encoding, and auth explicitly so the diff shows real semantic choices (form vs JSON, session jar vs bare request) instead of library defaults you didn't choose. The five ways that diff usually saves you are enumerated in the debugging guide linked above.
Prefer honest method flags
Delete -X GET (a no-op that can defeat caching). Keep -X POST when the payload is -d — it's redundant to curl but load-bearing for the human reading the command. See the flags that matter for the fifteen options that carry the other 215.
Scripts: exit codes over string matching
code=$(curl -sS -o /tmp/resp.json -w '%{http_code}' --fail-with-body "$URL") || {
cat /tmp/resp.json # the error body explains everything
exit 1
}
--fail-with-body gives you the exit code; -o + -w give you the code without contaminating stdout. This pattern retires every grep -q ok healthcheck you've ever seen.
FAQ
Is -k ever acceptable? In a lab against a self-signed cert, yes, with a comment. In CI or production, no — ship the CA bundle and use --cacert. -k doesn't skip a warning, it skips the security model.
Should I always add --compressed? For interactive use, fine. In scripts, only when the source command had it — the converter keeps it when it changes the response, and your timing measurements are meaningless if half your samples are compressed and half aren't.
What replaces -v for CI logs? --trace-ascii trace.log. Verbose mode interleaves body and headers and truncates; trace is complete and separable.