Copy and Move S3 Objects With AWS SDK v3

Rows of stacked cardboard boxes on tall metal warehouse shelves

Photo by Rana Kaname on Unsplash

To copy an S3 object with AWS SDK v3, send a CopyObjectCommand from @aws-sdk/client-s3 with the destination Bucket and Key and a URL-encoded CopySource of source-bucket/source-key. It copies up to 5 GB inside S3 without downloading anything. Larger objects need a multipart copy with UploadPartCopyCommand, and a move is a copy followed by DeleteObjectCommand.

This guide is for Node.js and TypeScript developers who need to duplicate, rename, re-tier or relocate objects: archive last year’s reports to another bucket, rename a key, move uploads out of a staging prefix, or copy data into another Region or account. You’ll get a typed module that covers every way to copy an object in S3 with AWS SDK v3, including objects over 5 GB and whole prefixes, and the settings that silently change during a copy.

Copying and listing go together, so the guide to list all objects in an S3 bucket with AWS SDK v3 is useful background for the bulk section.

Prerequisites

What CopyObject copies, and what it changes

CopyObject is a server-side operation: S3 reads the source and writes the destination, and your process never holds the bytes. A few defaults decide what the copy looks like:

Setting Default during a copy How to change it
User metadata and Content-Type Copied (MetadataDirective: "COPY") REPLACE plus every value you want to keep
Tags Copied (TaggingDirective: "COPY") REPLACE with Tagging
Storage class STANDARD unless you set StorageClass Pass the class you want
Encryption The destination bucket’s default encryption ServerSideEncryption and SSEKMSKeyId
Creation date, ETag for multipart copies New values Not changeable

The storage class default catches people out: copy an object out of STANDARD_IA without StorageClass and the copy lands in STANDARD, at a different price. The module below passes the source’s class through unless you ask for another; the guide to S3 Standard vs Intelligent-Tiering costs helps you pick one on purpose.

CopySource must be URL-encoded. Keys with spaces, +, #, ? or non-ASCII characters break unless each path segment is percent-encoded as described in RFC 3986, section 2.1. Encoding each segment separately keeps the / separators, and a specific version is selected by appending ?versionId=.

How to copy an S3 object with AWS SDK v3, step by step

  1. Create a client in the destination bucket’s RegionThe copy request goes to the destination; reads and deletes on the source go to the source’s Region.
  2. Read the source with HeadObjectCommandIts size decides single or multipart copy, and its ETag, StorageClass, ContentType and Metadata are what you’d otherwise lose.
  3. Build CopySourcebucket/ plus the key encoded segment by segment, plus ?versionId= if you copy a specific version.
  4. Send CopyObjectCommand up to 5 GBAdd CopySourceIfMatch with the ETag you read, so a source replaced in between fails instead of copying the wrong data.
  5. Above 5 GB, copy in partsCreateMultipartUploadCommand, one UploadPartCopyCommand per byte range, then CompleteMultipartUploadCommand, and abort on any error.
  6. For a move, delete the source lastOnly after the copy succeeded.

Example: a typed copy and move module

s3-copy.ts

// s3-copy.ts
// Copy and move S3 objects with AWS SDK for JavaScript v3: CopyObject up to 5 GB, UploadPartCopy
// multipart copies above that, "move" as copy + delete, and a bulk prefix copy with bounded concurrency.
import {
  AbortMultipartUploadCommand,
  CompleteMultipartUploadCommand,
  CopyObjectCommand,
  CreateMultipartUploadCommand,
  DeleteObjectCommand,
  HeadObjectCommand,
  paginateListObjectsV2,
  S3Client,
  UploadPartCopyCommand,
  type CompletedPart,
  type StorageClass,
} from "@aws-sdk/client-s3";

// One client per Region. A copy is sent to the destination bucket's Region; reads and deletes go to the source's.
const clients = new Map<string, S3Client>();
function clientFor(region?: string): S3Client {
  const id = region ?? "default";
  let client = clients.get(id);
  if (!client) {
    client = new S3Client(region ? { region } : {}); // no region: AWS_REGION or the profile's Region
    clients.set(id, client);
  }
  return client;
}

const SINGLE_COPY_LIMIT = 5 * 1024 ** 3; // 5 GiB: the most CopyObject accepts in one request
const PART_SIZE = 512 * 1024 ** 2; // 512 MiB parts; each part must be 5 MiB to 5 GiB (the last can be smaller)

export interface ObjectRef {
  bucket: string;
  key: string;
  versionId?: string;
  region?: string; // the bucket's Region, when it differs from the default
}

export interface CopyOptions {
  storageClass?: StorageClass; // CopyObject writes STANDARD unless you set this
  metadata?: Record<string, string>; // set = MetadataDirective REPLACE; you must then resend ContentType too
  contentType?: string;
}

/** "bucket/key" with the key percent-encoded per segment, plus ?versionId= when given. */
export function copySource({ bucket, key, versionId }: ObjectRef): string {
  const encodedKey = key.split("/").map(encodeURIComponent).join("/");
  return `${bucket}/${encodedKey}${versionId ? `?versionId=${encodeURIComponent(versionId)}` : ""}`;
}

/** Copy one object of any size. Uses CopyObject up to 5 GB and a multipart copy above that. */
export async function copyObject(from: ObjectRef, to: ObjectRef, opts: CopyOptions = {}): Promise<string | undefined> {
  const src = clientFor(from.region);
  const dst = clientFor(to.region);
  const head = await src.send(new HeadObjectCommand({ Bucket: from.bucket, Key: from.key, VersionId: from.versionId }));
  const size = head.ContentLength ?? 0;
  const replace = opts.metadata !== undefined;

  if (size <= SINGLE_COPY_LIMIT) {
    const res = await dst.send(
      new CopyObjectCommand({
        Bucket: to.bucket,
        Key: to.key,
        CopySource: copySource(from),
        CopySourceIfMatch: head.ETag, // fail instead of copying a newer object that replaced it meanwhile
        StorageClass: opts.storageClass ?? (head.StorageClass as StorageClass | undefined),
        MetadataDirective: replace ? "REPLACE" : "COPY",
        Metadata: replace ? opts.metadata : undefined,
        ContentType: replace ? (opts.contentType ?? head.ContentType) : undefined,
      }),
    );
    return res.CopyObjectResult?.ETag;
  }

  // Over 5 GB: CreateMultipartUpload does not read the source, so carry its metadata across yourself.
  const { UploadId } = await dst.send(
    new CreateMultipartUploadCommand({
      Bucket: to.bucket,
      Key: to.key,
      ContentType: opts.contentType ?? head.ContentType,
      Metadata: opts.metadata ?? head.Metadata,
      StorageClass: opts.storageClass ?? (head.StorageClass as StorageClass | undefined),
    }),
  );
  try {
    const ranges: { part: number; range: string }[] = [];
    for (let start = 0, part = 1; start < size; start += PART_SIZE, part++) {
      ranges.push({ part, range: `bytes=${start}-${Math.min(start + PART_SIZE, size) - 1}` });
    }
    const parts: CompletedPart[] = [];
    await runWithConcurrency(ranges, 8, async ({ part, range }) => {
      const res = await dst.send(
        new UploadPartCopyCommand({
          Bucket: to.bucket,
          Key: to.key,
          UploadId,
          PartNumber: part,
          CopySource: copySource(from),
          CopySourceRange: range,
          CopySourceIfMatch: head.ETag,
        }),
      );
      parts.push({ PartNumber: part, ETag: res.CopyPartResult?.ETag });
    });
    parts.sort((a, b) => (a.PartNumber ?? 0) - (b.PartNumber ?? 0));
    const done = await dst.send(
      new CompleteMultipartUploadCommand({ Bucket: to.bucket, Key: to.key, UploadId, MultipartUpload: { Parts: parts } }),
    );
    return done.ETag;
  } catch (err) {
    await dst.send(new AbortMultipartUploadCommand({ Bucket: to.bucket, Key: to.key, UploadId }));
    throw err;
  }
}

/** S3 has no rename: copy, then delete the source. In a versioned bucket, no versionId adds a delete marker; a versionId deletes that version for good. */
export async function moveObject(from: ObjectRef, to: ObjectRef, opts: CopyOptions = {}): Promise<void> {
  if (from.bucket === to.bucket && from.key === to.key) throw new Error("Source and destination are the same object");
  await copyObject(from, to, opts);
  await clientFor(from.region).send(new DeleteObjectCommand({ Bucket: from.bucket, Key: from.key, VersionId: from.versionId }));
}

export interface PrefixOptions {
  concurrency?: number;
  move?: boolean;
  dryRun?: boolean; // default true: list what would happen, change nothing
  srcRegion?: string;
  dstRegion?: string;
}

/** Copy (or move) every object under a prefix, with at most `concurrency` copies in flight. */
export async function copyPrefix(
  srcBucket: string,
  srcPrefix: string,
  dstBucket: string,
  dstPrefix: string,
  { concurrency = 16, move = false, dryRun = true, srcRegion, dstRegion }: PrefixOptions = {},
): Promise<{ copied: number; failed: string[] }> {
  const keys: string[] = [];
  for await (const page of paginateListObjectsV2({ client: clientFor(srcRegion) }, { Bucket: srcBucket, Prefix: srcPrefix })) {
    for (const o of page.Contents ?? []) if (o.Key && !o.Key.endsWith("/")) keys.push(o.Key);
  }
  if (dryRun) {
    for (const key of keys.slice(0, 5)) console.log(`would ${move ? "move" : "copy"} ${key} -> ${dstPrefix}${key.slice(srcPrefix.length)}`);
    return { copied: 0, failed: [] };
  }
  let copied = 0;
  const failed: string[] = [];
  await runWithConcurrency(keys, concurrency, async (key) => {
    const target = { bucket: dstBucket, key: dstPrefix + key.slice(srcPrefix.length), region: dstRegion };
    try {
      await (move ? moveObject : copyObject)({ bucket: srcBucket, key, region: srcRegion }, target);
      copied++;
    } catch (err) {
      failed.push(`${key}: ${err instanceof Error ? err.name : String(err)}`);
    }
  });
  return { copied, failed };
}

async function runWithConcurrency<T>(items: T[], limit: number, worker: (item: T) => Promise<void>): Promise<void> {
  let next = 0;
  const lanes = Array.from({ length: Math.min(limit, items.length) }, async () => {
    while (next < items.length) {
      const item = items[next++] as T;
      await worker(item);
    }
  });
  await Promise.all(lanes);
}

A caller that re-tiers one report into another Region, renames it, and copies a whole prefix (as a dry run until you pass --apply):

run-copy.ts

// run-copy.ts
// Usage: AWS_PROFILE=dev npx tsx run-copy.ts [--apply]
import { copyObject, copyPrefix, moveObject } from "./s3-copy.js";

const apply = process.argv.includes("--apply");

// 1. One object, into a cheaper storage class in another bucket (and Region).
const etag = await copyObject(
  { bucket: "acme-reports-use1", key: "2027/03/q1 summary.pdf", region: "us-east-1" },
  { bucket: "acme-archive-euw1", key: "reports/2027/03/q1 summary.pdf", region: "eu-west-1" },
  { storageClass: "STANDARD_IA" },
);
console.log(`copied, ETag ${etag}`);

// 2. "Rename" inside the archive bucket.
await moveObject(
  { bucket: "acme-archive-euw1", key: "reports/2027/03/q1 summary.pdf", region: "eu-west-1" },
  { bucket: "acme-archive-euw1", key: "reports/2027/q1/summary.pdf", region: "eu-west-1" },
);
console.log("moved");

// 3. A whole prefix: dry run unless --apply.
const result = await copyPrefix("acme-reports-use1", "2026/", "acme-archive-euw1", "reports/2026/", {
  concurrency: 16,
  dryRun: !apply,
  srcRegion: "us-east-1",
  dstRegion: "eu-west-1",
});
console.log(apply ? `copied ${result.copied}, failed ${result.failed.length}` : "dry run: nothing copied");
for (const f of result.failed) console.log(`  ${f}`);
Terminal

npm install @aws-sdk/client-s3
npm install --save-dev tsx typescript @types/node
npm pkg set type=module

AWS_PROFILE=dev npx tsx run-copy.ts
# copied, ETag "5d41402abc4b2a76b9719d911017c592"
# moved
# would copy 2026/01/january.pdf -> reports/2026/01/january.pdf
# would copy 2026/02/february.pdf -> reports/2026/02/february.pdf
# dry run: nothing copied

Copying objects larger than 5 GB

A single CopyObject request handles up to 5 GB. Above that you build the object from parts: each UploadPartCopy copies one byte range, written as bytes=first-last with zero-based offsets. Parts must be 5 MiB to 5 GiB except the last, part numbers run from 1 to 10,000, and the largest object S3 stores is 48.8 TiB (50 TB). The module uses 512 MiB parts, so a 100 GiB object takes 200 part copies, eight at a time.

CreateMultipartUpload doesn’t look at the source, so the module passes the source’s ContentType, Metadata and StorageClass explicitly; tags aren’t carried over this way unless you pass them yourself, which the guide to tag S3 objects with AWS SDK v3 (PutObjectTagging) shows how to read and reapply. If the process dies between create and complete, the parts stay in the bucket and are billed until aborted. A lifecycle rule that aborts incomplete multipart uploads is the safety net; the script to find S3 buckets without lifecycle rules shows which buckets lack one, and the example to upload large files and streams to S3 with AWS SDK v3 covers the upload side of multipart.

Moving and renaming: copy, then delete

S3 has no rename or move call. moveObject copies and then deletes the source, and refuses to “move” an object onto itself. In a versioned bucket, a delete without a version ID adds a delete marker and keeps the old data as a noncurrent version, which you keep paying for until a lifecycle rule expires it. With a version ID, that version is deleted permanently. Check whether the source bucket is versioned before you move anything out of it.

Copying across Regions and accounts

For a cross-Region copy, send the request to the destination’s Region and name the source bucket in CopySource as usual; the module does this by giving each ObjectRef an optional region. The transfer is billed to the source account: as of September 2026, AWS’s price list shows $0.02 per GB from us-east-1 to us-west-2. Cross-Region copies don’t work through VPC endpoints or S3 Transfer Acceleration. The guide to the AWS S3 data transfer out cost covers the other transfer charges.

For a cross-account copy, the caller needs s3:GetObject on the source through the source bucket’s policy and s3:PutObject on the destination. With S3 Object Ownership set to bucket owner enforced, ACLs are disabled and the destination account owns the copy. Without that setting, the copying account can own objects in someone else’s bucket, which is rarely what the destination owner wants.

Bulk copies: pagination, concurrency and cost

copyPrefix lists the prefix with paginateListObjectsV2, then runs up to 16 copies at a time; the default is a dry run that prints the first five mappings. Request charges add up per object. As of September 2026, S3 Standard in us-east-1 charges $0.005 per 1,000 PUT, COPY, POST or LIST requests and $0.004 per 10,000 GET and other requests. For 1,000,000 objects, the copies cost 1,000 × $0.005 = $5.00, the HeadObject calls 100 × $0.004 = $0.40, and listing 1,000 pages about $0.005. The guide to calculate S3 GET and PUT request costs shows the other classes.

Past a few million objects, or when you need a completion report, use S3 Batch Operations: you give it a manifest and it runs the copy on up to billions of objects. The module’s approach suits jobs you want to run, retry and read in your own code; to track a long copy as retryable steps, run each batch as a state and start a Step Functions execution from TypeScript with SDK v3 to kick it off. Transient throttling is handled by the SDK’s retries; tune them with the guide to configure retry and timeout settings in AWS SDK for JavaScript v3.

Which IAM permissions do copies and moves need?

s3-copy-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListSourcePrefix",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::acme-reports-use1"
    },
    {
      "Sid": "ReadAndMoveSourceObjects",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:DeleteObject"
      ],
      "Resource": "arn:aws:s3:::acme-reports-use1/*"
    },
    {
      "Sid": "WriteDestinationObjects",
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:PutObjectTagging",
        "s3:AbortMultipartUpload"
      ],
      "Resource": "arn:aws:s3:::acme-archive-euw1/*"
    }
  ]
}

Add s3:GetObjectVersion when you copy a specific version, and s3:DeleteObject on the destination for renames inside it. SSE-KMS objects also need kms:Decrypt on the source key and kms:GenerateDataKey on the destination key. To derive the list from your code, see how to find the IAM actions your AWS SDK for JavaScript code needs or use the IAM policy generator for TypeScript code.

Troubleshooting and common mistakes

  • NoSuchKey for a key that exists. CopySource isn’t encoded, so a space, + or # in the key changed its meaning. Use copySource().
  • InvalidRequest: copying an object to itself. S3 only allows a self-copy that changes something, such as metadata with REPLACE, the storage class or encryption.
  • PreconditionFailed. CopySourceIfMatch did its job: the source changed after HeadObject. Re-run to copy the new version.
  • InvalidObjectState. The source is in S3 Glacier Flexible Retrieval or Deep Archive and needs a restore first.
  • AccessDenied on tagged or KMS-encrypted objects. Plain objects copy but these fail: add the tagging and KMS permissions above, and read the message with the steps to troubleshoot AWS IAM access denied errors.
  • Copies that look fine but cost more. The storage class reset to STANDARD. Pass StorageClass explicitly.

What the module can’t do: copy between S3 and another storage service, preserve the original creation date, or make a multipart copy’s ETag match the source’s. To check a copy landed, send HeadObjectCommand for the destination key and compare sizes. Porting a boto3 copy_object script? The boto3 to AWS SDK for JavaScript v3 converter drafts the TypeScript for review.

Frequently asked questions

How do I copy an object between S3 buckets in Node.js?

Send CopyObjectCommand with Bucket and Key for the destination and CopySource: "source-bucket/encoded-key". The data is copied inside S3; nothing is downloaded.

Does CopyObject keep metadata and tags?

Yes by default: both directives default to COPY. The storage class does not carry over unless you set it, and encryption follows the destination bucket’s default unless you specify it.

How do I rename a file in S3 with SDK v3?

Copy it to the new key, then delete the old key. There is no rename API, so a rename is always a COPY request followed by a DELETE request.

What is the maximum size for CopyObject?

5 GB per request. For larger objects, use a multipart copy with UploadPartCopy, which supports objects up to 48.8 TiB.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud