curl
How to Read a curl Command: Every Flag You Will Actually See
A field guide to the curl flags that show up in the wild — -d vs --data-raw vs --data-binary, when -d silently makes it a POST, what -L does to your method on a redirect, and why -k is never the fix.
Someone pasted a curl command into Slack and asked “can you run this?” It’s eleven flags long, half of it is a -H header block, there’s a -d with a JSON blob that has a stray @ in it, and you have no idea if running it will read a file, send a POST, or delete something. Here’s how to actually read one.
The anatomy of a curl command
A curl command is curl [options] <url>. The URL is a positional argument, not tied to a flag — it can go first, last, or anywhere in between:
curl -X POST -H "Accept: application/json" https://api.example.com/v1/orders
curl https://api.example.com/v1/orders -X POST -H "Accept: application/json"
Both do the same thing. Every option has a short form (-X) and usually a long form (--request) — they’re interchangeable, and tools like “Copy as cURL” tend to emit the short forms. Short boolean flags can be combined: -sS, -fsSL, and -vk are each two or more flags glued together (-s -S, -f -s -S -L, -v -k).
The flags you’ll actually meet
| Flag | Does | The trap |
|---|---|---|
-X, --request | Overrides the HTTP method word | It’s a text swap, nothing more. -X GET with a -d still sends a body. -X HEAD doesn’t behave like -I — it skips -I’s response-handling shortcuts and can hang waiting for a body that isn’t coming |
-H, --header | Adds/overrides a request header, repeatable | Setting -H "Content-Type: ..." overrides what -d/-F would have set automatically — order in the command doesn’t matter, but a manual header always wins |
-d, --data | Sends data as a POST body | Implies POST. Does not URL-encode your data — see below |
--data-raw | Same as -d, but ignores a leading @ | Use this when a value might legitimately start with @ |
--data-binary | Sends data byte-for-byte, no processing | The only one of the three that preserves embedded newlines |
--data-urlencode | URL-encodes the value before sending | Several sub-syntaxes — see below |
-F, --form | Builds a multipart/form-data request | Sets its own Content-Type; mixing -F with a manual -H "Content-Type: ..." breaks the boundary |
-u, --user | HTTP Basic auth, user:password | Base64, not encrypted — worthless over plain HTTP |
-b, --cookie | Sends cookies | Takes either a literal name=value string or a filename to read a cookie jar from |
-A, --user-agent | Sets the User-Agent header | Shorthand for -H "User-Agent: ..." — the header wins if both are present |
-e, --referer | Sets the Referer header | Same idea, shorthand for -H "Referer: ..." |
-G, --get | Forces a GET, converts data to a query string | Only does anything when paired with -d — see below |
-L, --location | Follow redirects | Without it, curl prints the 3xx response and stops. With it, your method can silently change — see below |
-k, --insecure | Skips TLS certificate verification | Encryption still happens — it just stops checking who it’s talking to. Never a fix for a real cert error |
-o / -O | -o file saves to a named file; -O saves under the URL’s own filename | -O fails oddly on URLs with no filename in the path (query strings, trailing slashes) |
-s / -S | -s silences the progress meter and errors; -S (with -s) puts errors back | -s alone means a failed request fails silently — always pair it with -S or check $? |
-i / -I | -i prints headers and body; -I/--head does a real HEAD request, headers only | -i still does a normal GET/POST — it just also shows you the headers it got back |
--compressed | Requests and auto-decompresses gzip/br/zstd | Only works if the curl binary was built with that compression support |
-v | Verbose — prints the request and response headers as they go over the wire (> sent, < received) | The first thing to reach for when a request “isn’t doing what I expect” |
The data flags are the confusing part
-d, --data-raw, --data-binary, and --data-urlencode all end up putting bytes in a request body, but they don’t agree on how, and mixing them up is the single most common source of “why is my API rejecting this.”
-d / --data sends the value as-is and sets Content-Type: application/x-www-form-urlencoded by default — but it does not URL-encode anything for you. That header is a label, not an action. The real gotcha: if the value starts with @, curl treats the rest as a filename to read from (@- reads stdin). When it reads from a file this way, it also strips carriage returns and newlines out of the content.
# reads payload.json from disk, strips its newlines
curl -d @payload.json https://api.example.com/v1/orders
# a value that legitimately starts with @ — this is NOT what you want,
# curl will try to open a file named "@handle" and fail
curl -d "@handle" https://api.example.com/v1/users
--data-raw is identical to -d, except a leading @ is just a literal character — no file interpretation. This is the fix for the trap above:
curl --data-raw "@handle" https://api.example.com/v1/users # sends the literal string
--data-binary sends the payload exactly as given, with zero processing — newlines, carriage returns, everything preserved byte-for-byte. @file still reads a file, but this time the contents go over the wire verbatim. This is the one to use for JSON bodies that contain embedded newlines, and for anything you’d call a real file upload:
curl --data-binary @body.json -H "Content-Type: application/json" https://api.example.com/v1/orders
--data-urlencode actually URL-encodes the value, and it has four forms:
curl --data-urlencode "content" # encode this whole string, no name
curl --data-urlencode "=content" # same, explicit form
curl --data-urlencode "name=content" # encode "content" only, send as name=<encoded>
curl --data-urlencode "@filename" # encode the file's contents
curl --data-urlencode "name@filename" # encode the file's contents, send as name=<encoded>
-F / --form is a different animal entirely — it builds a multipart/form-data request and sets its own Content-Type (with a boundary), which is what real file uploads through a browser <form> look like on the wire:
curl -F "[email protected]" -F "username=kyle" https://api.example.com/v1/profile
Implicit methods: when curl decides for you
-d and -F both imply POST — you never need -X POST alongside either of them, and adding it is redundant, not wrong.
# these two are equivalent
curl -d '{"item":"widget"}' https://api.example.com/v1/orders
curl -X POST -d '{"item":"widget"}' https://api.example.com/v1/orders
-G / --get flips that: it takes whatever you handed to -d and appends it to the URL as a query string, then sends a plain GET instead. This is the trick for building a query string without hand-encoding it yourself:
curl -G -d "q=lunchbox" -d "page=2" https://api.example.com/v1/search
# → GET https://api.example.com/v1/search?q=lunchbox&page=2
What -L does to your method on a redirect
Without -L, curl doesn’t follow redirects at all — it prints the 3xx response body (often empty) and exits. That surprises people who assumed curl behaves like a browser by default. It doesn’t; you have to opt in.
With -L, curl mimics browser behavior, and that includes rewriting your method on certain redirect codes:
- 301 / 302 to a POST: curl switches the follow-up request to GET, same as a browser, unless you pass
--post301/--post302to force it to keep POSTing. - 303: always downgraded to GET (or the original method, if it wasn’t POST/HEAD) unless you pass
--post303. - 307 / 308: method and body are preserved automatically — no flag needed.
curl -L --post302 -d '{"item":"widget"}' https://api.example.com/v1/orders
One more thing worth knowing: Authorization and Cookie headers are not forwarded to a different host on redirect by default — curl drops them to avoid leaking credentials to wherever the redirect points. If you genuinely need them carried cross-host, that’s what --location-trusted is for, and it should be a deliberate choice, not a reflex fix for “my auth stopped working after a redirect.”
For the full breakdown of what each 3xx code means and does to link equity and caching, see 301 vs 302 redirects.
-k is not a fix
-k/--insecureturns off TLS certificate verification. The handshake and encryption still happen — curl just stops checking that the certificate is valid, unexpired, and actually issued for the host you’re talking to. It’s fine for a throwaway local self-signed dev box. It is never the correct response to a certificate error on anything that matters.
If curl is refusing a connection over TLS, the error is almost always one of: an expired certificate, a certificate for the wrong hostname, or a chain that’s missing an intermediate certificate (works in a browser, which caches intermediates, and fails in curl, which doesn’t). Reaching for -k hides the symptom and ships the actual problem to production. Instead, decode the certificate curl is choking on and look at the subject, validity dates, and issuer with the X.509 Certificate Decoder — it runs entirely in your browser, nothing gets uploaded.
Copy-as-cURL from DevTools: read before you paste
Chrome, Firefox, and Safari can all export a network request as a curl command from the Network tab. It’s convenient and it’s also a complete replay of the request as your browser sent it — every header, every cookie including your session cookie, and sometimes a bearer token sitting right there in an Authorization header.
Pasting that into a group chat, a public GitHub issue, or a support ticket hands over whatever that cookie or token can do — often “act as you, logged in” — to anyone who reads it. Before you paste a copied curl command anywhere outside your own terminal:
- Strip
Cookie:andAuthorization:headers, or replace their values with placeholders. - If you’ve already pasted one somewhere public, treat that session/token as compromised and rotate it.
- Note the two export flavors are not interchangeable: “Copy as cURL (bash)” quotes for a POSIX shell; “Copy as cURL (cmd)” quotes for Windows
cmd.exe. Running the wrong one in the wrong shell mangles the command in ways described below.
And it isn’t just single requests: a HAR export from that same Network tab captures the entire session at file scale, cookies and all — your HAR file has your login session in it.
Quoting: the part that breaks on a different machine
A curl command copied from one shell doesn’t always survive a paste into another.
- Bash/zsh, single quotes: nothing inside is expanded —
$, backticks, and!stay literal. This is the safe default for a JSON body:-d '{"name":"O'"'"'Brien"}'aside, plain JSON with no apostrophes just works:-d '{"item":"widget"}'. - Bash/zsh, double quotes: the shell expands
$VARSand`backticks`inside them. A JSON body with a$in it (an escaped regex, a price sign, a bcrypt hash starting with$2b$) gets silently mangled if you double-quote it. - Windows
cmd.exe: has no single-quote string syntax — quotes are always double quotes, and an inner double quote has to be escaped as\". A curl command copied verbatim from a bash example (single-quoted JSON) breaks immediately incmd.exe; you have to requote it entirely. - PowerShell:
curlis aliased toInvoke-WebRequestby default in Windows PowerShell 5.1, which does not take curl flags at all —curl -X POST ...in PowerShell either errors or does something unrelated. PowerShell 7 dropped that alias, socurlthere runs the real curl.exe directly. Callcurl.exeexplicitly if you’re not sure which you’ve got. - Embedded newlines: to put a literal newline inside a data string in bash/zsh, use ANSI-C quoting —
-d $'line one\nline two'— rather than trying to paste a real newline into a single-quoted string.
When quoting gets adversarial, the reliable move is to skip it: write the body to a file and pass --data-binary @body.json instead of trying to escape it inline.
Translate it to code
The same request, four ways — a POST with a header, Basic auth, and a JSON body:
curl -X POST https://api.example.com/v1/orders \
-H "Content-Type: application/json" \
-u admin:s3cret \
-d '{"item":"widget","qty":3}'
JavaScript (fetch)
const res = await fetch('https://api.example.com/v1/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Basic ' + btoa('admin:s3cret'),
},
body: JSON.stringify({ item: 'widget', qty: 3 }),
});
const data = await res.json();
Python (requests)
import requests
response = requests.post(
'https://api.example.com/v1/orders',
auth=('admin', 's3cret'),
json={'item': 'widget', 'qty': 3},
)
data = response.json()
Go (net/http)
body, _ := json.Marshal(map[string]any{"item": "widget", "qty": 3})
req, _ := http.NewRequest("POST", "https://api.example.com/v1/orders", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.SetBasicAuth("admin", "s3cret")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
Don’t hand-translate the next one
Once a curl command has more than a couple of headers, hand-translating it is where bugs creep in — a dropped header, -d treated as --data-raw by accident, an @ that should or shouldn’t have opened a file. The cURL → Code converter parses the actual command and outputs working JavaScript fetch, Python requests, Node 18+, or Go net/http — headers, auth, JSON bodies, and multipart forms included, entirely in your browser.
A few other tools for the rest of the loop:
- HTTP Request Builder — build and fire the request without writing curl or code at all, and inspect the raw response.
- JSON Formatter — pretty-print and validate the body before you paste it into
-d. - HTTP Status Code Reference — look up exactly what the response code means once the request actually runs.
- URL Encoder / Decoder — hand-encode a query parameter when
--data-urlencodeisn’t the right shape for what you’re building.