> ## Documentation index
> Fetch the complete documentation index at: https://upstash.com/docs/llms.txt
> Ask it with GET https://upstash.com/docs/ask?q=<keywords or question>. Short keywords rank best.
> Use these to discover all available pages before exploring further.
> Feature requests: https://upstash.com/docs/common/help/feature-requests.md lists what users asked for and how to vote for a request or add one.
> Page rating: `GET https://upstash.com/docs/feedback?path=/docs/blob/bucket/writing&helpful=true` (or `false`) records whether this page helped. An optional `comment` (URL-encoded, at most 500 characters) says what was missing or wrong. It is anonymous and needs no key. Comments hold no code, secrets or personal data.

# Writing

> put, unique paths, metadata, conditional writes, copy and move, and signed upload URLs.

This page covers every way to write an object from your server: `put` for bytes you have, `copy` and `move` to rearrange them, a JSON read-modify-write, and signed upload URLs to hand one write to somebody else.

<CodeGroup>
```ts TypeScript
const blob = await bucket.put("reports/q3.pdf", pdf, { contentType: "application/pdf" })
blob.url
```

```python Python
blob = bucket.put("reports/q3.pdf", pdf, content_type="application/pdf")
blob.url
```

```go Go
b, err := bucket.Put(ctx, "reports/q3.pdf", pdf, &blob.PutOptions{
	ContentType: "application/pdf",
})
b.URL
```
</CodeGroup>

`bucket` is the client from [Connecting](/blob/bucket/connecting). Everything here runs on your server with the bucket token. Method signatures and option types: [TypeScript](/blob/sdks/ts/bucket#put), [Python](/blob/sdks/py/bucket#put), [Go](/blob/sdks/go/bucket#put).

For files a user picks in the browser, do not proxy the bytes through your app. Use an [upload handler](/blob/uploads/upload-handler) instead.

---

## put

### Options

Every option is optional.

| Option | Default | What it does |
| --- | --- | --- |
| Content type | the body's type, else `application/octet-stream` | What the object is stored as. An explicit one wins over what the body carries. |
| Content types | none, any type | An allow list such as `["image/*", "application/pdf"]`. The declared type must be in it, and the body's leading bytes must not contradict the declared type. A refusal is `content_type_not_allowed` and nothing is written. |
| Max size | none | Refuses a body over this size with `too_large`. Also caps how much of an unknown-length stream is buffered. |
| Cache | the bucket default | The `Cache-Control` this object is stored with. |
| Metadata | none | Custom key-value pairs stored with the object. Keys come back lowercased, values must be printable ASCII. |
| Size | what the body carries | The exact length in bytes, for a stream whose size is not otherwise known. |
| Allow overwrite | on | Off refuses the write if something is already at the path. The refusal is `already_exists`. Unlike Vercel Blob, the default overwrites, so `put` stays safe to retry. |
| If unchanged | none | An etag. The write fails with `conflict` if the object changed since you read it. |
| Multipart | the [multipart threshold](/blob/uploads/large-files#the-multipart-threshold) | Bodies over this size go up in parts. Set it on to always use parts, or off to never use them. See [Large bodies](#large-bodies). |

Sizes are byte counts like `4096`, `'20mb'`, or `'32MiB'` ([Types](/blob/reference/types#size)). The content type grammar and wildcards are on [Constraints](/blob/uploads/constraints).

### Return value

`put` returns the stored object's record: path, URL, versioned URL, size in bytes, etag (what if-unchanged takes), upload time and content type ([Records](/blob/reference/types#records)). On a private bucket the URL and versioned URL are not set.

### Bodies

A body that carries its own length and content type, such as a file or an incoming request, needs nothing declared. In Go a request body is a plain reader that needs a size or a max size. One that carries neither, such as a stream, needs a size, or at least a max size. The body types each SDK accepts: [TypeScript](/blob/sdks/ts/bucket#bodies), [Python](/blob/sdks/py/bucket#bodies), [Go](/blob/sdks/go/bucket#bodies).

An incoming request with no body, or one that has already been read, is refused with `empty_body`. Python and Go have no request body type, so their `put` never returns it.

---

## Streams and unknown lengths

<CodeGroup>
```ts TypeScript
await bucket.put("export.csv", stream)
// BlobError: Length required (pass { size } or { maxSize } so the length is known
// before the first byte)  -- code 'length_required', status 411

// Buffer up to the cap. A stream that runs past it is cancelled with too_large.
await bucket.put("export.csv", stream, { maxSize: "10mb" })

// Stream straight through, nothing buffered. The declared size must be exact.
await bucket.put("export.csv", stream, { size: 5000 })
```

```python Python
bucket.put("export.csv", rows())
# BlobError: Length required (pass size= or max_size= so the length is known
# before the first byte)  -- code 'length_required', status 411

# Buffer up to the cap. An iterable that runs past it raises too_large.
bucket.put("export.csv", rows(), max_size="10mb")

# Stream straight through, nothing buffered. The declared size must be exact.
bucket.put("export.csv", rows(), size=5000)
```

```go Go
bucket.Put(ctx, "export.csv", pr, nil)
// *blob.Error: Length required (pass Size or MaxSize so the length is known
// before the first byte)  -- code length_required, status 411

// Buffer up to the cap. A reader that runs past it fails with too_large.
bucket.Put(ctx, "export.csv", pr, &blob.PutOptions{MaxSize: 10 * blob.MB})

// Stream straight through, nothing buffered. The declared size must be exact.
bucket.Put(ctx, "export.csv", pr, &blob.PutOptions{Size: 5000})
```
</CodeGroup>

Storage needs a content length before the first byte goes out, and a stream has none. Pass one of two options: a max size buffers up to that cap, and a stream that runs past it is cancelled with `too_large`; an exact size streams straight through with nothing buffered.

A body that does not match the declared size fails the request rather than being stored at the wrong length. A request that arrived chunked has no `content-length` and counts as an unknown length too.

When proxying bytes through a route, keep the max size under the platform's own request body cap, since that refusal happens before your route runs. See [Platform body limits](/blob/reference/errors#platform-body-limits).

---

## Paths

<CodeGroup>
```ts TypeScript
await bucket.put("uploads/../secrets/key.pem", body)
// TypeError: path may not contain "." or ".." segments
```

```python Python
bucket.put("uploads/../secrets/key.pem", body)
# ValueError: path may not contain "." or ".." segments
```

```go Go
bucket.Put(ctx, "uploads/../secrets/key.pem", body, nil)
// *blob.Error: Path may not contain "." or ".." segments:
// uploads/../secrets/key.pem -- code invalid_input, status 400
```
</CodeGroup>

A path is any non-empty string, with `/` as structure. It is percent-encoded for you, so spaces and unicode are fine. `.` and `..` segments are rejected, not normalized, by every method that takes a path.

Avoid empty segments (`dir/`, `/a`, `a//b`). `put` stores them, but no [signed URL](/blob/reference/signing#path-encoding) can name one, so a private object at such a path can only be read with `get`, and an upload handler refuses the path at `begin`.

### Unique paths

<CodeGroup>
```ts TypeScript
import { uniquePath } from "@upstash/blob"

const userId = "Alice_123"
const filename = "Q3 Report.pdf"
const path = uniquePath(`users/${userId}/${filename}`)
// 'users/Alice_123/Q3 Report-3xK9mBqR.pdf'
```

```python Python
from upstash_blob import unique_path

user_id = "Alice_123"
filename = "Q3 Report.pdf"
path = unique_path(f"users/{user_id}/{filename}")
# 'users/Alice_123/Q3 Report-3xK9mBqR.pdf'
```

```go Go
userID := "Alice_123"
filename := "Q3 Report.pdf"
path := blob.UniquePath("users/" + userID + "/" + filename)
// "users/Alice_123/Q3 Report-3xK9mBqR.pdf"
```
</CodeGroup>

The unique path helper adds one random suffix to the final filename while preserving the rest of the path. Case, underscores, spaces, punctuation, and Unicode stay exactly as supplied. User IDs such as `Alice_123` remain `Alice_123`; values are not lowercased, slugged, truncated, or Unicode-normalized.

Pass the whole path as one string. Slashes define directories wherever they appear, so a prefix can go in as is. Like every path, the result may not contain `.` or `..` segments; that is checked when the path is used.

| Input | Result |
| --- | --- |
| `chat/Alice_123/Photo.PNG` | `chat/Alice_123/Photo-9fQ2mAe7.PNG` |
| `Q3 Report (final).pdf` | `Q3 Report (final)-7hTbN2xY.pdf` |
| `café.png` | `café-Wm4pQ8dK.png` |
| `uploads/Alice_123/a.png` | `uploads/Alice_123/a-Zc5rT1vB.png` |

The suffix has 8 random characters and goes before the final extension when it is 1 to 8 ASCII letters or digits, preserving the extension's case. Otherwise it goes at the end. A leading dot stays part of the filename: `.env` becomes `.env-<random>`, while `.env.local` becomes `.env-<random>.local`. An empty final filename uses `file` as its stem. The SDK handles URL encoding when it sends the path to storage.

Store the returned path with the original owner ID and use that exact path for reads and deletion. The helper creates a unique key; ownership checks still belong in your application. See [Private documents](/blob/recipes/private-documents).

Two uploads of `photo.png` never land on the same object. To overwrite on purpose, write the path yourself.

---

## Metadata

<CodeGroup>
```ts TypeScript
await bucket.put("invoices/7.pdf", pdf, {
  contentType: "application/pdf",
  metadata: { owner: "u7", invoiceId: "2026-0042" },
})

const info = await bucket.info("invoices/7.pdf")
info.metadata // { owner: 'u7', invoiceid: '2026-0042' }
```

```python Python
bucket.put(
    "invoices/7.pdf",
    pdf,
    content_type="application/pdf",
    metadata={"owner": "u7", "invoice-id": "2026-0042"},
)

info = bucket.info("invoices/7.pdf")
info.metadata  # {'owner': 'u7', 'invoice-id': '2026-0042'}

bucket.put("invoices/7.pdf", pdf, metadata={"invoiceId": "2026-0042"})
# BlobError: Metadata key "invoiceId" must be lowercase (storage returns keys
# lowercased; write "invoiceid")  -- code 'invalid_input', status 400
```

```go Go
bucket.Put(ctx, "invoices/7.pdf", pdf, &blob.PutOptions{
	ContentType: "application/pdf",
	Metadata:    map[string]string{"owner": "u7", "invoice-id": "2026-0042"},
})

info, err := bucket.Info(ctx, "invoices/7.pdf")
info.Metadata // map[invoice-id:2026-0042 owner:u7]

bucket.Put(ctx, "invoices/7.pdf", pdf, &blob.PutOptions{
	Metadata: map[string]string{"invoiceId": "2026-0042"},
})
// *blob.Error: Metadata key "invoiceId" must be lowercase (storage returns keys
// lowercased; write "invoiceid")  -- code invalid_input, status 400
```
</CodeGroup>

Metadata is a flat map of string keys to string values, stored as `x-amz-meta-*` headers. Three rules:

- **Keys come back lowercased.** Write them lowercase to begin with.
- **Values must be printable ASCII.** Anything else is refused with `invalid_input`. Percent-encode other text and decode it on the way back.
- **It comes back from `info` and `get`, not `list`.** Reading metadata for many objects is one `info` call each.

<CodeGroup>
```ts TypeScript
await bucket.put("a.txt", "x", { metadata: { note: "café" } })
// BlobError: metadata.note has characters storage does not carry back unchanged
// (metadata is printable ASCII; percent-encode anything else with encodeURIComponent)
// -- code 'invalid_input', status 400

await bucket.put("a.txt", "x", { metadata: { note: encodeURIComponent("café") } })
decodeURIComponent((await bucket.info("a.txt")).metadata.note!)  // 'café'
```

```python Python
from urllib.parse import quote, unquote

bucket.put("a.txt", "x", metadata={"note": "café"})
# BlobError: metadata.note has characters storage does not carry back unchanged
# (metadata is printable ASCII; percent-encode anything else with urllib.parse.quote)
# -- code 'invalid_input', status 400

bucket.put("a.txt", "x", metadata={"note": quote("café")})
unquote(bucket.info("a.txt").metadata["note"])  # 'café'
```

```go Go
bucket.Put(ctx, "a.txt", strings.NewReader("x"), &blob.PutOptions{
	Metadata: map[string]string{"note": "café"},
})
// *blob.Error: metadata.note has characters storage does not carry
// back unchanged (metadata is printable ASCII; percent-encode anything
// else with url.QueryEscape) -- code invalid_input, status 400

bucket.Put(ctx, "a.txt", strings.NewReader("x"), &blob.PutOptions{
	Metadata: map[string]string{"note": url.QueryEscape("café")},
})
info, err := bucket.Info(ctx, "a.txt")
if err != nil {
	return err
}
note, err := url.QueryUnescape(info.Metadata["note"]) // "café"
```
</CodeGroup>

---

## Conditional writes

<CodeGroup>
```ts TypeScript
// Refuse if anything is already at the path.
await bucket.put("u/7/profile.json", body, { allowOverwrite: false })
// throws already_exists, with e.etag and e.size of what is there

// Refuse if the object changed since you read it.
const current = await bucket.info("u/7/profile.json")
await bucket.put("u/7/profile.json", next, { ifUnchanged: current.etag })
// throws conflict if somebody else wrote first
```

```python Python
# Refuse if anything is already at the path.
bucket.put("u/7/profile.json", body, allow_overwrite=False)
# raises already_exists, with e.etag and e.size of what is there

# Refuse if the object changed since you read it.
current = bucket.info("u/7/profile.json")
bucket.put("u/7/profile.json", next_body, if_unchanged=current.etag)
# raises conflict if somebody else wrote first
```

```go Go
// Refuse if anything is already at the path.
bucket.Put(ctx, "u/7/profile.json", body, &blob.PutOptions{NoOverwrite: true})
// returns already_exists, with ETag and Size of what is there

// Refuse if the object changed since you read it.
current, err := bucket.Info(ctx, "u/7/profile.json")
if err != nil {
	return err
}
bucket.Put(ctx, "u/7/profile.json", next, &blob.PutOptions{
	IfUnchanged: current.ETag,
})
// returns conflict if somebody else wrote first
```
</CodeGroup>

Turning allow-overwrite off refuses the write if anything is already at the path, with `already_exists` carrying the etag and size of what is there. If-unchanged takes an etag you read and refuses with `conflict` if somebody else wrote first. Both are enforced by storage, so there is no race window.

Both turn multipart off, so a conditional write of a large body goes up as one request. Combining either with multipart forced on is refused with `invalid_input`:

<CodeGroup>
```ts TypeScript
await bucket.put("big.bin", body, { allowOverwrite: false, multipart: true })
// BlobError: Multipart: allowOverwrite: false and ifUnchanged are single-PUT only
```

```python Python
bucket.put("big.bin", body, allow_overwrite=False, multipart=True)
# BlobError: Multipart: allow_overwrite=False and if_unchanged are single-PUT only
```

```go Go
bucket.Put(ctx, "big.bin", body, &blob.PutOptions{
	NoOverwrite: true,
	Multipart:   blob.MultipartAlways,
})
// *blob.Error: Multipart: NoOverwrite and IfUnchanged are single-PUT only
```
</CodeGroup>

---

## Updating JSON

<CodeGroup>
```ts TypeScript
interface Settings {
  theme: string
}

await bucket.updateJson<Settings>("u/7.json", (prev) => ({
  ...(prev ?? {}),
  theme: "dark",
}))
```

```python Python
bucket.update_json("u/7.json", lambda prev: {**(prev or {}), "theme": "dark"})
```

```go Go
type Settings struct {
	Theme string `json:"theme"`
}

setDark := func(prev *Settings) (Settings, error) {
	next := Settings{}
	if prev != nil {
		next = *prev
	}
	next.Theme = "dark"
	return next, nil
}
blob.UpdateJSON(ctx, bucket, "u/7.json", setDark, nil)
```
</CodeGroup>

The JSON update runs in the SDK, not in storage. It reads the document, calls your function with the parsed value, and writes the result back with if-unchanged. If somebody wrote in between, it pauses briefly, reads again and re-runs your function. After the maximum number of attempts it throws `conflict`.

- Your function gets null (`None` in Python, `nil` in Go) when there is nothing to read. An empty object reads as null too.
- It may be async (in Python, only on `AsyncBucket`). It runs on every attempt, so keep it a pure transform.
- The object is written as `application/json`. Existing metadata is carried over unless you pass your own.

### Options

Every option is optional. Names and types: [TypeScript](/blob/sdks/ts/bucket#updatejson), [Python](/blob/sdks/py/bucket#update_json), [Go](/blob/sdks/go/bucket#updatejson).

| Option | Default | What it does |
| --- | --- | --- |
| Max attempts | `6` | How many read-transform-write rounds to try before throwing `conflict`. The pause between rounds is jittered and doubles each time, starting under 50 ms. |
| Cache | the bucket default | The `Cache-Control` the rewritten object is stored with. |
| Metadata | the existing metadata | Replaces the metadata on the object. |

---

## copy and move

<CodeGroup>
```ts TypeScript
const archived = await bucket.copy("tmp/9f3c", "archive/2026/report.pdf")
const moved = await bucket.move("tmp/9f3c", "reports/q3.pdf", { contentType: "application/pdf" })
```

```python Python
archived = bucket.copy("tmp/9f3c", "archive/2026/report.pdf")
moved = bucket.move("tmp/9f3c", "reports/q3.pdf", content_type="application/pdf")
```

```go Go
archived, err := bucket.Copy(ctx, "tmp/9f3c", "archive/2026/report.pdf", nil)
moved, err := bucket.Move(ctx, "tmp/9f3c", "reports/q3.pdf", &blob.CopyOptions{
	ContentType: "application/pdf",
})
```
</CodeGroup>

`copy` runs inside storage, so the bytes never travel through your app. Storage has no rename, so `move` is a copy followed by a delete of the source. Both return the destination's record. A missing source throws `not_found`.

An existing destination is overwritten. There is no allow-overwrite option here because storage does not honor a precondition on a copy's destination.

### Options

Every option is optional, and `move` takes the same ones as `copy`. Names and types: [TypeScript](/blob/sdks/ts/bucket#copy-and-move), [Python](/blob/sdks/py/bucket#copy-and-move), [Go](/blob/sdks/go/bucket#copy-and-move).

| Option | Default | What it does |
| --- | --- | --- |
| Content type | the source's | What the destination is stored as. |
| Cache | the source's, else the bucket default | The `Cache-Control` the destination is stored with. |
| Metadata | the source's | Replaces the metadata outright. It is not merged with the source's. |

With no options the destination is an exact copy. Passing any one of them makes storage rewrite all three, so the SDK reads the other two off the source first and sends them back unchanged.

A move is not atomic. If the copy lands and the delete fails, `move` throws `move_left_a_copy` and keeps both objects. Retry the source delete to recover; see [Deleting](/blob/bucket/deleting#move-leaves-a-copy-on-failure).

---

## Large bodies

<CodeGroup>
```ts TypeScript
await bucket.put("video.mp4", data, { multipart: "100mb" })
```

```python Python
bucket.put("video.mp4", data, multipart="100mb")
```

```go Go
bucket.Put(ctx, "video.mp4", bytes.NewReader(data), &blob.PutOptions{
	Multipart: blob.MultipartOver(100 * blob.MB),
})
```
</CodeGroup>

A body over the [multipart threshold](/blob/uploads/large-files#the-multipart-threshold) of 16 MB goes up in parts instead of one PUT. The multipart option changes the threshold: a size sets a new one, on always uses parts, off never does.

- A single PUT cannot carry more than about 5 GiB. Multipart off on a body that big throws `too_large`.
- Parts are sent four at a time, fewer for bodies over about 3 GB to bound the memory held by buffered parts. Any failure aborts the whole upload before throwing, so nothing is left behind.
- A body that does not match a declared size throws `invalid_input`.

---

## Signed upload URLs

<CodeGroup>
```ts TypeScript
const upload = await bucket.signedUploadUrl("u/7/report.pdf", {
  contentType: "application/pdf",
  size: pdf.size,
  expiresIn: "10m",
})

await fetch(upload.url, { method: "PUT", headers: upload.headers, body: pdf })
```

```python Python
import httpx

upload = bucket.signed_upload_url(
    "u/7/report.pdf",
    content_type="application/pdf",
    size=len(pdf),
    expires_in="10m",
)

httpx.put(upload.url, headers=upload.headers, content=pdf)
```

```go Go
up, err := bucket.SignedUploadURL(ctx, "u/7/report.pdf",
	&blob.SignedUploadURLOptions{
		ContentType: "application/pdf",
		Size:        int64(len(data)),
		ExpiresIn:   10 * time.Minute,
	})
if err != nil {
	return err
}

body := bytes.NewReader(data)
req, _ := http.NewRequestWithContext(ctx, http.MethodPut, up.URL, body)
for k, v := range up.Headers {
	if k != "content-length" { // net/http takes the length from the body
		req.Header.Set(k, v)
	}
}
res, err := http.DefaultClient.Do(req)
if err != nil {
	return err
}
res.Body.Close()
if res.StatusCode != http.StatusOK {
	// a 403 means a header was changed or dropped
	return fmt.Errorf("upload refused: %s", res.Status)
}
```
</CodeGroup>

A signed upload URL comes back with the headers to send and when it expires: a URL somebody else can PUT exactly one object to. Use it for a CLI, a build step, or a server-to-server job whose bytes you do not want to relay. Names and types: [TypeScript](/blob/sdks/ts/bucket#signeduploadurl), [Python](/blob/sdks/py/bucket#signed_upload_url), [Go](/blob/sdks/go/bucket#signeduploadurl).

Every option is optional.

| Option | Default | What it does |
| --- | --- | --- |
| Expires in | `'10m'` | How long the link should live, at most 10 minutes. |
| Content type | `application/octet-stream` | The `Content-Type` the upload must send, and what the object is stored as. |
| Cache | the bucket default | The `Cache-Control` the object is stored with. |
| Metadata | none | Written as `x-amz-meta-*`, under the same rules as `put`. |
| Size | none, any length | Pins the body's exact length, so a URL handed out for one file cannot upload another size. |
| Allow overwrite | on | Off refuses the upload if something is already at the path. |

The returned headers are signed into the URL and must be sent verbatim. Drop one, change one, or add one, and storage answers 403. That is what stops the uploader from changing metadata.

The returned expiry may be sooner than what you asked for. Cache the link until then rather than computing your own deadline; see [Use the returned expiry](/blob/bucket/reading#use-the-returned-expiry).

For a browser upload, use the [upload handler](/blob/uploads/upload-handler) instead. A signed URL is one PUT: no multipart, no resume, and nothing tells your server it happened.

---

## Next steps

<CardGroup cols={2}>
  <Card title="Reading" href="/blob/bucket/reading">
    `get`, `info`, `exists` and paging through `list`.
  </Card>

  <Card title="Deleting" href="/blob/bucket/deleting">
    One path, a list, a prefix, and what a partial delete reports.
  </Card>

  <Card title="Caching" href="/blob/bucket/caching">
    What `cache` accepts and why it is written once, at upload.
  </Card>

  <Card title="Connecting" href="/blob/bucket/connecting">
    Client options, retries, and using an S3 client directly.
  </Card>
</CardGroup>
