Skip to content

Copying objects

CopyObject duplicates an object server-side, so the bytes never travel back to your process. Use it to move a key, branch a version, change metadata, or apply encryption on the copy. For objects too large to copy in one request, the S3 UploadPartCopy assembles a new object from ranges of existing ones.

Copies stay within the tenant. On the native path the source resolves under the token's tenant, so a cross-tenant copy is impossible by construction.

For the operation matrix, see the native data-plane reference and the S3 operations reference.

Copy within a bucket

Name the destination (bucket, key) and the source (bucket, key, and an optional source version id). Leave the source version unset to copy the current version.

ts
const res = await native.copyObject("reports", "q1/summary-copy.txt", {
  sourceBucket: "reports",
  sourceKey: "q1/summary.txt",
});
console.log(res.etag, res.versionId);
go
res, err := native.CopyObject(ctx, lockwellnative.CopyObjectInput{
    Bucket:       "reports",
    Key:          "q1/summary-copy.txt",
    SourceBucket: "reports",
    SourceKey:    "q1/summary.txt",
})
fmt.Println(res.ETag, res.VersionID)
java
import com.lockwell.sdk.nativeapi.NativeTypes.CopyOptions;

// copyObject(destBucket, destKey, sourceBucket, sourceKey, opts)
var res = native.copyObject("reports", "q1/summary-copy.txt",
    "reports", "q1/summary.txt", new CopyOptions());
System.out.println(res.etag() + " " + res.versionId());

The S3 client names the source first, then the destination:

ts
const res = await s3.copyObject("reports", "q1/summary.txt", "", "reports", "q1/summary-copy.txt");
console.log(res.etag, res.versionId);
go
// CopyObject(srcBucket, srcKey, srcVersionID, dstBucket, dstKey, opts...)
res, err := s3.CopyObject(ctx, "reports", "q1/summary.txt", "", "reports", "q1/summary-copy.txt")
fmt.Println(res.ETag, res.VersionID)
java
// copyObject(srcBucket, srcKey, srcVersionId, dstBucket, dstKey, ifMatch)
var res = s3.copyObject("reports", "q1/summary.txt", null, "reports", "q1/summary-copy.txt", null);
System.out.println(res.etag() + " " + res.versionId());

Copy across buckets

The destination bucket can differ from the source bucket. Both must belong to the same tenant.

ts
await native.copyObject("archive", "imports/upload.csv", {
  sourceBucket: "inbox",
  sourceKey: "upload.csv",
});
go
_, err := native.CopyObject(ctx, lockwellnative.CopyObjectInput{
    Bucket: "archive", Key: "imports/upload.csv",
    SourceBucket: "inbox", SourceKey: "upload.csv",
})
java
native.copyObject("archive", "imports/upload.csv", "inbox", "upload.csv", new CopyOptions());
ts
await s3.copyObject("inbox", "upload.csv", "", "archive", "imports/upload.csv");
go
_, err := s3.CopyObject(ctx, "inbox", "upload.csv", "", "archive", "imports/upload.csv")
java
s3.copyObject("inbox", "upload.csv", null, "archive", "imports/upload.csv", null);

Copy-source conditionals

Gate the copy on the state of the source object. The copy proceeds only if the condition holds; otherwise it fails with a 412 PreconditionFailed.

ConditionEffect
If-Match <etag>Copy only if the source ETag matches.
If-None-Match <etag>Copy only if the source ETag does not match.
If-Modified-Since <http-date>Copy only if the source changed since the date.
If-Unmodified-Since <http-date>Copy only if the source has not changed since the date.

The native client takes all four as ifMatch / ifNoneMatch / ifModifiedSince / ifUnmodifiedSince. The S3 client takes them as WithCopyIf* options.

The Java S3 copyObject accepts only an ifMatch argument. For the other three source conditionals on

Java, use the native client. :::

ts
await native.copyObject("reports", "q1/pinned.txt", {
  sourceBucket: "reports",
  sourceKey: "q1/summary.txt",
  ifUnmodifiedSince: "Wed, 01 Jan 2026 00:00:00 GMT",
});
go
_, err := native.CopyObject(ctx, lockwellnative.CopyObjectInput{
    Bucket: "reports", Key: "q1/pinned.txt",
    SourceBucket: "reports", SourceKey: "q1/summary.txt",
    IfUnmodifiedSince: "Wed, 01 Jan 2026 00:00:00 GMT",
})
java
// The native client covers all four source conditionals:
native.copyObject("reports", "q1/pinned.txt", "reports", "q1/summary.txt",
    new CopyOptions().ifUnmodifiedSince("Wed, 01 Jan 2026 00:00:00 GMT"));

::::

ts
// Copy only if the source still has the ETag we expect:
await s3.copyObject("reports", "q1/summary.txt", "", "reports", "q1/pinned.txt", {
  ifMatch: '"d41d8cd98f00b204e9800998ecf8427e"',
});
go
_, err := s3.CopyObject(ctx, "reports", "q1/summary.txt", "", "reports", "q1/pinned.txt",
    lockwellsdk.WithCopyIfMatch(`"d41d8cd98f00b204e9800998ecf8427e"`),
)
if lockwellsdk.IsNotFound(err) { /* ... */ }
java
// S3 takes ifMatch directly (the last argument):
var res = s3.copyObject("reports", "q1/summary.txt", null, "reports", "q1/pinned.txt",
    "\"d41d8cd98f00b204e9800998ecf8427e\"");

Destination conditionals (native client)

The native copy also gates on the destination atomically at the commit: requireAbsent copies only when the destination key does not exist (a copy-time create-only), and requireMatchEtag copies only when the destination's current ETag matches (a copy-time overwrite-only). These pair naturally with the conditional write patterns.

ts
// Branch a snapshot, but never clobber an existing one:
await native.copyObject("snapshots", "latest.json", {
  sourceBucket: "live",
  sourceKey: "state.json",
  requireAbsent: true, // 412 if snapshots/latest.json already exists
});
go
_, err := native.CopyObject(ctx, lockwellnative.CopyObjectInput{
    Bucket: "snapshots", Key: "latest.json",
    SourceBucket: "live", SourceKey: "state.json",
    RequireAbsent: true, // 412 if snapshots/latest.json already exists
})
java
// ifAbsent() sets the destination require-absent precondition:
native.copyObject("snapshots", "latest.json", "live", "state.json",
    new CopyOptions().ifAbsent()); // 412 if snapshots/latest.json already exists

Metadata directive: COPY vs REPLACE

By default a copy inherits the source's user metadata and content type (metadataDirective: "COPY"). Pass REPLACE to set fresh metadata and content type from the request instead.

On the native client, set the directive explicitly. On the S3 client, supplying copy metadata implies REPLACE (the SDK sets the directive for you when you pass metadata).

ts
// Explicit REPLACE with a new content type + metadata:
await native.copyObject("reports", "q1/tagged.txt", {
  sourceBucket: "reports",
  sourceKey: "q1/summary.txt",
  metadataDirective: "REPLACE",
  contentType: "text/plain; charset=utf-8",
  metadata: { reviewed: "true" },
});
go
_, err := native.CopyObject(ctx, lockwellnative.CopyObjectInput{
    Bucket: "reports", Key: "q1/tagged.txt",
    SourceBucket: "reports", SourceKey: "q1/summary.txt",
    MetadataDirective: "REPLACE",
    ContentType:       "text/plain; charset=utf-8",
    Metadata:          map[string]string{"reviewed": "true"},
})
java
import java.util.Map;

// replaceMetadata sets the directive to REPLACE and the new values:
native.copyObject("reports", "q1/tagged.txt", "reports", "q1/summary.txt",
    new CopyOptions().replaceMetadata("text/plain; charset=utf-8", Map.of("reviewed", "true")));
ts
// Passing metadata replaces the destination's metadata (sets REPLACE):
await s3.copyObject("reports", "q1/summary.txt", "", "reports", "q1/tagged.txt", {
  metadata: { reviewed: "true" },
});
go
// WithCopyMetadata replaces the destination metadata (sets REPLACE):
_, err := s3.CopyObject(ctx, "reports", "q1/summary.txt", "", "reports", "q1/tagged.txt",
    lockwellsdk.WithCopyMetadata(map[string]string{"reviewed": "true"}),
)
java
import java.util.Map;

// The S3 copyObject does not take a metadata argument; use the native client for
// a metadata-replacing copy.

With COPY (the default), the source's content type and every X-Lockwell-Meta-* / x-amz-meta-* entry carry over unchanged.

Encryption on the copy (S3 client)

The native store is always encrypted at rest, so the native copy has no SSE option. On the S3 client WithCopyServerSideEncryption / serverSideEncryption: true requests SSE-S3 for the destination object, so you can encrypt a previously unencrypted object by copying it onto itself or to a new key.

go
_, err := s3.CopyObject(ctx, "vault", "ledger.json", "", "vault", "ledger.json",
    lockwellsdk.WithCopyServerSideEncryption(),
)

Large objects: UploadPartCopy (S3 client)

A single CopyObject copies the whole object in one request. For an object too large for that, or to assemble a new object from ranges of existing ones, copy parts: start a multipart upload on the destination, then call UploadPartCopy for each part, naming a source object and an optional byte range. Complete the upload with the returned part ETags.

UploadPartCopy lives on the S3 client. The native multipart path uploads part bytes rather than copying ranges; see multipart uploads. Each part except the last must meet the multipart minimum part size.

ts
const { uploadId } = await s3.createMultipartUpload("archive", "merged.bin");

// Each part copies a byte range from a source object (no bytes through your app):
const p1 = await s3.uploadPartCopy("inbox", "a.bin", "", "archive", "merged.bin", uploadId, 1, "bytes=0-5242879");
const p2 = await s3.uploadPartCopy("inbox", "b.bin", "", "archive", "merged.bin", uploadId, 2, ""); // whole object

await s3.completeMultipartUpload("archive", "merged.bin", uploadId, [
  { partNumber: 1, etag: p1.etag },
  { partNumber: 2, etag: p2.etag },
]);
go
mu, _ := s3.CreateMultipartUpload(ctx, "archive", "merged.bin")

// UploadPartCopy(srcBucket, srcKey, srcVersionID, dstBucket, dstKey, uploadID, partNumber, byteRange)
e1, _ := s3.UploadPartCopy(ctx,
    "inbox", "a.bin", "", "archive", "merged.bin", mu.UploadID, 1, "bytes=0-5242879")
e2, _ := s3.UploadPartCopy(ctx,
    "inbox", "b.bin", "", "archive", "merged.bin", mu.UploadID, 2, "") // whole object

_, _ = s3.CompleteMultipartUpload(ctx, "archive", "merged.bin", mu.UploadID,
    []lockwellsdk.CompletedPart{{PartNumber: 1, ETag: e1.ETag}, {PartNumber: 2, ETag: e2.ETag}})
java
import java.util.List;

var mu = s3.createMultipartUpload("archive", "merged.bin", null);

// uploadPartCopy(bucket, key, uploadId, partNumber, srcBucket, srcKey, srcVersionId, copySourceRange)
String e1 = s3.uploadPartCopy("archive", "merged.bin", mu.uploadId(), 1,
    "inbox", "a.bin", null, "bytes=0-5242879");
String e2 = s3.uploadPartCopy("archive", "merged.bin", mu.uploadId(), 2,
    "inbox", "b.bin", null, null);

s3.completeMultipartUpload("archive", "merged.bin", mu.uploadId(),
    null, List.of(e1, e2));

Next steps

Released under the Apache-2.0 License. License