← back to latest

See exactly what rsync will change before it changes anything

rsync -avni --delete site/ backup/

anatomy

rsync
Copy a directory tree, but only the parts that differ. The delta-transfer algorithm splits each destination file into blocks, checksums them, and sends the sender only the byte ranges that fail to match. That is why a second run of the same sync costs almost nothing.
-a
Archive mode, which `rsync --help` expands to `-rlptgoD`: recurse, keep symlinks as symlinks, preserve permissions, times, group, owner, and device or special files. It does not include `-H` (hard links), `-A` (ACLs), or `-X` (extended attributes), which is the part people assume and get wrong.
-v
Verbose. On its own it prints the transfer header and the summary footer. The footer is what stamps `(DRY RUN)` on the output, so you have a second confirmation that nothing moved.
-n
Dry run. rsync builds the full file list, compares both sides, and decides every action, then performs none of them. The comparison is real, so the answer is real. This is the flag that makes `--delete` safe to reason about.
-i
Itemize changes. Instead of bare filenames you get an 11-character code per line describing precisely which attribute forced the update. Without it, a permissions-only fix and a full content rewrite look identical in the output.
--delete
Remove files on the destination that no longer exist on the source, which is what makes the copy a mirror rather than an accumulating pile. This is the flag with teeth, and the reason the preview exists. It refuses to run without recursion: pair it with `-a` or `-r` or rsync exits with a usage error.
site/
The source, with a trailing slash. The slash means the contents of this directory rather than the directory itself. Drop it and rsync creates `backup/site/` and mirrors into that instead.
backup/
The destination. Its trailing slash is decorative: rsync only reads slash semantics on the source. A destination of `user@host:/var/www/site/` works the same way and runs the comparison over SSH.

Sample output

sending incremental file list
*deleting   assets/hero-old.png
*deleting   posts/draft.html
>f.s....... assets/app.css
>f+++++++++ assets/theme.js
>f..t...... posts/022.html
>f+++++++++ posts/023.html

sent 248 bytes  received 69 bytes  634.00 bytes/sec
total size is 182  speedup is 0.57 (DRY RUN)

Read the codes column by column. The first character is the update type (> means data is being sent, * means the rest of the line is a message), the second is the file type (f for file, d for directory), and the nine after that are attributes: c checksum, s size, t modification time, p permissions, o owner, g group, then access time, ACLs, and extended attributes. A dot means unchanged and a row of + means the file does not exist on the destination yet. So assets/app.css is a genuine content edit, posts/022.html has identical bytes and a newer timestamp from the rebuild, and two files are about to be deleted. Everything not listed, including index.html, matches already and will not be touched.

When you would reach for it

You are about to mirror a build directory onto a server, a backup disk, or a staging copy, and the command includes --delete. The destination holds something you cannot recreate by running the build again. Any sync where the destination has its own history deserves this step: rsync will happily mirror a source that is emptier than you think, and the dry run is the only place that mistake is reversible. It is also the fastest way to answer “did that deploy actually change anything,” because a clean sync prints nothing at all.

Gotchas

  • The trailing slash on the source is the whole ballgame. rsync -ain --delete site/ backup/ mirrors the contents of site into backup. Remove one character and rsync -ain --delete site backup/ copies the directory itself, producing backup/site/index.html, then deletes everything already in backup because none of it matches. The dry run catches this instantly: if the output lines start with site/, the slash is missing.
  • --delete measures against whatever the source actually contains right now. Point it at a path that is empty, mistyped into existence, or a mount that failed to come up, and the mirror is faithfully applied: every file on the destination is queued for removal. Run it against an empty directory in dry-run mode once and watch rsync list your entire destination under *deleting. That output is the argument for -n.
  • Comparison defaults to size and modification time, not content. A file edited in place to the same length with its timestamp restored looks untouched and gets skipped. Add -c to compare checksums instead, and the itemize code changes from >f.s....... to >fcs........ It reads every byte on both sides, so reserve it for the case where you actively distrust the timestamps.
  • Excluded files are protected on the destination, not deleted from it. --exclude 'assets/app.css' means rsync stops considering that path entirely, so the stale copy in backup/ survives the --delete. If you want exclusions to be removed from the destination too, that is --delete-excluded, and it is worth previewing separately because the two flags read as though they should mean the same thing.
  • macOS 15 replaced the bundled rsync with openrsync, and macOS 14 and earlier shipped rsync 2.6.9 from 2006. Neither is the 3.x you are probably testing against, and flag coverage differs, so check rsync --version and rsync --help | grep itemize before trusting a script across machines. brew install rsync puts a current build in your path.

Variants

$ rsync -avni --delete site/ deploy@web01:/var/www/site/

The same preview against a remote host. rsync tunnels over SSH by default, so a working ssh deploy@web01 is the only prerequisite

$ rsync -ain --delete site/ backup/ | grep -c '^\*deleting'

Count what the mirror would remove before you commit to it. A number larger than you expected means stop and read the full list

$ rsync -ai --delete --exclude '.git' site/ backup/

The real run, once the preview matched your intent. Dropping the -n is the only change, and this one deletes on the destination for real

lineage

rsync began as a research problem. Andrew Tridgell, already known for Samba, wanted to know how to update a file across a slow link without either side reading the whole thing, and the answer became a technical report written with Paul Mackerras at the Australian National University in June 1996. The trick is a pair of checksums: a weak rolling checksum that can be advanced one byte at a time across the destination file, and a strong checksum computed only where the weak one matches. The receiver describes what it already has, the sender ships the gaps. Tridgell later built the work into his 1999 doctoral thesis, and rsync replaced rdist almost everywhere it appeared. The tool has an unusual second life on macOS. Apple shipped rsync 2.6.9, released in 2006, for well over a decade, by most accounts because later versions moved to GPLv3, and then in macOS 15 replaced it outright with openrsync, a clean-room BSD-licensed implementation that started life in OpenBSD. Thirty years on, the algorithm is the stable part and the implementations keep changing underneath it.