cURL Alternatives Compared: HTTPie, xh, wget2, and Native fetch
curl will never die — it's preinstalled everywhere and speaks every protocol — but "default" and "best" are different words. Here's an honest map of when the alternatives are genuinely better, tested against the same requests.
HTTPie (the friendly one)
http POST api.example.com/v1/jobs model:=small input="hello"
http api.github.com/users/octocat Authorization:'Bearer ghp_...'
- Wins: the
:=/=/:=@typed-data syntax makes JSON bodies pleasant; headers are justName:value; output is pretty and colorized by default;--print=hreads like English. - Losers: Python runtime (slow startup, needs a pip/pipx install); JSON-by-default is wrong for form endpoints and bites you once; not installed on any production server you didn't provision.
- Honest take: the best interactive tool for API poking. The moment you need the command in a Dockerfile or a colleague's bare server, you're back to curl.
xh (HTTPie's ergonomics, Rust's footprint)
xh post api.example.com/v1/jobs model:=small
xh :8080/health # localhost shorthand
- Wins: same human syntax as HTTPie but a single static binary — no Python, cold start in milliseconds, trivially vendorable. Good TLS defaults and
--curlflag that emits the equivalent curl command (the inverse of this site). - Losers: young project; no multipart file upload until recent versions matured; fewer auth plugins than HTTPie's ecosystem.
- Honest take: if you're allowed to install one binary on your team's machines, this is the one. For scripts that must run anywhere, still curl.
wget2 (when downloading is the whole job)
- Wins: recursion, timestamped resumption (
-c), rate limiting (--limit-rate), and content-disposition filename handling that curl needs three flags to approximate. It was born for mirrors. - Losers: API work. Custom headers, methods, and auth are clunky compared to curl; no per-request flexibility worth the name.
- Honest take:
wget2 -c URLfor flaky-network downloads, curl for anything with a status code you care about. Note classicwget(1.x) and wget2 differ subtly in quoting; see quoting hell for the Windows twist.
Node 18+ native fetch (in code, not the terminal)
- Wins: zero dependencies, spec-standard
ResponseAPI, streaming bodies viaReadableStream. For our curl-to-node route, modern fetch output is the default target for a reason. - Losers: no timeouts built in — you're assembling
AbortSignal.timeout()by hand; redirects preserve curl-like method changes only viaredirect: 'manual'gymnastics; error-on-non-2xx must be your ownif (!res.ok). - Honest take: fine for edge functions and scripts; the missing-defaults tax is exactly what the debugging guide documents from the other side.
Python requests / httpx
- Wins: sessions with cookie jars (the
-b/-cpattern, done right), connection pooling, retries via adapters; httpx adds HTTP/2 and proper timeouts-by-default. - Losers:
data=vsjson=— the form/JSON confusion that generates half the 400s on the internet. Set a timeout or inherit a 0-second patience budget from nobody. - Honest take: the right answer inside applications; the wrong answer for the "quick check" that curl was built for.
Comparison table
| Tool | Install barrier | JSON ergonomics | Scripting everywhere | Timeouts by default |
|---|---|---|---|---|
| curl | none (preinstalled) | manual flags | yes | no |
| HTTPie | Python | best-in-class | no | no |
| xh | one binary | best-in-class | usually | yes |
| wget2 | package | n/a | common | yes |
| Node fetch | Node 18+ | native objects | inside apps | no |
| requests/httpx | pip | json= kwarg | inside apps | requests: no, httpx: yes |
The same request, six ways
One real shape — authenticated JSON POST — in each tool. Read the defaults column in your head while you compare:
# curl: nothing is assumed except POST-from -d
curl -sS --fail-with-body https://api.example.com/v1/jobs \
-H 'Authorization: Bearer ***' \
-H 'Content-Type: application/json' \
-d '{"model":"small"}'
# HTTPie: JSON by default, Accept: */* unless you say otherwise
http POST https://api.example.com/v1/jobs \
Authorization:'Bearer ***' model:=small
# xh: same syntax, static binary, and it adds Accept-Encoding for you
xh POST https://api.example.com/v1/jobs \
Authorization:'Bearer ***' model:=small
What differs on the wire, verified against an echo origin with each tool:
| request line detail | curl bare -d | HTTPie | xh |
|---|---|---|---|
| method | POST (implied by -d) | POST (explicit verb) | POST (explicit verb) |
Content-Type | application/x-www-form-urlencoded | application/json | application/json |
Accept-Encoding | absent unless --compressed | gzip, deflate, br | gzip, deflate, br |
User-Agent | curl/8.x | HTTPie/3.x | xh/0.2x |
Two of those rows are the whole migration story. JSON-by-default means an HTTPie one-liner that works against your JSON API silently sends application/json to a form endpoint that used to accept it — and form endpoints that ignore content-type and parse the body anyway are exactly the ones that break later under load. And the UA row is why "HTTPie gets 403 but curl gets 200" exists: some edges allowlist curl/* and treat everything else as a bot. The debugging guide has the UA-first triage order.
Picking by constraint, not taste
- "The command has to run on machines I don't control" → curl. Every alternative's install story is a sentence you'll have to defend in a Dockerfile review.
- "I live in a terminal poking one API all day" → xh if you can install one binary, HTTPie if your team already runs Python everywhere. The typed-data syntax (
:=real JSON,=string field) is not cosmetic — it deletes the quoting layer where quoting hell lives. - "The job is mirrors and big files" → wget2, no contest.
wget2 -c --limit-rate 2M URLis resumption, rate limit, and correct filename handling in one line; the curl equivalent is a paragraph. - "It's inside an app" → requests/httpx or fetch, and then convert your working curl with the converter instead of rewriting from memory — the diff between converted code and hand-written code is where the bugs are.
FAQ
Which one should I paste into bug reports? curl, because everyone receiving it can run it. If you found the bug with xh, run xh --curl and attach that.
Does any alternative follow redirects by default? HTTPie and xh do; curl needs -L, and the failure mode is documented in the flags guide. wget follows by default, which is why its authors never needed -L.
Won't using HTTPie at work get me in trouble? It gets you in trouble the first time your one-liner has to run on a prod box with no pip. xh's single-binary story exists for exactly that sentence.
HTTPie gets 403 on the same URL where curl gets 200. Same network, same cert. Almost certainly the User-Agent: some edge rules allowlist curl/* and block tool UAs. Confirm by overriding it (http ... User-Agent:curl/8.5.0), then decide whether you want to keep spoofing it or ask the API owner to fix the rule.
Does xh's --curl output lose anything? Flags with no curl equivalent — xh's --curl emits method, headers, and body, but verify the body encoding by eye: JSON-by-default in xh maps to -d plus a Content-Type header in curl, and a form-field = in xh is not the same as a JSON body wearing a JSON header.