Cloud & DevOps

AWS S3 Sync vs Copy: When to Use Each Command

Understand the key differences between aws s3 sync and aws s3 cp commands. Learn when to use each for uploads, backups, and deployments.

By Inventive HQ Team

Two commands dominate S3 file transfers: aws s3 sync and aws s3 cp. While both move files to and from S3, they work fundamentally differently—and choosing wrong can waste time, money, or worse, delete data you meant to keep.

This guide breaks down exactly when to use each command, with real-world examples that help you make the right choice every time.

Try our free AWS S3 Command Generator to build the right sync or cp command with correct flags instantly.

The Core Difference

aws s3 cp — Copies specified files, always.

aws s3 sync — Synchronizes directories, transferring only what's changed.

Think of it this way:

  • cp is like a copy machine—it duplicates whatever you give it
  • sync is like a smart backup—it only transfers what's new or different

Quick Decision Guide

ScenarioUseWhy
Single file transfercpSimpler, no comparison overhead
First-time directory uploadEitherBoth transfer everything
Subsequent directory updatessyncOnly transfers changes
Website deploymentsync --deleteUpdates files, removes old ones
Backup (keep versions)cp --recursiveDoesn't delete old backups
Mirror/replicasync --deleteExact copy including deletions
Restore from backupsync or cpDepends on restore strategy

Command Comparison

Basic Syntax

# Copy single file
aws s3 cp file.txt s3://bucket/file.txt

# Copy directory (requires --recursive)
aws s3 cp ./dir s3://bucket/dir --recursive

# Sync directory (recursive by default)
aws s3 sync ./dir s3://bucket/dir

Key Differences in Behavior

One consequence worth knowing before you run either against a large tree: sync walks the whole prefix to compare it, so a permissions gap that cp would hit on a single object can fail partway through a sync instead. If you see An error occurred (AccessDenied) when calling the ListObjectsV2 operation, the missing permission is usually s3:ListBucket on the bucket rather than s3:GetObject on the keys.

# Initial upload: both transfer 100 files
aws s3 cp ./website s3://bucket/ --recursive    # Uploads 100 files
aws s3 sync ./website s3://bucket/              # Uploads 100 files

# After changing 3 files locally:
aws s3 cp ./website s3://bucket/ --recursive    # Uploads 100 files (all)
aws s3 sync ./website s3://bucket/              # Uploads 3 files (changed only)

What sync actually compares

Almost every surprise with sync traces back to one under-documented fact: sync never looks at file contents. It compares two things only — size and last-modified time.

Per the AWS CLI reference, a local file is uploaded if:

  • the size of the local file differs from the size of the S3 object, or
  • the last-modified time of the local file is newer than that of the S3 object, or
  • the object does not exist at the destination

The direction of that timestamp test is the part people miss. It is newer than, not different from. A source file that is older than the destination but identical in size is skipped, silently.

# A file restored from an old backup keeps its old mtime.
# Same size as what's in S3, older timestamp -> sync will NOT upload it.
aws s3 sync ./restored s3://bucket/   # transfers nothing

# Force it by comparing sizes only... which also won't help if the size matches.
aws s3 sync ./restored s3://bucket/ --size-only

The practical consequence: an in-place edit that preserves file size and does not advance the mtime is invisible to sync, forever. Config files edited by a script, files checked out by tooling that resets timestamps, and same-length ID or token substitutions are the usual culprits. If correctness matters more than transfer volume, use cp --recursive for that path, or touch the files first.

--size-only and --exact-timestamps

FlagEffectDirection it applies to
(default)Size differs, or source mtime is newerBoth
--size-onlySize is the sole criterion; timestamps ignoredBoth
--exact-timestampsSame-sized items skipped only if timestamps match exactlyS3 → local only

--exact-timestamps is the one most often misapplied. Two things to know:

  1. It only affects downloads. The AWS docs are explicit: "When syncing from S3 to local, same-sized items will be ignored only when the timestamps match exactly." Passing it on an upload is silently inert.
  2. It transfers more, not less. The default skips a same-sized object unless the local copy is newer; --exact-timestamps skips it only on an exact match, so any drift triggers a download. It is a correctness flag, not a safety flag.

--size-only is the right answer for build artifacts, where every file gets a fresh timestamp on each build and the default rule would re-upload the entire tree:

npm run build                              # every file gets a new mtime
aws s3 sync ./dist s3://bucket/            # re-uploads everything
aws s3 sync ./dist s3://bucket/ --size-only  # uploads only files whose size changed

The tradeoff is real: --size-only will miss a genuine content change that happens to preserve the byte count. For hashed asset filenames (app.4f3a2b.js) that risk is essentially nil, which is why it is the standard choice for static-site deploys.

Multipart transfers: the threshold that changes your ETag

Both cp and sync switch from a single PutObject to a multipart upload once a file crosses a size threshold. The defaults, from the AWS CLI S3 configuration reference:

SettingDefaultWhat it controls
multipart_threshold8 MBFile size at which multipart kicks in
multipart_chunksize8 MBSize of each part
max_concurrent_requests10Parallel transfer threads
max_queue_size1000Queued transfer tasks

Tune them in ~/.aws/config:

[profile default]
s3 =
  multipart_threshold = 64MB
  multipart_chunksize = 16MB
  max_concurrent_requests = 20

Two consequences worth planning around:

1. Multipart objects do not have an MD5 ETag. A single-part upload's ETag is the MD5 of the object. A multipart object's ETag is a hash of the part hashes, suffixed with a dash and the part count (d41d8c...-12). Any script that validates uploads by comparing an ETag to a locally computed MD5 will start failing the moment a file crosses 8 MB. This does not affect sync — which ignores checksums entirely — but it breaks a lot of homegrown verification.

2. Interrupted multipart uploads leave billable parts behind. Aborted parts are stored and charged for until they are removed, and they are invisible in a normal object listing:

# Find orphaned parts
aws s3api list-multipart-uploads --bucket my-bucket

# Abort one
aws s3api abort-multipart-upload \
  --bucket my-bucket --key big-file.zip --upload-id <UploadId>

The durable fix is a lifecycle rule that expires incomplete uploads automatically — worth adding to any bucket that receives large transfers:

{
  "Rules": [{
    "ID": "abort-incomplete-multipart",
    "Status": "Enabled",
    "Filter": { "Prefix": "" },
    "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 }
  }]
}

When to Use aws s3 cp

Single File Transfers

# Upload
aws s3 cp report.pdf s3://documents/reports/

# Download
aws s3 cp s3://documents/reports/report.pdf ./

# Copy between buckets
aws s3 cp s3://source/file.txt s3://destination/file.txt

Rename While Copying

# cp allows different destination name
aws s3 cp local.txt s3://bucket/renamed.txt

# sync copies directory structure as-is
aws s3 sync ./data s3://bucket/data  # Preserves all names

Stream Processing

# Pipe data directly (cp supports stdin)
pg_dump mydb | gzip | aws s3 cp - s3://backups/db-$(date +%Y%m%d).sql.gz

# Sync doesn't support streaming

Backup Without Deletion Risk

When you want to ensure files accumulate rather than mirror:

# Timestamped backup—old backups preserved
aws s3 cp ./logs s3://archive/logs-$(date +%Y%m%d)/ --recursive

# Never risk deleting previous backups

Copy with Specific Metadata

aws s3 cp large-file.zip s3://bucket/ \
  --storage-class STANDARD_IA \
  --metadata '{"project":"alpha","version":"1.2"}'

When to Use aws s3 sync

Website Deployments

The classic sync use case:

# Build and deploy
npm run build
aws s3 sync ./dist s3://my-website/ --delete

# Only changed files upload, old files removed

Why sync is better:

  • Faster deployments (only changes transfer)
  • Lower costs (fewer PUT requests)
  • Cleaner bucket (old files removed)

Regular Backups

# Daily backup of important directory
aws s3 sync /var/data s3://backups/data/

# Only new/changed files transfer each day

Development Synchronization

Keep local and S3 in sync during development:

# Push changes up
aws s3 sync ./project s3://dev-bucket/project

# Pull changes down
aws s3 sync s3://dev-bucket/project ./project

Disaster Recovery Replication

# Mirror production bucket to DR region
aws s3 sync s3://prod-bucket s3://dr-bucket \
  --source-region us-east-1 \
  --region us-west-2

Large Directory with Frequent Updates

# Log directory with constant new files
aws s3 sync /var/log/app s3://logs/app/ --exclude "*.tmp"

# Only new logs upload—existing ones skipped

Critical Flag Differences

Advertisement

The --delete Flag (Sync Only)

# WITHOUT --delete: Files only added/updated, never removed
aws s3 sync ./src s3://bucket/
# If you delete local file, it remains in S3

# WITH --delete: True mirror—deletions propagate
aws s3 sync ./src s3://bucket/ --delete
# Local deletions also delete from S3

Warning: --delete is dangerous. Always preview first:

# ALWAYS do this first
aws s3 sync ./src s3://bucket/ --delete --dryrun

The three ways --delete ruins your day

1. A wrong or empty source empties the destination. --delete means "make the destination look like the source." If the source path is a typo, or a build step failed and left ./dist empty, sync does exactly what you asked and deletes the bucket prefix. A trailing-slash difference or an unexpanded shell variable is enough:

BUILD_DIR=""                                   # variable never set
aws s3 sync "$BUILD_DIR" s3://prod-site/ --delete
# syncs from the current directory, or deletes everything not in it

Guard CI pipelines with an explicit check before the sync ever runs:

set -euo pipefail
test -d ./dist || { echo "dist/ missing, aborting"; exit 1; }
test -n "$(ls -A ./dist)" || { echo "dist/ empty, aborting"; exit 1; }
aws s3 sync ./dist s3://prod-site/ --delete

2. Excluded files are excluded from deletion too. This one cuts both ways, and the docs state it plainly: "files excluded by filters are excluded from deletion." So a filtered sync will not clean up orphans it cannot see:

# Deletes orphaned .html files, but leaves every orphaned .js and .css in place
aws s3 sync ./dist s3://bucket/ --exclude "*" --include "*.html" --delete

That is precisely why the two-pass deploy pattern below works: each pass deletes only within its own filter, so between them every orphan is covered exactly once.

3. Versioning does not undo it. On a versioned bucket, --delete writes delete markers rather than destroying data — recoverable, but only if you know which keys to restore and act before lifecycle rules expire the noncurrent versions. On an unversioned bucket the deletion is permanent and immediate. Enable versioning on anything you sync with --delete.

The --recursive Flag (cp Only)

# cp requires --recursive for directories
aws s3 cp ./dir s3://bucket/dir                # Fails
aws s3 cp ./dir s3://bucket/dir --recursive    # Works

# sync is inherently recursive
aws s3 sync ./dir s3://bucket/dir              # Works

Exclude and Include Patterns

Both commands support filtering, but behavior differs:

# Exclude patterns (both commands)
aws s3 sync ./project s3://bucket/ --exclude "*.log" --exclude "node_modules/*"
aws s3 cp ./project s3://bucket/ --recursive --exclude "*.log"

# Include after exclude (both)
aws s3 sync ./data s3://bucket/ --exclude "*" --include "*.csv"

Performance Comparison

A note on counting LIST calls: ListObjectsV2 returns up to 1,000 keys per request, so listing a 10,000-object prefix costs 10 requests, not 10,000. That paging behaviour is what makes sync's comparison step so cheap relative to the transfers it avoids.

First-Time Transfer: 10,000 Files (empty destination)

Metricsynccp --recursive
API calls1 LIST + 10,000 PUT10,000 PUT
TimeSimilarSimilar
NetworkSameSame

Subsequent Transfer: 100 of 10,000 Files Changed

Metricsynccp --recursive
API calls10 LIST + 100 PUT10,000 PUT
Data transferred100 files10,000 files
Request cost~$0.00055~$0.05
TimeMuch fasterSame as first time

Sync wins dramatically for updates — roughly 90× cheaper on requests here, and far larger savings in time and bandwidth.

Comparison Overhead

Sync must list and compare before transferring:

# For tiny transfers, cp may be faster
time aws s3 cp single-file.txt s3://bucket/    # ~1 second
time aws s3 sync ./one-file-dir s3://bucket/   # ~2 seconds (list overhead)

# For large directories, sync wins on updates
time aws s3 cp ./10k-files s3://bucket/ --recursive  # 5 minutes
time aws s3 sync ./10k-files s3://bucket/            # 5 minutes (first time)
time aws s3 sync ./10k-files s3://bucket/            # 30 seconds (subsequent)

Common Patterns

Website Deployment Pipeline

#!/bin/bash
# build-and-deploy.sh

# Build
npm run build

# Sync HTML with short cache
aws s3 sync ./dist s3://website/ \
  --exclude "*" \
  --include "*.html" \
  --cache-control "max-age=3600" \
  --delete

# Sync assets with long cache
aws s3 sync ./dist s3://website/ \
  --exclude "*.html" \
  --cache-control "max-age=31536000" \
  --delete

# Invalidate CDN
aws cloudfront create-invalidation \
  --distribution-id E12345 \
  --paths "/*"

Backup with Retention

#!/bin/bash
# backup-with-retention.sh

# Daily incremental backup
aws s3 sync /data s3://backups/current/

# Weekly snapshot (copy, not sync, to preserve)
if [ $(date +%u) -eq 7 ]; then
  WEEK=$(date +%Y-W%V)
  aws s3 cp s3://backups/current s3://backups/weekly/$WEEK/ --recursive
fi

Bidirectional Sync

#!/bin/bash
# two-way-sync.sh

# Pull remote changes first
aws s3 sync s3://shared/project ./project

# Make local changes...
# ...

# Push local changes
aws s3 sync ./project s3://shared/project

Warning: Bidirectional sync can cause conflicts. Consider proper version control for code.

Selective Restore

# Restore only specific files with cp
aws s3 cp s3://backup/config.json ./config.json

# Restore entire directory with sync
aws s3 sync s3://backup/data ./data

# Restore, overwriting any local file whose timestamp doesn't match exactly.
# NOTE: --exact-timestamps transfers MORE, not less. The default already skips
# a same-sized object when the local copy is newer; this flag drops that
# leniency, so any timestamp drift triggers a download. Downloads only.
aws s3 sync s3://backup/data ./data --exact-timestamps

Cost Analysis

Transfer Costs

Both commands incur the same data transfer costs—they differ in request costs:

OperationCost per 1,000
PUT/POST$0.005
GET$0.0004
LIST$0.005

Scenario: Deploy 10,000-File Website Daily

Assume 100 files change per deploy — typical for a site with content-hashed assets.

Using cp --recursive (every file, every time):

Daily:   10,000 PUT x $0.005/1,000 = $0.0500
Monthly: 30 x $0.05                = $1.50
Data:    the entire site re-uploaded, 30 times

Using sync (only the 100 changed files):

Daily:   10 LIST  x $0.005/1,000 = $0.00005
       + 100 PUT  x $0.005/1,000 = $0.00050
                                   ---------
                                   $0.00055
Monthly: 30 x $0.00055           = $0.0165
Data:    1% of the site, 30 times

Roughly 90× cheaper on requests — about $1.48/month saved on this one bucket, and 99% less data transferred.

That absolute figure is small, and for a single site the request cost of either approach rounds to nothing. The reason it matters is that it does not stay small: the gap scales linearly with object count and deploy frequency. Run the same pattern across 200 buckets, or deploy on every commit instead of daily, and cp --recursive turns a rounding error into a real line item — while also taking 10× longer on every run.

Where the cost genuinely bites is LIST at scale. Sync must enumerate the destination prefix on every run, and that cost is driven by the total object count, not the number of changes:

Objects in prefixLIST calls per syncCost per syncCost per month (hourly sync)
10,00010$0.00005$0.036
1,000,0001,000$0.005$3.60
50,000,00050,000$0.25$180.00

At tens of millions of objects, a frequent sync over the whole prefix costs more in LIST requests than the transfers it saves. Two ways out:

  • Narrow the prefix. Sync s3://bucket/2026/08/ rather than s3://bucket/, so the enumeration is proportional to the data that can actually have changed.
  • Stop enumerating. For very large or very active buckets, use S3 Replication, or drive transfers from an event source (S3 Event Notifications, or S3 Inventory for scheduled reconciliation) instead of a full-prefix comparison.

Data transfer costs are identical between the two commands — uploads into S3 are free, and both pay the same egress rate on downloads. Same-region S3-to-S3 copies incur no transfer charge either way; cross-region does, for both.

Edge Cases and Gotchas

Timestamp Issues

# Sync uses modification time—can cause issues with builds
npm run build  # All files get new timestamps
aws s3 sync ./dist s3://bucket/  # Re-uploads everything!

# Solution: use --size-only for build artifacts
aws s3 sync ./dist s3://bucket/ --size-only

Hidden Files

# Both commands transfer hidden files by default
aws s3 sync ./project s3://bucket/  # Includes .env, .git, etc.

# Exclude sensitive files
aws s3 sync ./project s3://bucket/ \
  --exclude ".env" \
  --exclude ".git/*" \
  --exclude "*.secret"

Empty Directories

# S3 doesn't have true directories—both commands skip empty folders
mkdir empty-dir
aws s3 sync ./empty-dir s3://bucket/empty-dir/  # Nothing uploaded

# To create "folder" marker:
aws s3api put-object --bucket bucket --key empty-dir/

Sync with Versioned Buckets

# Sync doesn't automatically handle versions
aws s3 sync ./data s3://versioned-bucket/data

# Previous versions remain (good for recovery)
# But --delete only affects current versions

Summary: Decision Matrix

QuestionIf Yes →If No →
Single file?cpContinue
First-time bulk transfer?Either (cp simpler)Continue
Need to mirror exactly?sync --deleteContinue
Regular updates to same destination?syncContinue
Streaming data from stdin?cp -N/A
Want old destination files preserved?cpsync
Website/app deployment?sync --deleteN/A

Conclusion

Both commands have their place:

  • Use cp for single files, one-time transfers, streaming, and when you want full control over what gets transferred
  • Use sync for directories that update regularly, deployments, backups, and when you want incremental efficiency

The key insight: sync is about efficiency over time, while cp is about explicit control. For most directory operations where you'll run the command repeatedly, sync is the better choice.

Generate commands with proper flags using our AWS S3 Command Generator to avoid syntax errors and ensure best practices.

Frequently Asked Questions

What is the main difference between S3 sync and copy?

The main difference is that 'aws s3 sync' compares source and destination, only transferring changed or new files. 'aws s3 cp' transfers everything specified regardless of what exists at the destination. Sync is incremental; copy is absolute.

Is aws s3 sync faster than cp?

For subsequent transfers, sync is usually faster because it skips unchanged files. For first-time transfers of many files, they're similar. For single files or small transfers, cp may be slightly faster due to less comparison overhead.

Does aws s3 sync delete files?

Not by default. Sync only adds and updates files. To delete files in the destination that don't exist in the source (making it a true mirror), use the --delete flag. Always use --dryrun first to preview deletions.

Can I use sync for a single file?

Technically yes, but cp is better for single files. Sync is designed for directories and adds overhead comparing timestamps. For single files, use 'aws s3 cp source s3://bucket/dest' instead.

How does sync determine if a file changed?

Sync compares only size and last-modified time — never file contents or checksums. A file transfers if its size differs from the destination's, if the source's last-modified time is NEWER than the destination's, or if it does not exist at the destination. Note the asymmetry: a source file that is merely older, but the same size, is skipped. Use --size-only to compare sizes alone. --exact-timestamps tightens the rule so same-sized items are skipped only on an exact timestamp match, but it applies only when syncing from S3 to local.

Does aws s3 sync verify file contents or checksums?

No. Sync's comparison is size plus last-modified time only, so a file edited in place without changing its size or timestamp will never be re-uploaded. If you need content-level assurance, compare checksums yourself or force the transfer. Individual object transfers are still integrity-checked in flight, but that is separate from how sync decides what to transfer.

What happens if sync is interrupted?

Sync is safe to interrupt and resume. On the next run, it compares again and transfers only what's still missing or changed. No special resume command needed—just run sync again.

Should I use sync or cp for website deployments?

Use sync for website deployments. It only uploads changed files, making deployments faster and cheaper. Add --delete to remove old files, and --cache-control for proper caching headers. Most CI/CD pipelines use sync.

Does sync work between two S3 buckets?

Yes, sync works between S3 buckets (s3://source/ to s3://dest/), local to S3, or S3 to local. It performs server-side copies when possible, which is faster and doesn't incur data transfer out charges for same-region copies.

awss3clicloud-storagedevopsbackup