CopyObject
CopyObject makes a new object from one that is already stored, without the bytes ever leaving STRG - nothing is downloaded and uploaded again. The source can sit in the same bucket or in another bucket in the same region. It is the building block behind renaming an object (a copy, then a DeleteObject of the original), changing its metadata, and bringing back an earlier version as the current one.
For a large object, UploadPartCopy copies it in parts within a multipart upload.
- 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.
x-amz-tagging,x-amz-checksum-algorithm,If-Match,If-None-Match, event holds, SSE-KMS andx-amz-grant-*get a501 NotImplementedanswer, so a client knows straight away.x-amz-tagging-directiveis welcome and has nothing to do: objects carry no tags.x-amz-aclisprivateorpublic-read, matching the destination bucket - access is set per bucket, so every object in it shares the bucket's setting.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.- A destination key with a
.or..path segment (a/../b) is answered400 InvalidURI, and nothing is stored.
When you'd use it
- Renaming and reorganizing. A photo app that receives uploads under
incoming/moves each one tophotos/2024/once it has been processed: a CopyObject to the new key, then a DeleteObject of the old one. - Fixing metadata. A static site's assets went up with the wrong
Content-Type, or a longerCache-Controlwould suit them better. Copying each object onto itself withx-amz-metadata-directive: REPLACEsets the new headers without re-uploading anything. - Bringing back an earlier version. Every bucket keeps the versions of its objects, so an agent that overwrote the notes file it keeps its memory in can copy yesterday's version onto the same key, and that version is current again.
- Promoting a release. A deploy script copies a tested build from a private
stagingbucket into the public bucket its users download from, both in the same region, without the files passing through the CI machine.
Request
PUT /photos/2024/cat-copy.jpg 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/photos/2024/cat.jpg
Path-style, the same request is PUT /my-bucket/photos/2024/cat-copy.jpg on
s3.eu-central-1.strg.com - Addressing has both styles.
The request has no body. Every x-amz-* header, x-amz-copy-source included, is part of the
signature; the AWS CLI and SDKs take care of that.
Permissions
A copy reads one object and writes another, so the access key needs both of these:
| Permission | Dashboard label | For |
|---|---|---|
objects:write | Write objects | Writing the destination object |
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.
Setting Object Lock on the copy takes one more permission per header:
| Header | Also needs |
|---|---|
x-amz-object-lock-mode or x-amz-object-lock-retain-until-date | Set retention (object-retention:write) |
x-amz-object-lock-legal-hold | Set legal holds (object-legal-hold:write) |
Access keys has the details.
URI parameters
| Name | Type | Required | Description |
|---|---|---|---|
Bucket | String | Yes | The destination bucket. |
Key | String | Yes | The destination object key. A key with a . or .. path segment is answered 400 InvalidURI. |
CopyObject takes no query parameters. A query parameter S3 does not define for CopyObject gets a
501 NotImplemented answer.
Request headers
The headers below are every one S3 defines for CopyObject, each with what STRG does with it.
| Name | Type | Required | Description |
|---|---|---|---|
x-amz-copy-source | String | Yes | The source, as my-bucket/photos/2024/cat.jpg (a leading / is optional), the key URL-encoded. Appending ?versionId=VersionId copies that version; without it, the current version is copied. 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-metadata-directive | String | No | COPY (the default) keeps the source's metadata and content headers; REPLACE takes the ones in this request instead. |
x-amz-meta-* | String | No | User metadata for the copy. Applied with x-amz-metadata-directive: REPLACE. |
Cache-Control | String | No | Stored with the copy. Applied with REPLACE. |
Content-Disposition | String | No | Stored with the copy. Applied with REPLACE. |
Content-Encoding | String | No | Stored with the copy. Applied with REPLACE. |
Content-Language | String | No | Stored with the copy. Applied with REPLACE. |
Content-Type | String | No | Stored with the copy. Applied with REPLACE. |
Expires | Timestamp | No | Stored with the copy. Applied with REPLACE. |
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. |
If-Match | String | No | STRG answers 501 NotImplemented. |
If-None-Match | String | No | STRG answers 501 NotImplemented. |
x-amz-acl | String | No | private or public-read, matching the destination bucket's Bucket Protection (private for a protected bucket). Any other canned ACL is answered 501 NotImplemented. Access is set per bucket, never per object. |
x-amz-grant-full-control | String | No | STRG answers 501 NotImplemented. |
x-amz-grant-read | String | No | STRG answers 501 NotImplemented. |
x-amz-grant-read-acp | String | No | STRG answers 501 NotImplemented. |
x-amz-grant-write-acp | String | No | STRG answers 501 NotImplemented. |
x-amz-tagging | String | No | STRG answers 501 NotImplemented: objects carry no tags. |
x-amz-tagging-directive | String | No | Welcome, whatever its value, and has nothing to do: objects carry no tags. |
x-amz-object-annotation-directive | String | No | STRG answers 501 NotImplemented. |
x-amz-checksum-algorithm | String | No | STRG answers 501 NotImplemented. The copy keeps the source's checksum algorithm, if it has one. |
x-amz-server-side-encryption | String | No | AES256 - server-side encryption with STRG-managed keys. aws:kms and aws:kms:dsse are answered 501 NotImplemented. |
x-amz-server-side-encryption-aws-kms-key-id | String | No | STRG answers 501 NotImplemented. |
x-amz-server-side-encryption-context | String | No | STRG answers 501 NotImplemented. |
x-amz-server-side-encryption-bucket-key-enabled | Boolean | No | STRG answers 501 NotImplemented. |
x-amz-server-side-encryption-customer-algorithm | String | No | AES256, to encrypt the copy with your own key (SSE-C). |
x-amz-server-side-encryption-customer-key | String | No | The base64 key to encrypt the copy with (SSE-C). |
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-object-lock-mode | String | No | GOVERNANCE or COMPLIANCE, for a destination bucket with Object Lock enabled. Needs Set retention. Object Lock explains the modes. |
x-amz-object-lock-retain-until-date | Timestamp | No | When the copy's retention ends. Needs Set retention. |
x-amz-object-lock-legal-hold | String | No | ON or OFF. Needs Set legal holds. |
x-amz-object-lock-event-hold | String | No | STRG answers 501 NotImplemented. |
x-amz-object-lock-event-hold-duration-days | Integer | No | STRG answers 501 NotImplemented. |
x-amz-object-lock-event-hold-duration-years | Integer | No | STRG answers 501 NotImplemented. |
x-amz-storage-class | String | No | STRG has one storage class, STANDARD. |
x-amz-website-redirect-location | String | No | STRG does not host bucket websites, so this header has no effect to count on. |
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.
Response
A successful copy answers 200 OK with a CopyObjectResult document.
Response headers
| Name | Description |
|---|---|
x-amz-version-id | The version ID of the new copy. Buckets are always versioned, so every copy creates a new version. |
x-amz-copy-source-version-id | The version ID of the source that was copied. |
x-amz-server-side-encryption | AES256 when the copy is encrypted with STRG-managed keys. |
x-amz-server-side-encryption-customer-algorithm | AES256 when the copy 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. |
Version IDs are opaque strings, best stored and sent back exactly as they are.
x-amz-server-side-encryption-aws-kms-key-id, x-amz-server-side-encryption-context,
x-amz-server-side-encryption-bucket-key-enabled and x-amz-request-charged are not part of the
answer, and x-amz-expiration is not something to count on.
Response body
<?xml version="1.0" encoding="UTF-8"?>
<CopyObjectResult>
<ETag>"85f30635602dc09bd85957a6e82a2c21"</ETag>
<LastModified>2026-09-26T12:00:00.000Z</LastModified>
</CopyObjectResult>
| Element | Description |
|---|---|
ETag | The entity tag of the new copy. |
LastModified | When the copy was created. |
Errors
These are the answers a copy can get instead of 200 OK, and the situation behind each.
| Code | HTTP status | When |
|---|---|---|
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. |
InvalidArgument | 400 | x-amz-copy-source does not name a valid bucket and key. |
InvalidURI | 400 | The destination key contains a . or .. path segment, such as a/../b. Nothing is stored. |
NotImplemented | 501 | The request carries a header or query parameter STRG does not offer - Request headers lists each one. |
AccessDenied | 403 | The access key lacks Write objects or Read objects, or the Object Lock permission a lock header needs, or the source or destination is outside its buckets or object prefix. Also an expired presigned URL, and an unsigned x-amz-* header. |
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. |
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 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 |
| Tags | x-amz-tagging and x-amz-tagging-directive copy or replace the tag set | Objects carry no tags: x-amz-tagging is answered 501, and x-amz-tagging-directive has nothing to do |
| Checksum algorithm | x-amz-checksum-algorithm can change it | 501 NotImplemented. The copy keeps the source's checksum |
If-Match / If-None-Match on the destination | Supported | 501 NotImplemented |
| ACLs | Any canned ACL and x-amz-grant-* | private or public-read, matching the destination bucket; grants are answered 501 |
| SSE-KMS | Supported | 501 NotImplemented. AES256 and SSE-C both encrypt the copy |
| Event holds, annotations | Supported | 501 NotImplemented |
x-amz-copy-source in a presigned URL's query | Accepted | 501 NotImplemented. It travels as a header |
x-amz-expected-bucket-owner, x-amz-source-expected-bucket-owner | Checked against the bucket owner | Welcome, with nothing to check |
Keys with a . or .. path segment (a/../b) | Supported | 400 InvalidURI as the destination key; nothing is stored |
Examples
Copying photos/2024/cat.jpg to photos/2024/cat-copy.jpg in the same bucket:
- AWS CLI
- Python (boto3)
- JavaScript (AWS SDK v3)
aws s3api copy-object \
--bucket my-bucket \
--key photos/2024/cat-copy.jpg \
--copy-source my-bucket/photos/2024/cat.jpg \
--endpoint-url https://s3.eu-central-1.strg.com \
--region eu-central-1
import boto3
s3 = boto3.client(
"s3",
endpoint_url="https://s3.eu-central-1.strg.com",
region_name="eu-central-1",
)
response = s3.copy_object(
Bucket="my-bucket",
Key="photos/2024/cat-copy.jpg",
CopySource={"Bucket": "my-bucket", "Key": "photos/2024/cat.jpg"},
)
print(response["CopyObjectResult"]["ETag"], response.get("VersionId"))
import { CopyObjectCommand, S3Client } from "@aws-sdk/client-s3";
const client = new S3Client({
endpoint: "https://s3.eu-central-1.strg.com",
region: "eu-central-1",
});
const response = await client.send(
new CopyObjectCommand({
Bucket: "my-bucket",
Key: "photos/2024/cat-copy.jpg",
// A key with characters other than letters, digits and / . - _ goes in URL-encoded
CopySource: "my-bucket/photos/2024/cat.jpg",
}),
);
console.log(response.CopyObjectResult?.ETag, response.VersionId);
Changing an object's metadata in place is a copy onto itself with REPLACE:
- AWS CLI
- Python (boto3)
- JavaScript (AWS SDK v3)
aws s3api copy-object \
--bucket my-bucket \
--key photos/2024/cat.jpg \
--copy-source my-bucket/photos/2024/cat.jpg \
--metadata-directive REPLACE \
--content-type image/jpeg \
--cache-control "max-age=86400" \
--metadata photographer=alex \
--endpoint-url https://s3.eu-central-1.strg.com \
--region eu-central-1
import boto3
s3 = boto3.client(
"s3",
endpoint_url="https://s3.eu-central-1.strg.com",
region_name="eu-central-1",
)
s3.copy_object(
Bucket="my-bucket",
Key="photos/2024/cat.jpg",
CopySource={"Bucket": "my-bucket", "Key": "photos/2024/cat.jpg"},
MetadataDirective="REPLACE",
ContentType="image/jpeg",
CacheControl="max-age=86400",
Metadata={"photographer": "alex"},
)
import { CopyObjectCommand, S3Client } from "@aws-sdk/client-s3";
const client = new S3Client({
endpoint: "https://s3.eu-central-1.strg.com",
region: "eu-central-1",
});
await client.send(
new CopyObjectCommand({
Bucket: "my-bucket",
Key: "photos/2024/cat.jpg",
CopySource: "my-bucket/photos/2024/cat.jpg",
MetadataDirective: "REPLACE",
ContentType: "image/jpeg",
CacheControl: "max-age=86400",
Metadata: { photographer: "alex" },
}),
);
Bringing back an earlier version as the current one is a copy of that version onto the same key. ListObjectVersions lists the version IDs:
- AWS CLI
- Python (boto3)
- JavaScript (AWS SDK v3)
aws s3api copy-object \
--bucket my-bucket \
--key photos/2024/cat.jpg \
--copy-source "my-bucket/photos/2024/cat.jpg?versionId=$VERSION_ID" \
--endpoint-url https://s3.eu-central-1.strg.com \
--region eu-central-1
import os
import boto3
s3 = boto3.client(
"s3",
endpoint_url="https://s3.eu-central-1.strg.com",
region_name="eu-central-1",
)
s3.copy_object(
Bucket="my-bucket",
Key="photos/2024/cat.jpg",
CopySource={
"Bucket": "my-bucket",
"Key": "photos/2024/cat.jpg",
"VersionId": os.environ["VERSION_ID"],
},
)
import { CopyObjectCommand, S3Client } from "@aws-sdk/client-s3";
const client = new S3Client({
endpoint: "https://s3.eu-central-1.strg.com",
region: "eu-central-1",
});
await client.send(
new CopyObjectCommand({
Bucket: "my-bucket",
Key: "photos/2024/cat.jpg",
CopySource: `my-bucket/photos/2024/cat.jpg?versionId=${encodeURIComponent(process.env.VERSION_ID)}`,
}),
);