A cURL PUT request replaces whatever lives at a URL with the body you send. The command fits on one line; the failure modes hide in the flags around it.
You get the command first. After that: stripped newlines, 411 errors, redirects that drop your body, and retries that print garbage, with a fix for each.
Every command ran against curl 8.5.0 and a local echo server, so the byte counts and timings are measured output.
How do you send a PUT request with cURL?
cURL PUT requests need the -X PUT flag plus a body. Send JSON with --json (curl 7.82+) or with -d and a Content-Type: application/json header. To upload a file, use -T, which switches curl to PUT on its own and streams from disk. Check the result with -w '%{http_code}'.
Here are the three forms you'll use most. httpbin.org/put echoes your request back, so it's a safe place to try them.
# JSON body with an explicit header (works on any curl version)
curl -X PUT https://httpbin.org/put \
-H "Content-Type: application/json" \
-d '{"name": "Widget", "price": 19.99}'
# Same request on curl 7.82+ (also sets Accept: application/json)
curl -X PUT --json '{"name": "Widget", "price": 19.99}' https://httpbin.org/put
# Upload a file: -T implies PUT, no -X needed
curl -T item.json https://httpbin.org/put
Drop -X PUT from the first two and curl sends a POST, since -d and --json default to it. Only -T picks PUT by itself.
PUT vs POST vs PATCH in one table
PUT means "store this exact representation at this URL." Send the same PUT five times and the server ends up in the same state as after one.
| PUT | POST | PATCH | |
|---|---|---|---|
| Body means | Full replacement | Input the server processes | A partial change |
| Who picks the URL | You | Usually the server | You |
| Safe to repeat | Yes (idempotent) | No | Depends on the patch format |
| Typical success code | 200 or 204; 201 if created | 201 | 200 or 204 |
| curl flag | -X PUT or -T |
-d on its own |
-X PATCH |
The idempotency row is the one that matters day to day. It's why retrying a failed PUT is safe and retrying a failed POST can create duplicates.
RFC 9110 fixes the status codes: 201 when PUT creates the resource, 200 or 204 when it replaces one.
The catch with PUT: fields you leave out can get wiped. PUT {"price": 17.99} to a record holding name, price, and stock, and a strict server keeps only the price.
For one-field changes, use curl -X PATCH when the API supports it. Check the docs for which patch format it expects.
Prerequisites
- curl 7.82.0 or newer for
--json(check withcurl --version). Older builds work with-dplus a header. - An endpoint to test against.
https://httpbin.org/putechoes whatever you send. jqfor Step 1 and Step 6 (apt install jqorbrew install jq).- On Windows PowerShell 5.1, type
curl.exeinstead ofcurl. The troubleshooting section explains why.
Step 1: Send JSON without quoting bugs
Inline JSON works until a value contains a quote or comes from a shell variable. Then you're escaping by hand, and one missed backslash sends invalid JSON.
Two habits fix that. Keep static payloads in a file and send them with --json @item.json. Build dynamic payloads with jq, which escapes values for you:
NAME='Widget "Pro"' # this quote would break inline JSON
PRICE=24.50
jq -n --arg name "$NAME" --argjson price "$PRICE" \
'{name: $name, price: $price}' \
| curl -X PUT --json @- https://api.example.com/items/42
--json @- reads the body from stdin. --argjson passes price as a number, so the API gets 24.5 instead of a string.
Stuck on curl older than 7.82? Swap --json @- for -H "Content-Type: application/json" --data-binary @-. Avoid -d @- there; the next step shows why.
Step 2: Send a file with a cURL PUT request
curl has five ways to send a file as the body. They differ on newlines, default Content-Type, and memory use.
| Flag | Newlines in the file | Default Content-Type | Loads file into memory | Method without -X |
|---|---|---|---|---|
-d @file |
Stripped | application/x-www-form-urlencoded |
Yes | POST |
--data-binary @file |
Kept | application/x-www-form-urlencoded |
Yes | POST |
--json @file |
Kept | application/json |
Yes | POST |
-T file |
Kept | None sent | No, streams | PUT |
-F field=@file |
Kept | multipart/form-data |
No, streams | POST |
The trap is -d @file. The curl manual says it strips carriage returns and newlines when reading from a file. JSON survives that. YAML, CSV, NDJSON, and PEM certificates don't.
printf 'server:\n port: 8080\n tls: true\n' > config.yaml
curl -s -X PUT -H "Content-Type: application/yaml" \
-d @config.yaml https://httpbin.org/put
# the server receives: "server: port: 8080 tls: true"
That YAML arrives on one line with its structure gone. On a 41-byte JSON file, -w '%{size_upload}' reported 37 bytes sent with -d and 41 with --data-binary.
My rule for scripts: --json or --data-binary for files, -T for anything large, and never -d @file.
-T has two behaviors worth knowing. It sends no Content-Type unless you add one, and a URL ending in / gets your local filename appended:
# Streams from disk; add the Content-Type yourself
curl -T report.pdf -H "Content-Type: application/pdf" \
https://files.example.com/reports/q3.pdf
# Trailing slash: curl sends PUT /reports/report.pdf
curl -T report.pdf https://files.example.com/reports/
For endpoints that want multipart data, combine -F with -X PUT, as in curl -X PUT -F "[email protected]" URL. Don't set Content-Type by hand there; curl generates the boundary.
Step 3: Add authentication
Most APIs want a bearer token. Keep it in a variable or a file, never typed inline:
# Bearer token from the environment
curl -X PUT https://api.example.com/items/42 \
-H "Authorization: Bearer $API_TOKEN" \
--json @item.json
# Headers from a file: the token stays out of `ps` output too
curl -X PUT -H @auth-headers.txt --json @item.json \
https://api.example.com/items/42
# Basic auth: give only the username and curl prompts for the password
curl -X PUT -u alice --json @item.json https://api.example.com/items/42
A variable keeps the token out of shell history, but it still shows up in the process arguments. -H @file avoids that on shared machines.
Step 4: Read the response
Scripts need the status code separate from the body. -o sends the body to a file and -w prints only the code:
code=$(curl -s -o response.json -w '%{http_code}' \
-X PUT --json @item.json https://api.example.com/items/42)
case $code in
200|204) echo "updated" ;;
201) echo "created" ;;
*) echo "failed: HTTP $code" >&2; cat response.json ;;
esac
While testing by hand, -i prints response headers above the body, including Location after a 201.
| Code | What it means for a PUT | What to do |
|---|---|---|
| 200 / 204 | Resource replaced | Nothing |
| 201 | New resource created | Read the Location header |
| 405 | Endpoint doesn't accept PUT | Check the Allow header; updates may use PATCH |
| 409 | Conflicts with current state | Re-read, merge, resend |
| 411 | Server wants Content-Length | See troubleshooting |
| 412 | If-Match failed |
Someone wrote first; re-read |
| 413 | Body too large | Compress or use the API's chunked upload |
| 415 | Wrong Content-Type | Send the type the API expects |
Step 5: Upload large files without the 1-second stall
-T streams from disk, so a 4 GB file doesn't need 4 GB of RAM. --data-binary @file and --json @file read the whole file into memory before sending.
Streaming has one quirk. For bodies over 1 MB, curl sends Expect: 100-continue and waits for a go-ahead.
If the server ignores that header, curl waits a full second before sending anyway.
I timed a 2 MB -T upload against a local server that never answers 100 Continue. Three runs took 1.004 to 1.007 seconds.
With the header removed, each run took 0.001 to 0.002 seconds.
An empty Expect: header deletes it:
curl -T backup.tar.gz -H "Expect:" \
-H "Content-Type: application/gzip" \
https://storage.example.com/backups/backup.tar.gz
Keep the header for multi-gigabyte uploads that might hit a 401, so the server can refuse early. everything curl covers the mechanics.
Piping into -T - causes a different failure. curl can't know the size, so it switches to chunked transfer encoding, and strict servers answer 411:
# Chunked upload: strict servers reply 411 Length Required
pg_dump shop | gzip | curl -T - https://storage.example.com/shop.sql.gz
# Buffered: curl reads stdin fully, then sends Content-Length
pg_dump shop | gzip | curl -X PUT --data-binary @- \
-H "Content-Type: application/gzip" \
https://storage.example.com/shop.sql.gz
Buffering costs RAM equal to the compressed dump. Past a few hundred megabytes, write to a temp file and -T that file instead.
One limit no flag fixes: curl can't resume an interrupted HTTP PUT, and -C errors out on PUT uploads.
For multi-gigabyte files over flaky links, use a resumable protocol like tus or your storage API's multipart endpoint.
Step 6: Retry without duplicates or garbage output
curl's built-in retry covers most cases. --retry handles timeouts and HTTP 408, 429, 500, 502, 503, and 504 with exponential backoff.
Since 7.66.0 it also waits for whatever Retry-After says.
curl --fail-with-body --retry 4 --retry-max-time 60 \
-o response.json \
-X PUT --json @item.json https://api.example.com/items/42
Against a test route that returned two 429s with Retry-After: 2, this finished on the third attempt after 4.0 seconds.
Keep the -o. Without it, curl writes every failed attempt's body to stdout, and my test script captured three JSON objects glued together.
--retry-all-errors extends retries to every error, including 4xx. That's harmless for PUT, since a replay lands on the same state.
On POST it can create duplicate records. It also burns time on 400s and 404s, which won't fix themselves.
Built-in retry can't refresh an expired token. This function handles one 401 with a refresh, lets curl handle everything else, and returns non-zero on failure:
put_json() { # usage: put_json URL FILE
local url=$1 file=$2 body code
body=$(mktemp)
for attempt in 1 2; do
code=$(curl -sS -o "$body" -w '%{http_code}' \
--retry 4 --retry-max-time 60 \
-X PUT -H "Authorization: Bearer $API_TOKEN" \
--json @"$file" "$url") || { rm -f "$body"; return 1; }
[ "$code" = 401 ] && [ "$attempt" = 1 ] || break
API_TOKEN=$(curl -sS -X POST -d "refresh_token=$REFRESH_TOKEN" \
"$TOKEN_URL" | jq -r '.access_token')
done
cat "$body"; rm -f "$body"
case $code in 2??) return 0 ;; *) echo "HTTP $code" >&2; return 1 ;; esac
}
I tested four cases: 401 then a good refresh, two 429s, a token that stays invalid, and a refused connection. It returned 0, 0, 1, and 1.
Set API_TOKEN, REFRESH_TOKEN, and TOKEN_URL first, and adapt the refresh call to your auth server.
Persistent 429s mean you should send fewer requests per second. The HTTP 429 guide covers rate-limit patterns.
Two cURL PUT mistakes that corrupt data silently
Both can finish with a 2xx status, so nothing in your terminal says anything went wrong.
Following redirects can send an empty PUT
-L reuses your -X PUT on every hop. With -d or --json bodies, though, curl drops the body on 301, 302, and 303.
The result is a PUT carrying nothing, unless you pass --post301, --post302, or --post303. curl 8.5.0 sent these follow-up requests in my tests:
| Command | Redirect | Follow-up request curl sent |
|---|---|---|
-L -X PUT -d '...' |
301 | PUT, 0-byte body, no Content-Length |
-L -X PUT --json '...' |
301 | PUT, 0-byte body |
-L -X PUT -d '...' |
303 | PUT, 0-byte body |
-L -X PUT --post301 -d '...' |
301 | PUT, full body |
-L -T file |
301 | PUT, full body |
-L -T file |
303 | GET, no body |
Depending on the API, an empty PUT gets rejected with 411 or 400, or it clears the record. The safest fix is to stop following redirects for writes:
- Use the final URL. APIs usually redirect for
httptohttpsor a moved version prefix; update your script once. - If you must follow, add
--post301 --post302, or upload with-T, which re-sends the body on 301 and 302. - On curl 8.16.0 or newer,
--followapplies the method the way HTTP specifies: kept on 307 and 308, possibly switched to GET on 301 through 303. - Cross-host redirects drop your
Authorizationheader unless you add--location-trusted, so the PUT arrives unauthenticated.
Two writers overwrite each other: use If-Match
PUT replaces the whole resource. Two scripts that read, edit, and write the same record will lose one of the updates. ETags stop that when the API sends them.
Grab the ETag on read, then send it back in If-Match. The server applies the PUT only if nobody changed the record in between:
etag=$(curl -s -D - -o /dev/null https://api.example.com/items/42 \
| grep -i '^etag:' | cut -d' ' -f2 | tr -d '\r')
curl -s -o /dev/null -w '%{http_code}\n' -X PUT \
-H "If-Match: $etag" \
--json @item.json https://api.example.com/items/42
In my test the first PUT returned 200. Replaying it with the old ETag returned 412 Precondition Failed, because the first write had changed the ETag.
On a 412, re-read the record, re-apply your change, and PUT again. No ETag header from the API means no protection; use its version field if it has one.
Debug a cURL PUT request
-v prints the request and response headers to stderr. Filter it down to the lines that matter:
curl -sv -X PUT --json @item.json https://httpbin.org/put 2>&1 \
| grep -E '^[<>] '
Lines starting with > are what curl sent; < lines came back. Check the method, Content-Type, and Content-Length there first. That trio explains most failed PUTs.
For full visibility, including the body bytes, route the request through a local intercepting proxy such as Charles or mitmproxy:
# -k because the debugging proxy re-signs TLS; never ship this in scripts
curl -x http://127.0.0.1:8888 -k \
-X PUT --json @item.json https://api.example.com/items/42
Port 8888 is Charles's default; mitmproxy uses 8080. See the Charles Proxy guide for setup and the cURL proxy guide for -x with authenticated proxies.
Troubleshooting common errors
"411 Length Required"
Why: A bare curl -X PUT URL sends no Content-Length, and -T - from a pipe sends chunked encoding. Strict servers reject both.
Fix: Add -d '' for an intentionally empty body; curl then sends Content-Length: 0. For piped data, use --data-binary @- or a temp file with -T.
Don't set Content-Length by hand. curl computes it, and a hand-counted ${#var} counts characters, which is wrong for any non-ASCII text.
"415 Unsupported Media Type"
Why: -d sends application/x-www-form-urlencoded even when the body is JSON, and -T sends no Content-Type at all. curl doesn't guess the type from a .json extension.
Fix: Add -H "Content-Type: application/json", or use --json, which sets it for you.
"405 Method Not Allowed"
Why: The endpoint doesn't accept PUT. RFC 9110 requires servers to list the allowed methods in an Allow header on a 405.
Fix: Run the request with -i and read Allow. If it lists PATCH or POST, that's how this API does updates.
"Invoke-WebRequest : A parameter cannot be found that matches parameter name 'X'."
Why: In Windows PowerShell 5.1, curl is an alias for Invoke-WebRequest, which has no -X flag.
Fix: Type curl.exe. In cmd.exe, single quotes aren't string delimiters, so inline JSON breaks there too. Put the body in a file and send --json @item.json, which works the same in every shell.
FAQ
What's the difference between cURL PUT and POST?
PUT stores the body at the exact URL you name, and repeating it leaves the same result. POST hands data to the server, often creating a new resource each time.
In curl, -d alone means POST; add -X PUT to switch.
How do I send form data with PUT in curl?
Use curl -X PUT -d 'name=Widget&price=19.99' URL for URL-encoded fields, since that's curl's default Content-Type for -d. For file fields, use -X PUT -F '[email protected]', which sends multipart/form-data.
Can curl resume an interrupted PUT upload?
No. The curl manual states that HTTP PUT uploads can't be resumed with -C. Resumable uploads need a server-side protocol that accepts chunks with offsets, such as tus.
Why does my PUT return 200 but nothing changed?
First check for a redirect that dropped your body: run with -v and look for a second request.
Then confirm you hit the item URL rather than the collection URL, and check whether the API queues writes for later.
Wrapping up
Default to --json for payloads, -T for files, and -o plus --retry in scripts. Skip -L on writes.
Before shipping, run the script once with -v and confirm method, Content-Type, and Content-Length. Moving the same calls to Python? Start with the Python requests guide.