A love letter to cURL — the world's most underrated debugger
Postman is great. Insomnia is great. But the moment something goes wrong in production, the tool I reach for first is the one that's been on every Unix box since 1996.
I want to start with a confession. For about a decade of my career, I
used curl the way most engineers do: to copy-paste the contents of
the “Copy as cURL” button from Chrome DevTools into a shell, run it
once, see what came back, and never think about the tool again.
I want to start with a confession because, looking back, that was an
embarrassing waste of curl. Sort of like owning a piano and only
playing the C-major scale with one finger. There’s a whole instrument
in there. Most engineers I know — including, until recently, me —
don’t realise it.
This is a long-overdue thank-you post to the most underrated debugger in our profession.
A brief, biased argument
I have nothing against GUI HTTP clients. Postman has, on net, probably made the world a better place for the average web developer. Insomnia has a nicer UI than Postman. Bruno is open source and ships faster than either. Hoppscotch runs in a browser. These are all good tools and they all have their place.
Their place is exploration. Building a request from scratch. Sharing a collection with the rest of the team. Onboarding a new hire. Generating client code from a spec. Those are real, valuable workflows and a GUI is the right shape for them.
The place a GUI is wrong for is debugging. Debugging happens late, on someone else’s box, over an SSH session, when something is already on fire and the GUI is not installed and you don’t have the patience to install it. In that mode, the tool that wins is the one that’s already there.
curl is already there. It is, in fact, the most “already there”
piece of software on the modern internet. There is curl in your
container. There is curl in your Raspberry Pi. There is, somewhat
absurdly, curl running on Mars right now. It will outlive you. You
should probably learn it properly.
The six flags that get you 80% of the way
Memorise these. They go together.
curl -sS \
-i \
-X POST \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer eyJhbGciOi...' \
--data '{"hello":"world"}' \
https://api.example.com/v1/things
What’s going on:
-smakes curl shut up about the progress meter.-Sputs errors back on stderr. The combination —-sS— is what you want approximately always. Without-S, a silent failure is genuinely silent. You will, at some point, lose half an hour to this. Save yourself.-iincludes the response headers in stdout. They are, more often than not, the part of the response you actually care about. Runcurlwithout-iand you’ll spend the next ten minutes wondering why your authenticated endpoint returns an empty body. It’s because it’s returning a307and you can’t see it.-X POSTdoes what it says. There’s-d/--datafor the body. There’s also--data-raw,--data-binary,--data-urlencode, and a couple of others, each for a slightly different shape of payload. The mnemonic I use:--datais for JSON,--data-binaryis for the moment you discover that JSON is corrupting your bytes.
That’s the boring 80%. The fun starts past it.
When you need to know what actually went over the wire
The single most-useful flag in curl and almost nobody talks about it:
curl --trace-ascii /tmp/trace.txt https://api.example.com/healthz
This writes every byte sent and received, in a readable form, to
/tmp/trace.txt. Not “the headers”. Every byte. Including the bytes
that are causing you to lose your mind.
A non-exhaustive list of bugs I have personally solved by cat-ing
a --trace-ascii output and squinting at it:
- A reverse proxy that was quietly stripping
Transfer-Encoding: chunkedand breaking streaming uploads in production. The proxy config was managed by another team. The bug had been live for about six weeks. We found it in twenty minutes. - An HTTP client library appending an extra
\r\nto a JSON body, which the server’s body parser silently treated as “end of body, but the body is invalid, return 400 with no detail.” - A load balancer rejecting HTTP/2 in production but accepting it in staging. Root cause: a misconfigured ALPN cipher list, six letters different. The “X-Service-Version” header was right. The TLS handshake metadata was not.
- An “invalid signature” error caused by a single zero-width space pasted from someone’s Slack message into a config file. I am not making that up. I wish I were.
I want to be clear here: I could not have caught any of these in
Postman. Postman would have shown me “400 Bad Request” and a body
that said “invalid request”. --trace-ascii showed me the bytes.
The bytes were lying. The bytes are always lying. You just have
to look at them.
The timing breakdown
This is the one I tell every junior engineer about and they look at
me like I just handed them a cheat code, which, in fairness, I sort
of did. Add this to your ~/.curlrc:
# ~/.curlrc
-w '\n
namelookup: %{time_namelookup}s
connect: %{time_connect}s
appconnect: %{time_appconnect}s
pretransfer: %{time_pretransfer}s
starttransfer: %{time_starttransfer}s
total: %{time_total}s
http_code: %{http_code}
size_download: %{size_download} bytes\n'
Now every curl invocation tells you, for free, exactly where the
time went. DNS slow? That’s namelookup. TLS handshake slow? That’s
appconnect. Server slow? That’s starttransfer minus
pretransfer. Egress slow? Subtract.
If you’ve ever spent half an hour in a meeting arguing about whether your latency problem is “the network” or “the application”, and the answer turned out to be “TLS handshake on a cold connection”, this is the tool that would have ended that meeting in 30 seconds.
Replaying traffic, badly, with shell
Most teams I’ve worked with have at least once gone through the ritual of “we need a load testing tool”. They evaluate k6, locust, vegeta, gatling, sometimes JMeter (don’t), pick one, document something on Confluence, and then nobody ever uses it again because running it requires too much setup.
curl plus xargs plus a directory of captured requests will, in
my experience, cover 70% of the use cases of an actual load testing
tool, with zero setup:
# Replay 1000 captured requests at staging, 20 in parallel,
# count response codes.
ls captured/*.json | head -1000 |
xargs -I{} -P20 \
curl -sS -o /dev/null -w '%{http_code}\n' \
-H 'Content-Type: application/json' \
--data-binary @{} \
https://staging.api.example.com/v1/things |
sort | uniq -c | sort -rn
I have used variants of that one-liner to:
- Confirm a new validation schema rejects ~0.03% of historical traffic, none of which integration tests had ever caught.
- Bisect which day of which release introduced a sudden 403 spike (it was a Tuesday, the spike was a customer with a leading whitespace in their API key, the customer’s lawyers were involved).
- Smoke-test a new region in a CDN before swinging DNS, by
combining the above with
--resolve(see next section).
I’m not saying don’t use a real load tester for real load testing. I’m saying: a surprising amount of “real” load testing is actually this, with a fancier wrapper.
TLS is the rest of curl’s superpower
There is a whole second curl hiding inside curl, and it’s about
TLS. A non-exhaustive tour:
# What certificate chain is the server actually serving?
curl -vI https://api.example.com 2>&1 | grep -E '^\*'
# Force a particular TLS version, to see if the new version is the
# problem or the old version is.
curl --tls-max 1.2 https://api.example.com
# Mutual TLS, in two flags.
curl --cert client.pem --key client.key https://api.example.com/private
# Pin a hostname to a specific IP, bypassing DNS, GeoDNS, Anycast,
# and whatever other routing voodoo is between you and the server.
# This is the trick.
curl --resolve api.example.com:443:203.0.113.42 https://api.example.com
That last one — --resolve — is the killer feature. The first
time you use it to hit one specific edge node in a CDN, in a
specific region, bypassing every routing layer between you and
that one box, you will wonder how you ever debugged a CDN issue
any other way. (Answer: very, very slowly, while arguing with
support.)
The real argument
You could do all of the above in a GUI. You could also, in principle, drive a screw with a butter knife. The question is whether the tool fits the situation.
Debugging fits a specific situation. The situation is: tired, late, on someone else’s machine, with no patience for friction. In that situation, the tool that wins is the one that’s already installed, scriptable, pipeable, honest about what’s happening at the byte level, and old enough that the answer to any question you have is on Stack Overflow.
curl is all four. It has been all four since 1996. It will still
be all four in 2050. It works in a Docker FROM scratch image
(well, with the static build). It works over SSH on a box that
hasn’t been updated since 2014. It works on Mars.
Spend a weekend with man curl. Yes, the whole thing — it’s not
that long, and most of it is genuinely useful. Take notes. Build
yourself a cheat sheet, preferably on actual paper. The next time
something is on fire at 3am, you’ll save half an hour and possibly
your sanity.
Daniel Stenberg, if you’re reading this: thank you. Genuinely. We all owe you several beers.