# UploadPartCopy

UploadPartCopy fills one part of a multipart upload with data that is already stored - a whole
object, or a byte range of one - so the bytes never have to travel through your client. The
request has no body at all. A multipart upload starts with
[CreateMultipartUpload](https://docs.strg.com/s3-api/CreateMultipartUpload), takes its parts from UploadPartCopy and
[UploadPart](https://docs.strg.com/s3-api/UploadPart) in any mix, and is finished by
[CompleteMultipartUpload](https://docs.strg.com/s3-api/CompleteMultipartUpload).

It is the way to copy an object too large for a single [CopyObject](https://docs.strg.com/s3-api/CopyObject), and to
build a new object out of ranges of existing ones.

**Info: Good to know on STRG**

- A copy stays within one region: the source and the destination are both buckets in the
  endpoint's region.
- One access key covers both ends of the copy. It holds **Read objects** as well as **Write
  objects**, and its buckets and object prefix apply to the source just as they do to the
  destination.
- Parts other than the last are at least 5,000,000 bytes - a little under Amazon S3's 5 MiB, so
  a part size chosen for S3 works unchanged.
- `x-amz-copy-source` and the `x-amz-copy-source-*` headers travel as headers: in a presigned
  URL's query string they get a `501 NotImplemented` answer, so a client knows straight away.
- `x-amz-expected-bucket-owner` and `x-amz-source-expected-bucket-owner` are welcome and have
  nothing to check: a key only ever reaches buckets of its own account.

## When you'd use it

- **Copying a very large object.** A nightly job keeps a copy of a 20 GiB database backup in an
  archive bucket in the same region. An object that size is beyond what a single CopyObject is made
  for, so the job opens a multipart upload and copies the backup across in ranges, one part each, without
  downloading a byte.
- **Assembling one object from several.** A video service records a long stream as separate
  segments and later joins them into one file: each segment becomes a part, copied in place,
  and CompleteMultipartUpload stitches them together.
- **Adding to an object that is already stored.** A log archive grows by a few hundred MiB each
  day. Copying the existing archive as the first parts and uploading only the new data as the
  last part with UploadPart produces the longer archive, without the old data going up again.
- **Copying with the AWS CLI or an SDK's transfer helper.** Above a size threshold, `aws s3 cp`
  between two buckets and boto3's managed `copy()` call UploadPartCopy for you, so it is often
  at work without being called by name.

## Request

```http
PUT /videos/big-copy.mp4?partNumber=1&uploadId=UploadId HTTP/1.1
Host: my-bucket.s3.eu-central-1.strg.com
Authorization: AWS4-HMAC-SHA256 Credential=...
x-amz-date: 20260926T120000Z
x-amz-content-sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
x-amz-copy-source: /my-bucket/videos/big.mp4
x-amz-copy-source-range: bytes=0-1073741823
```

Path-style, the same request is `PUT /my-bucket/videos/big-copy.mp4?partNumber=1&uploadId=UploadId`
on `s3.eu-central-1.strg.com` - [Addressing](https://docs.strg.com/s3-api/overview#addressing) has both styles.

Every `x-amz-*` header, `x-amz-copy-source` included, is part of the signature; the AWS CLI and
SDKs take care of that, and [Authentication](https://docs.strg.com/s3-api/overview#authentication) has the details.

### Permissions

A part copy reads one object and writes into an upload, so the access key needs both of these:

| Permission | Dashboard label | For |
| --- | --- | --- |
| `objects:write` | **Write objects** | Writing the part into the upload |
| `objects:read` | **Read objects** | Reading the source object |

For a key that reaches certain buckets or an object prefix, the destination bucket and key
**and** the source bucket and key all fall within them; a copy reaching outside them is answered
`403 AccessDenied`. [Access keys](https://docs.strg.com/access-keys) has the details.

### URI parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Bucket` | String | Yes | The bucket of the multipart upload. |
| `Key` | String | Yes | The key the multipart upload was started for. |
| `partNumber` | Integer | Yes | The part's number, 1 to 10,000. Any other value is answered `400 InvalidArgument`. |
| `uploadId` | String | Yes | The upload ID [CreateMultipartUpload](https://docs.strg.com/s3-api/CreateMultipartUpload) returned. |

Any other query parameter gets a `501 NotImplemented` answer. A specific version of the source
is named inside `x-amz-copy-source`, never in the query string.

### Request headers

The headers below are every one S3 defines for UploadPartCopy, each with what STRG does with
it.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `x-amz-copy-source` | String | Yes | The source, as `my-bucket/videos/big.mp4` (a leading `/` is optional), the key URL-encoded. Appending `?versionId=VersionId` copies that version; without it, the current version is copied. The source bucket is in the endpoint's region, as the destination is. It travels as a header: in a presigned URL's query string it is answered `501 NotImplemented`. A value that does not name a valid bucket and a non-empty key is answered `400 InvalidArgument`. |
| `x-amz-copy-source-range` | String | No | The bytes to copy, as `bytes=first-last` - zero-based and inclusive, so `bytes=0-9` is the first 10 bytes. Without it, the whole source object is copied. |
| `x-amz-copy-source-if-match` | String | No | Copies only if the source's ETag matches. STRG passes it along with the copy, and does not guarantee the outcome matches Amazon S3's. |
| `x-amz-copy-source-if-none-match` | String | No | Copies only if the source's ETag differs. STRG passes it along with the copy, and does not guarantee the outcome matches Amazon S3's. |
| `x-amz-copy-source-if-modified-since` | Timestamp | No | Copies only if the source changed after this time. STRG passes it along with the copy, and does not guarantee the outcome matches Amazon S3's. |
| `x-amz-copy-source-if-unmodified-since` | Timestamp | No | Copies only if the source has not changed since this time. STRG passes it along with the copy, and does not guarantee the outcome matches Amazon S3's. |
| `x-amz-server-side-encryption-customer-algorithm` | String | No | `AES256`, when the upload is encrypted with your own key (SSE-C). |
| `x-amz-server-side-encryption-customer-key` | String | No | The base64 key the upload is encrypted with - the same key CreateMultipartUpload was given. |
| `x-amz-server-side-encryption-customer-key-MD5` | String | No | The base64 MD5 digest of that key. |
| `x-amz-copy-source-server-side-encryption-customer-algorithm` | String | No | `AES256`, when the source is encrypted with your own key. |
| `x-amz-copy-source-server-side-encryption-customer-key` | String | No | The key the source was encrypted with. |
| `x-amz-copy-source-server-side-encryption-customer-key-MD5` | String | No | The base64 MD5 digest of that key. |
| `x-amz-request-payer` | String | No | Has nothing to do: STRG has no Requester Pays buckets. |
| `x-amz-expected-bucket-owner` | String | No | Welcome, and has nothing to check: an access key only ever reaches its own account's buckets. |
| `x-amz-source-expected-bucket-owner` | String | No | Welcome, and has nothing to check, as above. |

### Request body

None.

## Part sizes

A copied part follows the same size rules as every other part of a multipart upload:

| | |
| --- | --- |
| Part numbers | 1 to 10,000 |
| Minimum part size | 5,000,000 bytes, except the last part. A smaller one is answered `400 EntityTooSmall` when the upload is completed |
| Maximum part size | 5 GiB |

The limits are the same whether a part is copied or uploaded with [UploadPart](https://docs.strg.com/s3-api/UploadPart),
and the parts of one upload can each be a different size. A source larger than 5 GiB is copied
in ranges with `x-amz-copy-source-range`, one part each.

**Danger: Unfinished uploads are billed**

The parts of an upload that is never completed or aborted stay stored, copied parts included,
and are billed as stored data. Each upload ends well with either CompleteMultipartUpload or
[AbortMultipartUpload](https://docs.strg.com/s3-api/AbortMultipartUpload), and a lifecycle rule that aborts incomplete
multipart uploads catches the ones a crashed script leaves behind -
[Cleaning up abandoned multipart uploads](https://docs.strg.com/lifecycle-policies#cleaning-up-abandoned-multipart-uploads)
shows one.

## Response

A successful copy answers `200 OK` with a `CopyPartResult` document.

**Note: A 200 can still be an error**

As on Amazon S3, a part copy that fails after it has started can answer `200 OK` with an `Error`
document in the body instead of `CopyPartResult`. Its code is one of `InternalError`,
`SlowDown`, `EntityTooSmall`, `InvalidPart`, `InvalidPartOrder` or `NoSuchUpload`, and it comes
without the `x-amz-*` response headers, since no part was stored. The AWS SDKs and the AWS CLI
recognise this and raise an error; code calling the API directly reads the whole body and checks
its root element.

### Response headers

| Name | Description |
| --- | --- |
| `x-amz-copy-source-version-id` | The version ID of the source that was copied. Version IDs are opaque strings, best stored and sent back exactly as they are. |
| `x-amz-server-side-encryption` | `AES256` when the upload is encrypted with STRG-managed keys. |
| `x-amz-server-side-encryption-customer-algorithm` | `AES256` when the upload is encrypted with your own key. |
| `x-amz-server-side-encryption-customer-key-MD5` | The MD5 digest of your key, for SSE-C. |
| `x-amz-request-id` | The request ID, handy when contacting support. |

`x-amz-server-side-encryption-aws-kms-key-id`, `x-amz-server-side-encryption-bucket-key-enabled`
and `x-amz-request-charged` are not part of the answer: STRG has no SSE-KMS and no Requester
Pays buckets.

### Response body

```xml
<?xml version="1.0" encoding="UTF-8"?>
<CopyPartResult>
  <ETag>"85f30635602dc09bd85957a6e82a2c21"</ETag>
  <LastModified>2026-09-26T12:00:00.000Z</LastModified>
</CopyPartResult>
```

| Element | Description |
| --- | --- |
| `ETag` | The part's entity tag. It goes, with the part number, into [CompleteMultipartUpload](https://docs.strg.com/s3-api/CompleteMultipartUpload). |
| `LastModified` | When the part was copied. |
| `ChecksumCRC32`, `ChecksumSHA256`, ... | The part's checksum, when the upload was started with a checksum algorithm (`x-amz-checksum-algorithm` on CreateMultipartUpload). STRG computes it for you from the copied bytes. |

## Errors

These are the answers a part copy can get instead of `200 OK`, and the situation behind each.

| Code | HTTP status | When |
| --- | --- | --- |
| `NoSuchUpload` | 404 | The upload ID does not exist, or the upload has already been completed or aborted. |
| `NoSuchKey` | 404 | The source object, or the version named by `versionId`, does not exist. |
| `NoSuchBucket` | 404 | The destination bucket does not exist, or it is in a region other than the endpoint's - including when your account stores nothing in the endpoint's region yet. |
| `InvalidArgument` | 400 | `partNumber` is not an integer from 1 to 10,000, or `x-amz-copy-source` does not name a valid bucket and key. |
| `PreconditionFailed` | 412 | An `x-amz-copy-source-if-*` condition is not met. STRG passes the conditions along with the copy, and does not guarantee this exact status and code for an unmet one. |
| `NotImplemented` | 501 | The request carries a query parameter this operation does not take, or `x-amz-copy-source` or an `x-amz-copy-source-*` header in the query string. |
| `AccessDenied` | 403 | The access key lacks **Write objects** or **Read objects**, or the source or destination is outside its buckets or object prefix, or an `x-amz-*` header is not signed. |
| `InvalidAccessKeyId` | 403 | The access key does not exist, has expired or been revoked, or was created moments ago and is not yet usable in this region, where you already store data. It becomes usable shortly after. |
| `SignatureDoesNotMatch` | 403 | The request signature does not match. |
| `RequestTimeTooSkewed` | 403 | The request time is more than 15 minutes from the server's. |
| `AuthorizationHeaderMalformed` | 400 | The signature's credential scope names a region other than the endpoint's. |
| `InternalError` | 200 or 500 | A temporary failure, which a retry of the request gets past. |
| `SlowDown` | 503 | Too many requests at once. The AWS CLI and SDKs retry after the `Retry-After` interval. |
| `ServiceUnavailable` | 503 | A temporary failure, which a retry of the request gets past. |

## Differences from Amazon S3

Each row is something STRG does its own way, and what that means for a part copy.

| Aspect | Amazon S3 | STRG |
| --- | --- | --- |
| Source in another region | Supported | A copy stays within one region. Moving an object between regions is a download from one regional endpoint and an upload to the other. |
| Bucket in another region | `301` redirect with `x-amz-bucket-region` | `404 NoSuchBucket`. The bucket's own regional endpoint reaches it. |
| Permissions | `s3:GetObject` on the source, `s3:PutObject` on the destination | One access key holding both **Read objects** and **Write objects**; its buckets and object prefix apply to the source and the destination |
| Minimum part size | 5 MiB | 5,000,000 bytes, except the last part - so a part size chosen for S3 works unchanged |
| `x-amz-copy-source` and `x-amz-copy-source-*` in a presigned URL's query | Accepted | `501 NotImplemented`. They travel as headers |
| SSE-KMS | Supported | `AES256` with STRG-managed keys, or SSE-C, both set on [CreateMultipartUpload](https://docs.strg.com/s3-api/CreateMultipartUpload) |
| `x-amz-expected-bucket-owner`, `x-amz-source-expected-bucket-owner` | Checked against the bucket owner | Welcome, with nothing to check |

## Examples

Copying a whole object into part 1 of a new upload, then completing it - which works for any
source up to 5 GiB:

**AWS CLI**

```bash
ENDPOINT=https://s3.eu-central-1.strg.com
REGION=eu-central-1

UPLOAD_ID=$(aws s3api create-multipart-upload \
  --bucket my-bucket \
  --key videos/big-copy.mp4 \
  --endpoint-url "$ENDPOINT" --region "$REGION" \
  --query UploadId --output text)

ETAG=$(aws s3api upload-part-copy \
  --bucket my-bucket \
  --key videos/big-copy.mp4 \
  --upload-id "$UPLOAD_ID" \
  --part-number 1 \
  --copy-source my-bucket/videos/big.mp4 \
  --endpoint-url "$ENDPOINT" --region "$REGION" \
  --query CopyPartResult.ETag --output json)

aws s3api complete-multipart-upload \
  --bucket my-bucket \
  --key videos/big-copy.mp4 \
  --upload-id "$UPLOAD_ID" \
  --multipart-upload "{\"Parts\":[{\"PartNumber\":1,\"ETag\":$ETAG}]}" \
  --endpoint-url "$ENDPOINT" --region "$REGION"
```

**Python (boto3)**

```python
import boto3

s3 = boto3.client(
    "s3",
    endpoint_url="https://s3.eu-central-1.strg.com",
    region_name="eu-central-1",
)

upload = s3.create_multipart_upload(Bucket="my-bucket", Key="videos/big-copy.mp4")
try:
    part = s3.upload_part_copy(
        Bucket="my-bucket",
        Key="videos/big-copy.mp4",
        UploadId=upload["UploadId"],
        PartNumber=1,
        CopySource={"Bucket": "my-bucket", "Key": "videos/big.mp4"},
    )
    s3.complete_multipart_upload(
        Bucket="my-bucket",
        Key="videos/big-copy.mp4",
        UploadId=upload["UploadId"],
        MultipartUpload={
            "Parts": [{"PartNumber": 1, "ETag": part["CopyPartResult"]["ETag"]}]
        },
    )
except Exception:
    s3.abort_multipart_upload(
        Bucket="my-bucket", Key="videos/big-copy.mp4", UploadId=upload["UploadId"]
    )
    raise
```

**JavaScript (AWS SDK v3)**

```javascript
import {
  AbortMultipartUploadCommand,
  CompleteMultipartUploadCommand,
  CreateMultipartUploadCommand,
  S3Client,
  UploadPartCopyCommand,
} from "@aws-sdk/client-s3";

const client = new S3Client({
  endpoint: "https://s3.eu-central-1.strg.com",
  region: "eu-central-1",
});

const Bucket = "my-bucket";
const Key = "videos/big-copy.mp4";

const { UploadId } = await client.send(new CreateMultipartUploadCommand({ Bucket, Key }));
try {
  const part = await client.send(
    new UploadPartCopyCommand({
      Bucket,
      Key,
      UploadId,
      PartNumber: 1,
      // A key with characters other than letters, digits and / . - _ goes in URL-encoded
      CopySource: "my-bucket/videos/big.mp4",
    }),
  );
  await client.send(
    new CompleteMultipartUploadCommand({
      Bucket,
      Key,
      UploadId,
      MultipartUpload: { Parts: [{ PartNumber: 1, ETag: part.CopyPartResult.ETag }] },
    }),
  );
} catch (error) {
  await client.send(new AbortMultipartUploadCommand({ Bucket, Key, UploadId }));
  throw error;
}
```

Copying an object of any size in 1 GiB ranges with `x-amz-copy-source-range`:

**AWS CLI**

```bash
ENDPOINT=https://s3.eu-central-1.strg.com
REGION=eu-central-1
PART_SIZE=1073741824

SIZE=$(aws s3api head-object \
  --bucket my-bucket \
  --key videos/big.mp4 \
  --endpoint-url "$ENDPOINT" --region "$REGION" \
  --query ContentLength --output text)

UPLOAD_ID=$(aws s3api create-multipart-upload \
  --bucket my-bucket \
  --key videos/big-copy.mp4 \
  --endpoint-url "$ENDPOINT" --region "$REGION" \
  --query UploadId --output text)

PARTS=""
PART=1
for ((START = 0; START < SIZE; START += PART_SIZE)); do
  END=$((START + PART_SIZE - 1))
  if ((END >= SIZE)); then END=$((SIZE - 1)); fi
  ETAG=$(aws s3api upload-part-copy \
    --bucket my-bucket \
    --key videos/big-copy.mp4 \
    --upload-id "$UPLOAD_ID" \
    --part-number "$PART" \
    --copy-source my-bucket/videos/big.mp4 \
    --copy-source-range "bytes=$START-$END" \
    --endpoint-url "$ENDPOINT" --region "$REGION" \
    --query CopyPartResult.ETag --output json)
  PARTS="$PARTS${PARTS:+,}{\"PartNumber\":$PART,\"ETag\":$ETAG}"
  PART=$((PART + 1))
done

aws s3api complete-multipart-upload \
  --bucket my-bucket \
  --key videos/big-copy.mp4 \
  --upload-id "$UPLOAD_ID" \
  --multipart-upload "{\"Parts\":[$PARTS]}" \
  --endpoint-url "$ENDPOINT" --region "$REGION"
```

**Python (boto3)**

```python
import boto3

PART_SIZE = 1024**3

s3 = boto3.client(
    "s3",
    endpoint_url="https://s3.eu-central-1.strg.com",
    region_name="eu-central-1",
)

size = s3.head_object(Bucket="my-bucket", Key="videos/big.mp4")["ContentLength"]
upload = s3.create_multipart_upload(Bucket="my-bucket", Key="videos/big-copy.mp4")
try:
    parts = []
    for number, start in enumerate(range(0, size, PART_SIZE), start=1):
        end = min(start + PART_SIZE, size) - 1
        part = s3.upload_part_copy(
            Bucket="my-bucket",
            Key="videos/big-copy.mp4",
            UploadId=upload["UploadId"],
            PartNumber=number,
            CopySource={"Bucket": "my-bucket", "Key": "videos/big.mp4"},
            CopySourceRange=f"bytes={start}-{end}",
        )
        parts.append({"PartNumber": number, "ETag": part["CopyPartResult"]["ETag"]})
    s3.complete_multipart_upload(
        Bucket="my-bucket",
        Key="videos/big-copy.mp4",
        UploadId=upload["UploadId"],
        MultipartUpload={"Parts": parts},
    )
except Exception:
    s3.abort_multipart_upload(
        Bucket="my-bucket", Key="videos/big-copy.mp4", UploadId=upload["UploadId"]
    )
    raise
```

**JavaScript (AWS SDK v3)**

```javascript
import {
  AbortMultipartUploadCommand,
  CompleteMultipartUploadCommand,
  CreateMultipartUploadCommand,
  HeadObjectCommand,
  S3Client,
  UploadPartCopyCommand,
} from "@aws-sdk/client-s3";

const PART_SIZE = 1024 ** 3;

const client = new S3Client({
  endpoint: "https://s3.eu-central-1.strg.com",
  region: "eu-central-1",
});

const Bucket = "my-bucket";
const Key = "videos/big-copy.mp4";

const { ContentLength: size } = await client.send(
  new HeadObjectCommand({ Bucket: "my-bucket", Key: "videos/big.mp4" }),
);
const { UploadId } = await client.send(new CreateMultipartUploadCommand({ Bucket, Key }));
try {
  const parts = [];
  for (let start = 0, number = 1; start < size; start += PART_SIZE, number += 1) {
    const end = Math.min(start + PART_SIZE, size) - 1;
    const part = await client.send(
      new UploadPartCopyCommand({
        Bucket,
        Key,
        UploadId,
        PartNumber: number,
        CopySource: "my-bucket/videos/big.mp4",
        CopySourceRange: `bytes=${start}-${end}`,
      }),
    );
    parts.push({ PartNumber: number, ETag: part.CopyPartResult.ETag });
  }
  await client.send(
    new CompleteMultipartUploadCommand({ Bucket, Key, UploadId, MultipartUpload: { Parts: parts } }),
  );
} catch (error) {
  await client.send(new AbortMultipartUploadCommand({ Bucket, Key, UploadId }));
  throw error;
}
```

## Related

- [CreateMultipartUpload](https://docs.strg.com/s3-api/CreateMultipartUpload)
- [UploadPart](https://docs.strg.com/s3-api/UploadPart)
- [CompleteMultipartUpload](https://docs.strg.com/s3-api/CompleteMultipartUpload)
- [AbortMultipartUpload](https://docs.strg.com/s3-api/AbortMultipartUpload)
- [ListParts](https://docs.strg.com/s3-api/ListParts)
- [ListMultipartUploads](https://docs.strg.com/s3-api/ListMultipartUploads)
- [CopyObject](https://docs.strg.com/s3-api/CopyObject)
- [Lifecycle policies](https://docs.strg.com/lifecycle-policies)
- [Access keys](https://docs.strg.com/access-keys)
- [S3 API overview](https://docs.strg.com/s3-api/overview)
