> ## Content Index
> Fetch the complete content index at: https://roundproxies.com/blog/llms.txt
> Use this file to discover other available public pages before exploring further.

# cURL PUT request: syntax, examples, and fixes that work
- URL: https://roundproxies.com/blog/curl-put/
- Published: 2025-08-15T08:50:37.000Z
- Updated: 2026-09-24T12:46:11.000Z
- Description: Master cURL PUT requests: handle 411 errors, upload large files, implement smart retries. Real code examples that actually work.
- Author: Marius Bernard
- Tags: #dated-689ef2914fa498a2c6ee0db7

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.

```bash
# 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](https://www.rfc-editor.org/rfc/rfc9110#name-put) 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:

```bash
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](https://curl.se/docs/manpage.html) says it strips carriage returns and newlines when reading from a file. JSON survives that. YAML, CSV, NDJSON, and PEM certificates don't.

```bash
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:

```bash
# 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 "file=@avatar.png" 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:

```bash
# 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:

```bash
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:

```bash
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](https://everything.curl.dev/http/post/expect100) 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:

```bash
# 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.

```bash
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:

```bash
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](https://roundproxies.com/blog/http-error-429/) 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:

```bash
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:

```bash
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:

```bash
# -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](https://roundproxies.com/blog/how-to-use-charles-proxy/) for setup and the [cURL proxy guide](https://roundproxies.com/blog/curl-proxy/) 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 'file=@photo.png'`, 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](https://roundproxies.com/blog/requests-python/).