cURL PUT request: syntax, examples, and fixes that work

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 with curl --version). Older builds work with -d plus a header.
  • An endpoint to test against. https://httpbin.org/put echoes whatever you send.
  • jq for Step 1 and Step 6 (apt install jq or brew install jq).
  • On Windows PowerShell 5.1, type curl.exe instead of curl. 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 http to https or 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, --follow applies the method the way HTTP specifies: kept on 307 and 308, possibly switched to GET on 301 through 303.
  • Cross-host redirects drop your Authorization header 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.