rsync is the tool you reach for the day cp -r isn’t enough: it copies only what changed, resumes where it stopped, and can make one directory an exact mirror of another. It is also the tool most likely to do something drastic because of a single character you didn’t type.
That character is a trailing slash. This is the safe version of the three commands people paste, with all three ways they betray you left in — because every one of these is going to happen to you, and two of them are silent.
All output below is real, captured from rsync version 3.2.7 (protocol 31) on bash 5.2.21, copying local directory to local directory (no network, no server — rsync does the same thing to /mnt/backup that it does to ./dest).
Footgun one: the trailing slash on the source
Make a source directory with a file and a subdirectory:
$ cd "$(mktemp -d)"
$ mkdir -p src/logs
$ echo one > src/a.txt
$ echo two > src/b.txt
$ echo deep > src/logs/app.log
Now copy it — the way you’d copy anything, no trailing slash:
$ mkdir dest1
$ rsync -a src dest1/
$ find dest1 | sort
dest1
dest1/src
dest1/src/a.txt
dest1/src/b.txt
dest1/src/logs
dest1/src/logs/app.log
Read that. You asked for your files in dest1, and you got dest1/src/…. rsync took src — the directory itself — and dropped it inside the destination. Do this to a backup and every run that “worked” actually built backup/src/src/src/ nesting if the arguments drift.
Now the same command with a slash after the source:
$ mkdir dest2
$ rsync -a src/ dest2/
$ find dest2 | sort
dest2
dest2/a.txt
dest2/b.txt
dest2/logs
dest2/logs/app.log
a.txt landed directly in dest2. The subdirectory came along. No stray src wrapper.
The rule, once and for all:
rsync -a src dest/→ copy the directorysrcintodest(you getdest/src/…).rsync -a src/ dest/→ copy the contents ofsrcintodest(you getdest/a.txt).
The trailing slash on the source is the only thing that changed, and it changed the whole result. (A trailing slash on the destination is cosmetic — it only says “this is a directory.” It’s the source slash that decides the shape.) The -a there is archive mode: recurse, and preserve permissions, timestamps, symlinks, and ownership — the flag you want 95% of the time.
You’ll know you got it right when the first file lands where you expect (dest/a.txt, not dest/src/a.txt). When in doubt, run it with -n first — which is the next footgun’s fix, so keep reading.
Footgun two: –delete mirrors, and mirrors remove
-a copies new and changed files but never removes anything. The moment you want a true backup — dest is exactly src, no leftovers — you add --delete. That flag is correct and it is a loaded gun, because it deletes every file in the destination that isn’t in the source.
Set up a destination that already holds a file the source doesn’t:
$ mkdir -p src dest
$ echo keep > src/keep.txt
$ echo important > dest/not-in-src.txt
$ find dest -type f | sort
dest/not-in-src.txt
not-in-src.txt is a real file with real contents. Now mirror src onto dest — but dry-run it first with -n (alias --dry-run), plus -i so it itemizes what it would do:
$ rsync -a -n -i --delete src/ dest/
*deleting not-in-src.txt
>f+++++++++ keep.txt
$ find dest -type f | sort
dest/not-in-src.txt
-n changed nothing — not-in-src.txt is still there — but it told you the truth: a real run would delete it and create keep.txt. That *deleting line is the whole reason -n exists. Read it before every --delete, every time, especially when the source path came from a variable that might have expanded to empty.
Now run it for real:
$ rsync -a --delete src/ dest/
$ find dest -type f | sort
dest/keep.txt
not-in-src.txt is gone. That is --delete doing exactly what it promises. If dest had been someone’s home directory and src had been empty, --delete would have emptied it every bit as cheerfully. The dry-run is not optional caution; it is the difference between a backup and an incident.
Footgun three: the change rsync doesn’t see
Here’s the quiet one. rsync decides whether to re-copy a file with a quick check: same size and same modification time means “unchanged, skip it.” That’s what makes it fast. It’s also a small lie, and you can catch it lying.
Make a file, sync it, then change its contents to something the same length — and force the timestamps to match, which is what happens naturally when an edit lands in the same one-second window rsync last recorded:
$ printf 'AAAA' > src/config.txt
$ rsync -a src/ dest/
$ printf 'BBBB' > src/config.txt
$ touch -d '2026-01-01 12:00:00' src/config.txt dest/config.txt
$ stat -c '%n mtime=%Y size=%s' src/config.txt dest/config.txt
src/config.txt mtime=1767268800 size=4
dest/config.txt mtime=1767268800 size=4
Same size (4), same mtime. Source says BBBB, destination still says AAAA. Sync again and watch rsync do nothing:
$ rsync -a -i src/ dest/
$ cat src/config.txt; cat dest/config.txt
BBBB
AAAA
No itemize line, no transfer. rsync looked at size and mtime, saw a match, and skipped a file whose contents are wrong. Your backup now disagrees with your source and nothing warned you. (I found this the honest way — a same-size overwrite in a test that ran too fast for the clock to tick — which is exactly how it finds you: a config file rewritten in place, same length, twice in one second.)
The fix is -c (--checksum): compare files by a full content checksum instead of size-plus-mtime.
$ rsync -a -c -i src/ dest/
>fc........ config.txt
$ cat dest/config.txt
BBBB
The c in >fc........ means “checksum differed” — rsync read both files, saw the contents disagree, and copied. --checksum reads every byte on both ends, so it’s slower; you don’t want it on a nightly sync of a huge tree. But when correctness matters more than speed — restoring from a backup, verifying a copy, syncing files that get rewritten in place — it’s the flag that doesn’t get fooled.
The safe pattern, tested
Here is the shape to reach for: contents-into-dest with the source slash, a dry-run before any --delete, then the real mirror. This block is opted into our test harness (lh:run) and runs on every build in a locked-down, no-network sandbox, so the version you’re reading is the version that passed:
```bash lh:run #!/usr/bin/env bash set -euo pipefail
work=”$(mktemp -d)” cd “$work” mkdir -p src/logs dest echo “a” > src/a.txt echo “b” > src/logs/b.txt
echo “==> trailing slash copies CONTENTS of src into dest (not src/ itself)” rsync -a src/ dest/ test -f dest/a.txt && test -f dest/logs/b.txt && test ! -e dest/src echo “ ok: dest/a.txt and dest/logs/b.txt exist, dest/src does not”
echo “==> dry-run (-n) before –delete: see what a mirror would remove, change nothing” echo “orphan” > dest/orphan.txt rsync -a -n -i –delete src/ dest/ | grep -q ‘*deleting orphan.txt’ test -f dest/orphan.txt echo “ ok: -n reported the delete but orphan.txt is still here”
echo “==> for real: –delete makes dest an exact mirror of src” rsync -a –delete src/ dest/ test ! -e dest/orphan.txt echo “ ok: orphan.txt is gone; dest now mirrors src”
echo “done” ```
When this goes wrong
- You put the slash on the destination and forgot it on the source.
dest/vsdestbarely matters;src/vssrcmatters completely. If your copy shows up one directory too deep, the source slash is missing. --deleteon a source that expanded to empty.rsync -a --delete "$SRC/" dest/when$SRCis unset copies nothing and deletes everything indest. Quote your variables, setset -u, and always-nfirst. This is the one that ends up in the postmortem.- A progress bar you’ll want: add
-P(--partial --progress) for large transfers so an interrupted copy resumes instead of restarting, and add-hfor human-readable sizes. Neither changes what gets copied — only what you see. --checksumis slow on purpose. Don’t reach for it on every sync. Reach for it when a file might have changed without its size or timestamp changing, or when you need to prove two trees match.
Two characters and one flag are the whole game: the trailing slash decides shape, --delete decides whether extras survive, and -n lets you find out which before rsync commits. Run the dry-run. Read the *deleting lines. Then let it rip.