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
| Scenario | Use | Why |
|---|---|---|
| Single file transfer | cp | Simpler, no comparison overhead |
| First-time directory upload | Either | Both transfer everything |
| Subsequent directory updates | sync | Only transfers changes |
| Website deployment | sync --delete | Updates files, removes old ones |
| Backup (keep versions) | cp --recursive | Doesn't delete old backups |
| Mirror/replica | sync --delete | Exact copy including deletions |
| Restore from backup | sync or cp | Depends 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
| Flag | Effect | Direction it applies to |
|---|---|---|
| (default) | Size differs, or source mtime is newer | Both |
--size-only | Size is the sole criterion; timestamps ignored | Both |
--exact-timestamps | Same-sized items skipped only if timestamps match exactly | S3 → local only |
--exact-timestamps is the one most often misapplied. Two things to know:
- 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.
- It transfers more, not less. The default skips a same-sized object unless the local copy is newer;
--exact-timestampsskips 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:
| Setting | Default | What it controls |
|---|---|---|
multipart_threshold | 8 MB | File size at which multipart kicks in |
multipart_chunksize | 8 MB | Size of each part |
max_concurrent_requests | 10 | Parallel transfer threads |
max_queue_size | 1000 | Queued 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
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)
| Metric | sync | cp --recursive |
|---|---|---|
| API calls | 1 LIST + 10,000 PUT | 10,000 PUT |
| Time | Similar | Similar |
| Network | Same | Same |
Subsequent Transfer: 100 of 10,000 Files Changed
| Metric | sync | cp --recursive |
|---|---|---|
| API calls | 10 LIST + 100 PUT | 10,000 PUT |
| Data transferred | 100 files | 10,000 files |
| Request cost | ~$0.00055 | ~$0.05 |
| Time | Much faster | Same 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:
| Operation | Cost 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 prefix | LIST calls per sync | Cost per sync | Cost per month (hourly sync) |
|---|---|---|---|
| 10,000 | 10 | $0.00005 | $0.036 |
| 1,000,000 | 1,000 | $0.005 | $3.60 |
| 50,000,000 | 50,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 thans3://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
| Question | If Yes → | If No → |
|---|---|---|
| Single file? | cp | Continue |
| First-time bulk transfer? | Either (cp simpler) | Continue |
| Need to mirror exactly? | sync --delete | Continue |
| Regular updates to same destination? | sync | Continue |
| Streaming data from stdin? | cp - | N/A |
| Want old destination files preserved? | cp | sync |
| Website/app deployment? | sync --delete | N/A |
Conclusion
Both commands have their place:
- Use
cpfor single files, one-time transfers, streaming, and when you want full control over what gets transferred - Use
syncfor 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.