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, takes its parts from UploadPartCopy and UploadPart in any mix, and is finished by CompleteMultipartUpload.
It is the way to copy an object too large for a single CopyObject, and to build a new object out of ranges of existing ones.
- 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-sourceand thex-amz-copy-source-*headers travel as headers: in a presigned URL's query string they get a501 NotImplementedanswer, so a client knows straight away.x-amz-expected-bucket-ownerandx-amz-source-expected-bucket-ownerare 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 cpbetween two buckets and boto3's managedcopy()call UploadPartCopy for you, so it is often at work without being called by name.
Request
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 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 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 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 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,
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.
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, and a lifecycle rule that aborts incomplete multipart uploads catches the ones a crashed script leaves behind - Cleaning up abandoned multipart uploads shows one.
Response
A successful copy answers 200 OK with a CopyPartResult document.
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 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. |
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 |
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
- Python (boto3)
- JavaScript (AWS SDK v3)
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"
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
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
- Python (boto3)
- JavaScript (AWS SDK v3)
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"
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
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;
}