↑ Top
File synchronization · user guide + hands-on labs

Stop re-reading
the man page.

The model beneath the flags: derive the command, skip the man page.

Checked with rsync 3.4.4 (protocol 32) · 2026-08-07 rsync 3.5.0 now available · not yet re-verified macOS 15.6 · Ubuntu/WSL · openrsync differences covered Single file · works offline Every transcript captured from a real run 3 interactive tools · 10 labs · capstone

1 · Mental model: rsync is a reconciler, not a copier

Every confusing thing about rsync becomes obvious once you accept one fact: rsync does not copy files. It makes a destination match a source, and it works hard to move as few bytes as possible while doing it. Copying is what it does when reconciling happens to require copying.

That distinction is not pedantry. It explains the behavior that trips people up:

  • Why it appears to do nothing. Run the same command twice and the second run transfers no files. A copier would copy again. A reconciler sees the destination already matches and stops.
  • Why --delete exists and is not the default. A destination with extra files does not match the source. rsync will not remove them unless you say so, because "match" is ambiguous and the destructive reading needs consent.
  • Why the trailing slash matters so much. Reconciling means answering "which destination path should hold this source?" The slash is how you answer.
  • Why interrupting it is usually safe. Reconciliation is restartable. Run it again and it picks up the remaining difference.

Three processes, not one program

When you type one rsync command, up to three rsync processes cooperate. Knowing their names makes error messages readable, because rsync tags every error with the role that produced it — you have already seen [sender], [receiver], and [generator] in real output.

The sender, generator, and receiver sender Reads the source tree. Builds the file list. Computes the deltas the generator asks for. side you named first (unless pulling) generator Walks the file list against the destination. Decides: skip · update · delete · create Emits block checksums. receiver Reassembles each file into a hidden temp file, then renames it into place. .name.XXXXXX → name destination Only ever changed by the receiver. A file is either the old one or the new one, never half-written — that is what the rename buys. file list checksums + requests file data (deltas) write · rename stat() what is already there
One command, three roles. On a local copy all three run on your machine; over SSH the sender is on one side and the generator plus receiver on the other. Error prefixes like [receiver] tell you which role failed.

The two questions rsync asks about every file

Internally, every file passes through two decisions, and almost every flag you will ever use tunes one of them:

1. Does this file need updating?

The default answer, called the quick check, is: update it if the size differs or the modification time differs. Nothing else. Not the contents.

Tuned by: --checksum, --size-only, --ignore-times, --update, --modify-window.

2. How little data can carry the update?

The delta-transfer algorithm finds which blocks the destination already has and sends only the rest.

Tuned by: --whole-file, --no-whole-file, --compress, --inplace, --block-size.

The load-bearing default rsync decides a file is unchanged from its size and timestamp, not its contents. This makes repeat runs fast and is right almost always. It is also the single assumption that, when violated, causes rsync to silently skip a file that really did change. §6 shows both the mechanism and the escape hatch.

The misconception worth killing first

Tempting belief: "rsync is scp with resume." It is not, and the difference has teeth. scp transfers what you name. rsync computes a difference and then applies it — which means it can delete, it can change permissions on directories you did not think you were touching, and it can decide your file needs no transfer at all. Treat every rsync invocation as a small, deliberate mutation of the destination tree, not as a copy. That is why --dry-run is in your muscle memory by the end of §7.

What you will be able to do

By the end of this guide you can, without consulting documentation:

  • Perform: mirror a directory locally or over SSH, with or without deletion, and predict the resulting tree before you press Enter.
  • Explain: why a file was or was not transferred, from the itemized output alone.
  • Build: a hardlinked snapshot backup system with retention, scheduled under launchd or systemd.
  • Recognize: which of the two rsyncs on your Mac you are running, and why it matters.
  • Debug: permission failures, vanished files, filter rules that do not fire, and interrupted transfers.
  • Evaluate: when rsync is the wrong tool, and what to reach for instead.

Sources: rsync(1) manual, 3.4.4 · The rsync algorithm (Tridgell & Mackerras technical report)

2 · Prerequisite floor

rsync assumes you are fluent in the shell's model of paths and files. It does not assume anything else. If the self-assessment below is comfortable, start at §3; if two or more items are shaky, the refreshers are short and they will save you an hour of confusion later.

Hard prerequisites

You need toBecause rsyncQuick refresher
Read and write absolute vs relative pathsresolves both, and remote paths are relative to the remote home directorypwd, cd .., ./x vs /x vs ~/x
Know what a directory's contents are, separately from the directory itselfencodes exactly that distinction in the trailing slash (§4)ls dir lists contents; ls -d dir lists the directory
Read ls -l outputpreserves mode, owner, group, mtime, and symlink targets-rw-r--r-- = type, then owner/group/other triads
Understand exit statussignals every failure mode through it, and your cron job depends on itcmd; echo $?0 is success
Quote shell argumentspasses patterns to itself, not to the shell — unquoted * is a bug--exclude='*.log' (quoted) vs --exclude=*.log (shell may expand)

Soft prerequisites — helpful, learned just in time here

  • SSH with key authentication. Needed for §10 onward. If ssh yourhost already works without a password prompt, you are set. If not, §10 includes the setup.
  • Hard links. Needed only for §14. Taught there from first principles; no prior exposure assumed.
  • cron / launchd / systemd timers. Needed only for §15, which gives complete working unit files for both platforms.

Self-assessment

Answer these before continuing. Each links to where the answer lives if you want to check.

1. What is the difference between ls dir and ls -d dir? Why would rsync care?

ls dir lists what is inside; ls -d dir shows the directory entry itself. rsync makes the same distinction with a trailing slash: src/ means "the contents", src means "the directory named src". Getting this backwards produces the classic nested dst/src/… result. (§4)

2. A file's contents changed but its size and timestamp did not. Will a default rsync -a copy it?

No. The quick check compares size and mtime only. You need --checksum to catch it. This is verified with real output in §6.

3. What does echo $? print after a successful command? After a failed one?

0 after success, non-zero after failure. rsync uses roughly twenty distinct non-zero codes; §15 lists all of them and shows which ones a backup script should tolerate.

4. Why is --exclude=*.log (unquoted) risky?

The shell expands *.log against your current directory before rsync ever sees it. If a file named debug.log exists locally, rsync receives --exclude=debug.log and quietly excludes the wrong thing. Always quote pattern arguments. (§8)

5. Two files on disk share one inode. How much space do they use?

The space of one. That is a hard link, and it is the entire trick behind --link-dest snapshot backups. If this is new, §14 builds it up from scratch — do not skip ahead to it.

How to study this guide Read a section, then run its lab immediately. The labs are 5–12 minutes each, use mktemp -d or a throwaway directory, and never touch anything you care about. Reading rsync and running rsync produce different knowledge; only one of them survives to the moment you actually need it. A realistic pace is three sections plus their labs per sitting, with the quiz (§Quiz) used as spaced review a day later.

Sources: GNU Bash manual — pathname expansion, quoting, exit status · rsync(1) — USAGE

3 · Install & verify: you probably have two rsyncs

On macOS this is not a formality. The rsync that ships with the system is a different program, written by different people, speaking an older protocol, missing flags this guide relies on. Finding out which one you are running takes ten seconds and prevents a whole category of "that flag doesn't work" confusion.

macOS: the openrsync trap

Ask any Mac what its rsync is:

# On macOS 15.6, verified 2026-08-06
/usr/bin/rsync --version
openrsync: protocol version 29
rsync version 2.6.9 compatible

That is openrsync, an independent reimplementation originating in OpenBSD, shipped by Apple in place of the Samba rsync. It is not rsync 2.6.9; it advertises compatibility with that protocol level. Meanwhile, if you have installed rsync from Homebrew:

/usr/local/bin/rsync --version | head -3
rsync  version 3.4.4  protocol version 32
Copyright (C) 1996-2026 by Andrew Tridgell, Wayne Davison, and others.
Web site: https://rsync.samba.org/

Two programs, same name, protocol 29 versus 32. Which one runs depends entirely on your PATH order. Check with which -a rsync — it lists every match in order, and the first one wins.

What openrsync actually cannot do

Verified by running each flag against openrsync on macOS 15.6:

Capabilityrsync 3.4.4Apple's openrsync
-a, -v, --delete, --excludeyesyes — the everyday core works
--link-dest (snapshot backups)yesyes — verified to produce real shared inodes
-i / --itemize-changesyes — 11-character codesyes — but in the older 9-character protocol-29 format (>f+++++++, no slots for ACL/xattr)
--info=progress2yesrsync: unrecognized option `--info=progress2'
-X / --xattrsyesrsync: invalid option -- X
--zc=zstd and modern compressionzstd, lz4, zlibx, zlibrsync: unrecognized option `--zc=zstd'
-v output wordingsending incremental file listTransfer starting: 7 files
The man page lies to you by default Even with Homebrew rsync first in PATH, man rsync on macOS 15.6 resolves to /usr/share/man/man1/openrsync.1. Confirm it yourself with man -w rsync. Consequence: you look up -a, read "Shorthand for -Dgloprt", and get openrsync's definition — while the rsync you are running documents it as -rlptgoD with a paragraph of caveats about ACLs and hardlinks. Read the right page explicitly:
man "$(brew --prefix rsync)/share/man/man1/rsync.1"
Or make it permanent by putting Homebrew's man directory first in MANPATH.

Install the real thing

# macOS — Homebrew
brew install rsync
which -a rsync   # Homebrew's path must come first
rsync --version | head -1
rsync  version 3.4.4  protocol version 32

On Apple Silicon, Homebrew installs under /opt/homebrew; on Intel, under /usr/local. The transcripts in this guide come from an Intel-layout install, so paths read /usr/local/…. Substitute brew --prefix if yours differs.

Ubuntu and WSL

sudo apt update && sudo apt install rsync
rsync --version | head -1

What you get depends on the release, and the spread is wide enough to matter:

Ubuntu seriesrsync versionPractical consequence
22.04 LTS · Jammy Jellyfish3.2.7-0ubuntu0.22.04.7Has zstd, xattrs, --mkpath, --stop-at. Security fixes backported; feature set is 3.2-era.
24.04 LTS · Noble Numbat3.2.7-1ubuntu1.5Same feature set as 22.04. If your WSL is a few years old, this is probably you.
26.04 LTS · Resolute Raccoon3.4.1+ds1-7ubuntu0.3Post-CVE-2024 rewrite. Close enough to this guide that everything here applies.
26.10 development · Stonking Stingray3.4.4+ds1-1build1Matches this guide exactly.
20.04 LTS · Focal Fossa3.1.3-8ubuntu0.9Pre-3.2: no zstd, no --mkpath, no --stop-at, older checksum set.
Version skew is normal and mostly fine A 3.4.4 client talking to a 3.2.7 server negotiates down to the older side's protocol and works. The failure mode is narrower than people fear: it is options, not protocol. If you pass a flag your local rsync knows and the remote does not, you get an option was specified that is supported by the client and not by the server — exit code 4. The fix is to drop the flag, not to upgrade in a panic.

WSL: the filesystem boundary is the real issue

Inside WSL, rsync is ordinary Linux rsync and behaves exactly as documented — as long as both sides live in the Linux filesystem. The moment a path crosses into /mnt/c/…, you are on the Windows drive through a translation layer, and three things change:

  • Permissions are synthesized. NTFS has no Unix mode bits. Preserving them with -a produces noise at best and permission errors at worst. Use -rlt plus explicit --no-perms --no-owner --no-group, or --chmod to impose a mode you choose.
  • Timestamps can be coarse. If the destination filesystem stores mtimes at lower resolution, every run sees a difference and re-copies everything. --modify-window=1 (or -@1) tells rsync to treat timestamps within N seconds as equal (§6). This is the standard fix for FAT/exFAT targets too.
  • It is slow. Cross-boundary I/O in WSL goes through a file-server protocol, not a direct filesystem. A tree that syncs in two seconds inside ~ can take a minute under /mnt/c. Keep working sets on the Linux side and sync outward deliberately.
# WSL → Windows drive: don't fight NTFS over ownership
rsync -rlt --no-perms --no-owner --no-group --modify-window=1 \
      --delete ~/project/ /mnt/c/Users/you/project/
Going the other direction From Windows, your WSL filesystem is reachable at \\wsl$\Ubuntu\home\you. Do not rsync into that path from a Windows-side tool if you can avoid it — run rsync inside WSL and let it write to /mnt/c. One translation layer is enough.
Lab 1 · Know which rsync you are running 5 minutes · local only · no remote needed

Goal: never again be surprised by a flag that "doesn't exist".

  1. List every rsync on your PATH, in priority order:

    which -a rsync

    Expect: on a Mac with Homebrew rsync installed, two lines — /usr/local/bin/rsync then /usr/bin/rsync. In WSL, one line: /usr/bin/rsync.

  2. Print the version of the one that wins:

    rsync --version | head -1

    Expect: rsync version 3.4.4 protocol version 32. If instead you see openrsync: protocol version 29, your PATH favors Apple's build — fix that before continuing.

  3. Find out which man page you would actually read:

    man -w rsync

    Expect (macOS): /usr/share/man/man1/openrsync.1 — the wrong page, even when the right binary is first. On Ubuntu/WSL you get /usr/share/man/man1/rsync.1.gz, which is correct.

  4. Check the capability line, which tells you what this build was compiled with:

    rsync --version | sed -n '4,12p'

    Expect: a Capabilities: block naming ACLs, xattrs, … crtimes, a Checksum list including xxh128, and a Compress list including zstd. Anything missing here is a flag that will fail later no matter what the docs say.

Checkpoint: you can state your rsync's version, its protocol number, and whether it supports xattrs — from memory of what you just ran.

Sources: rsync downloads & release history · Ubuntu rsync package versions · openrsync(1)

4 · The trailing slash, settled forever

This is rsync's most famous confusion and it has exactly one rule. The rule is not "remember which way it goes." The rule is that a trailing slash on the source means "the contents of this directory" and its absence means "this directory". Everything else follows.

Proof, from a real run

Same source tree, same destination, one character apart:

# WITHOUT the slash — "copy the directory src"
rsync -av src dst/
sending incremental file list
src/
src/a.txt
src/b.txt
src/docs/
src/docs/note.md

find dst
dst
dst/src           ← src itself landed inside dst
dst/src/a.txt
dst/src/b.txt
dst/src/docs
dst/src/docs/note.md
# WITH the slash — "copy the contents of src"
rsync -av src/ dst/
sending incremental file list
a.txt
b.txt
docs/
docs/note.md

find dst
dst
dst/a.txt         ← contents landed directly in dst
dst/b.txt
dst/docs
dst/docs/note.md

Notice the verbose output already told you before find did. With the slash, paths are printed relative to the source; without it, every path is prefixed with src/. Read the first line of file output and you know which behavior you are getting — that is the habit worth building.

The destination slash does nothing (for directory sources)

Half the internet's advice hedges here. It does not need to. Verified:

rsync -a src/ d1     # no trailing slash on dest
rsync -a src/ d2/    # trailing slash on dest
find d1 d2
d1
d1/f.txt
d2
d2/f.txt          ← identical
The one case where the destination slash matters When the source is a single file and the destination does not exist yet, the slash decides whether you create a file or a directory:
rsync -a src/f.txt out     → out is a FILE (a copy of f.txt)
rsync -a src/f.txt out2/   → out2 is a DIRECTORY containing f.txt
Both verified. This is the only asymmetry; for directory sources the destination slash is decoration.

Interactive: predict the tree

Toggle the slashes and watch the destination change. Then close your eyes, guess, and check.

trailing-slash simulatorsource tree fixed · destination predicted

source: src/

src/
├── a.txt
├── b.txt
└── docs/
    └── note.md

destination after the run


          
The mnemonic that actually sticks Say the command out loud with the slash pronounced as the word "contents". rsync -a src/ dst/ reads "sync the contents of src into dst." rsync -a src dst/ reads "sync src into dst." You already know which one you want; you just needed the sentence.

Where this bites in real life

The idempotency trap

Forgetting the slash and running twice does not create dst/src/src — it creates dst/src and then reconciles it. So the mistake is quiet. You discover it a week later when the backup is one level deeper than every script expects.

The --delete amplifier

With --delete, the slash mistake becomes destructive. rsync -a --delete src dst/ makes dst contain only src/, deleting everything else already in dst. Always dry-run a --delete command you have just edited. (§9)

Lab 2 · Burn in the slash rule 6 minutes · local only · fully disposable
  1. Build a throwaway tree:

    cd "$(mktemp -d)" && mkdir -p src/docs dst
    printf 'alpha\n' > src/a.txt
    printf 'note\n'  > src/docs/note.md
  2. Run it the wrong way first — the mistake is more instructive than the fix:

    rsync -av src dst/ && find dst

    Expect: transferred paths printed as src/a.txt, and find showing dst/src/a.txt.

  3. Clear and do it right:

    rm -rf dst && mkdir dst
    rsync -av src/ dst/ && find dst

    Expect: paths printed as bare a.txt and docs/note.md; find shows dst/a.txt.

  4. Prove the destination slash is irrelevant here:

    rsync -a src/ d1 && rsync -a src/ d2/ && diff -r d1 d2 && echo IDENTICAL

    Expect: IDENTICAL, with no output from diff.

  5. Now the single-file asymmetry:

    rsync -a src/a.txt out && test -f out && echo "out is a FILE"
    rsync -a src/a.txt out2/ && test -d out2 && echo "out2 is a DIRECTORY"

    Expect: both echo lines print.

Checkpoint: without running anything, state what rsync -a ~/photos ~/backup/ produces versus rsync -a ~/photos/ ~/backup/. If you hesitated, redo step 2.

Sources: rsync(1) — "a trailing slash on the source"

5 · Anatomy of a command: the eleven flags that matter

rsync 3.4.4 documents over two hundred options. You will use eleven of them regularly and about fifteen more occasionally. The rest exist for mirror operators, distribution builders, and people recovering from filesystems that no longer ship. This section teaches the eleven and, more importantly, how to compose them.

The shape

rsyncthe program
+
OPTIONSwhat to preserve, what to skip, what to delete
+
SRC…one or more sources; slash decides contents-vs-directory
+
DESTexactly one destination, always last

Either SRC or DEST may carry a host: prefix, never both. That single constraint defines rsync's whole network model, and §10 unpacks it.

-a is not magic, it is an abbreviation

The archive flag is where most people stop reading, which is a shame, because knowing its expansion tells you exactly what it does not do. From the rsync 3.4.4 manual:

# --archive, -a  →  equivalent to -rlptgoD
-r  --recurse       descend into directories
-l  --links         copy symlinks as symlinks
-p  --perms         preserve permissions
-t  --times         preserve modification times
-g  --group         preserve group
-o  --owner         preserve owner   (silently ignored without privilege — §12)
-D  --devices --specials   preserve device and special files
What -a deliberately leaves out The manual is explicit: -a does not include ACLs (-A), extended attributes (-X), access times (-U), creation times (-N), or hard-link detection (-H). If you assumed "archive" meant "everything", §12 is the correction — and on macOS the omission of -X silently drops Finder tags and Spotlight metadata.

Note also: openrsync documents -a as -Dgloprt. Same seven letters, different order, and it is a different program's promise. One more reason to read the right man page (§3).

The working set

FlagWhat it doesWhen you reach for it
-arecurse + preserve the usual metadataAlways. This is the baseline, not an option.
-vname each transferred fileInteractive runs. Omit in cron unless you want mail.
-n / --dry-rundecide everything, change nothingBefore any --delete or any command you just edited.
-i / --itemize-changesemit a change code per fileWhenever "why did it do that?" comes up. (§7)
--deleteremove destination files absent from sourceMirroring. Never without a dry run first. (§9)
--exclude=PATskip matching pathsConstantly. Quote the pattern. (§8)
-z / --compresscompress the stream in flightSlow or metered links only. (§13)
-P--partial --progress togetherBig transfers you might interrupt.
-hhuman-readable byte countsAlways, honestly. Costs nothing.
-e ssh …choose/configure the remote shellNon-default ports, identity files, jump hosts. (§10)
--statsprint a transfer summaryLearning, benchmarking, and proving delta worked.

The universal starting pattern

# Say this to yourself, then edit down. It is safe by construction:
rsync -avhn --delete SRC/ DEST/     # look
rsync -avh  --delete SRC/ DEST/     # leap (drop the n)

Type the dry run, read it, then press and delete one character. That two-step is the single highest-value habit in this guide, and it costs about four seconds.

Interactive: compose a command

Toggle flags and read what the command actually means. The expansion line shows what -a unfolds into, so you can see when a flag you added is already implied.

flag composerrsync 3.4.4 semantics

Opinionated default Use -avh as your reflex and add from there. Do not reach for -z by habit — on a LAN or a local disk it costs CPU and saves nothing, and on already-compressed data (photos, video, .gz) it is pure loss. Do not reach for --checksum by habit either; it reads every byte on both sides, which is precisely the work rsync exists to avoid.

Sources: rsync(1) — OPTIONS SUMMARY

6 · The quick check and the delta algorithm

Two mechanisms decide everything rsync does. The quick check decides whether a file is transferred. The delta-transfer algorithm decides how much of it crosses the wire. They are independent, they are both defaults, and both have a switch.

Mechanism 1: the quick check

Before transferring anything, the generator compares each source file against its destination counterpart. The default test — the "quick check" — is:

Transfer this file if the size differs, or the modification time differs. That is the whole test. Contents are not read. If both match, rsync skips the file entirely, which is why the second run of any command is nearly instant.

It is a very good heuristic. It is also falsifiable, and here is the falsification, run for real:

# Two files, same length, different content, identical timestamps
printf 'same size!\n' > src/f.txt   # synced to dst
printf 'SAME SIZE!\n' > src/f.txt   # edited: 10 bytes → 10 bytes
touch -t 202608061200 src/f.txt dst/f.txt

rsync -avi src/ dst/
sent 75 bytes  received 12 bytes  174.00 bytes/sec   ← nothing transferred!

rsync -avic src/ dst/          # -c is --checksum
>fc........ f.txt
sent 149 bytes  received 35 bytes  368.00 bytes/sec
cat dst/f.txt
SAME SIZE!

The c in >fc........ is rsync telling you the checksum differed — a code you can only see because --checksum made it look. §7 decodes the rest of that string.

Tuning the quick check

FlagChanges the test toCost / use
-c / --checksumcompare a full-file checksum on files whose size already matches (algorithm auto-negotiated; xxh128 between 3.2+ peers, MD5 with older ones)Reads every byte on both sides. Use to verify a migration, not routinely.
--size-onlysize alone; ignore timestampsFor destinations with untrustworthy clocks. Misses same-size edits.
-I / --ignore-timesnever skip; always transferForces the delta algorithm to run on everything. Rarely what you want.
-u / --updateskip files that are newer on the receiverProtects destination edits. Verified below.
--modify-window=Ntreat mtimes within N seconds as equalFAT/exFAT/NTFS-over-WSL destinations. Usually 1.
# -u in action: destination file is newer, so it survives
rsync -avi -u up/src/ up/dst/
sent 77 bytes  received 12 bytes
cat up/dst/f.txt
NEWER ON DEST          ← preserved

# without -u, the source wins unconditionally
rsync -avi up/src/ up/dst/
>f.st...... f.txt
cat up/dst/f.txt
old                    ← clobbered

Two neighbours worth knowing: --existing and --ignore-existing

These do not tune the comparison — they restrict which files are eligible at all. They are exact opposites and their names are unhelpfully similar, so learn them as a pair. Source has have.txt (also at the destination) and new.txt (not):

# --existing: update only what is already there. Never create.
rsync -ain --existing src/ dst/
.d..t...... ./
>f.st...... have.txt        ← new.txt is not created

# --ignore-existing: create only what is missing. Never update.
rsync -ain --ignore-existing src/ dst/
>f+++++++++ new.txt          ← have.txt is left alone

--existing is for pushing a config change to machines that already opted in. --ignore-existing is for topping up a destination whose copies you must not disturb — filling gaps in a media library, or seeding without overwriting local edits.

Mechanism 2: the delta-transfer algorithm

Once rsync decides a file needs updating, it does not necessarily send the file. This is the algorithm rsync is named for, and the reason it exists.

How the delta-transfer algorithm avoids sending whole files receiver — has the old copy B1B2 B3B4 B5 Split into fixed-size blocks. For each, compute a cheap rolling checksum and a strong hash. Send that list only. cost: ~ a few bytes per block, not the block checksum list → sender — has the new copy B1B2 NEWB4 B5 Roll a window byte-by-byte over the new file. When the cheap checksum hits, confirm with the strong hash. Matches become references; everything else is literal data. ← deltas what actually crosses the wire "use your B1, B2" literal NEW "B4, B5" Real measurement, 10 MB file, 50 bytes changed: Matched 10,484,608 · Literal 1,152 · speedup 310.52 vs. whole-file: Literal 10,485,760 · speedup 1.00 when this is turned OFF Local copies default to --whole-file: with both files on local disks, reading them to compute deltas costs more than just writing the bytes. rsync makes that call for you. force it back on with --no-whole-file
The receiver describes what it already has; the sender sends only what is missing. The rolling checksum is what makes this work even when bytes are inserted, shifting everything downstream — a fixed-offset comparison would find nothing.

Proof, both directions

A 10 MB file, copied, then 50 bytes overwritten in the middle. Over a remote transport:

# first copy — everything is literal
Literal data: 10,485,760 bytes
Matched data: 0 bytes
Total bytes sent: 10,488,443
speedup is 1.00

# after changing 50 bytes — delta kicks in
Literal data: 1,152 bytes
Matched data: 10,484,608 bytes
Total bytes sent: 14,263
Total bytes received: 19,505
speedup is 310.52

The same change on a purely local copy sends everything:

# local src → local dst, default behavior
Literal data: 10,485,760 bytes
Matched data: 0 bytes
speedup is 1.00

# same local copy with --no-whole-file
Literal data: 1,152 bytes
Matched data: 10,484,608 bytes
speedup is 310.54
Misconception: "rsync always sends only the changes." Only across a remote transport. For a local-to-local copy — including to a mounted external drive or an SMB/NFS share, which look local to rsync — the delta algorithm is disabled by default because --whole-file is implied. The reasoning is sound: computing deltas requires reading both files completely, and if both live on disks you own, reading 20 MB to avoid writing 10 MB is a loss. It stops being sound when the "local" destination is a slow network mount, which is exactly when to add --no-whole-file.

The simplified model versus the complete one

Earlier this guide said "the delta algorithm sends only changed blocks." The complete model adds three qualifications you now have the context for:

  • Block size is chosen from the file size (or forced with -B). Very large files get larger blocks, so tiny scattered edits can still transmit more than you expect.
  • The receiver must have an old copy to describe. A brand-new file has no basis, so it is always fully literal — unless you supply one with --fuzzy, --link-dest, or --compare-dest (§14).
  • The speedup figure counts file bytes versus wire bytes, including protocol overhead. It is a useful ratio, not a benchmark.
Lab 3 · Watch the quick check fail, then fix it 8 minutes · local only · fully disposable
  1. Set up a same-size edit:

    cd "$(mktemp -d)" && mkdir -p src dst
    printf 'same size!\n' > src/f.txt
    rsync -a src/ dst/
    printf 'SAME SIZE!\n' > src/f.txt
    touch -t 202608061200 src/f.txt dst/f.txt
  2. Confirm rsync refuses to notice:

    rsync -avi src/ dst/ ; cat dst/f.txt

    Expect: no itemized line for f.txt, and dst/f.txt still reading same size!. This is not a bug; it is the documented quick check.

  3. Force a content comparison:

    rsync -avic src/ dst/ ; cat dst/f.txt

    Expect: >fc........ f.txt and SAME SIZE!.

  4. Now measure delta transfer. Build a 10 MB file and sync it, then change 50 bytes in the middle:

    head -c 10485760 /dev/zero | tr '\0' 'A' > src/big.dat
    rsync -a --stats src/big.dat dst/ | grep -E 'Literal|Matched'
    sleep 2
    printf 'Z%.0s' {1..50} | dd of=src/big.dat bs=1 seek=5000000 conv=notrunc status=none

    Expect: Literal data: 10,485,760 bytes / Matched data: 0 bytes on the first sync.

  5. Sync again twice — once with the local default, once forcing delta:

    rsync -a --stats src/big.dat dst/ | grep -E 'Literal|Matched|speedup'
    printf 'Q%.0s' {1..50} | dd of=src/big.dat bs=1 seek=6000000 conv=notrunc status=none
    rsync -a --stats --no-whole-file src/big.dat dst/ | grep -E 'Literal|Matched|speedup'

    Expect: the first shows Literal data: 10,485,760 and speedup is 1.00; the second shows roughly Literal data: 1,152, Matched data: 10,484,608, speedup is 310-ish. Your exact literal figure depends on block size, which scales with file size.

Checkpoint: explain, in one sentence each, why step 2 transferred nothing and why step 5's two runs differed by 300×.

Sources: The rsync algorithm — technical report · rsync(1) — --checksum, --whole-file, --size-only

7 · Reading rsync's output

rsync's default verbose output tells you what it touched. The itemized output tells you why. Learning the eleven-character change string converts rsync from a black box into an instrument, and it takes about ten minutes.

The default: -v

rsync -av src/ dst/
sending incremental file list
a.txt
b.txt
docs/
docs/note.md

sent 304 bytes  received 85 bytes  778.00 bytes/sec
total size is 16  speedup is 0.04

Three things worth naming. "sending incremental file list" means rsync is streaming the file list as it walks, not building it all up front — that is why big trees start transferring immediately. Directories appear with a trailing slash when they are created or their attributes change. speedup below 1.00 is normal for tiny transfers; protocol overhead dominates.

The instrument: -i

Every changed item gets an eleven-character code:

rsync -avi src/ dst/
.d..t...... ./
>f.st...... a.txt
.f...p..... b.txt
>f+++++++++ c.txt
cd+++++++++ docs/
cL+++++++++ link.lnk -> target.txt
*deleting   b.txt

Read it positionally. Positions 1–2 are what and how; positions 3–11 are which attributes changed.

itemize decoderclick any position · -i / --itemize-changes

The full position table

PosMeaningValues you will see
1Update type / direction> received by this side · < sent to the remote · c created locally (dir, symlink, device) · h became a hard link · . no transfer, attributes only · * message follows (e.g. *deleting)
2File typef file · d directory · L symlink · D device · S special
3cregular file's checksum differs (needs --checksum), or a symlink/device/special file's value changed. A brand-new item shows + here, like every other slot.
4ssize differs
5tmodification time differs and is being updated (T = set to transfer time instead)
6ppermissions differ
7oowner differs (needs -o and privileges)
8ggroup differs
9u/n/baccess time differs (u, needs -U/--atimes), create time differs (n, needs -N/--crtimes), or both (b). Without those flags: always .
10aACL differs (needs -A)
11xextended attributes differ (needs -X)
The two codes you will see most >f+++++++++ — a brand-new file; all nine attribute slots are + because there is nothing to compare against. >f.st...... — an existing file whose size and time changed, i.e. a normal edit. Once those two are instant recognition, everything else is a small deviation you can look up.

--dry-run and its one honest limitation

-n makes every decision and performs none of them. Combined with -i it is the best tool in the box:

rsync -ain --delete src/ dst/
*deleting   b.txt
>f.st...... a.txt
Dry runs print things they did not do A dry run into a non-existent destination prints created directory out — and then does not create it. Verified: after the run, ls -d out returns No such file or directory. Worse, because the directory does not exist, rsync cannot inspect what is inside it, so a dry run into a fresh destination shows every file as new even when a real run would find matches. Dry-run output is a plan, not a transcript.

Progress, for humans

OptionShowsNotes
--progressper-file percentage, rate, ETANoisy with many small files.
--info=progress2one running total for the whole transferWhat you actually want. Pair with -h. Not in openrsync.
-P--partial --progressThe interactive big-transfer default.
--statsend-of-run summary incl. literal/matchedHow you prove delta transfer worked.
rsync -ah --info=progress2 src/ dst/
          2.10M 100%  246.43MB/s    0:00:00 (xfr#3, to-chk=0/4)

xfr#3 is the third file actually transferred; to-chk=0/4 means zero of four remaining file-list entries left to examine. When to-chk stalls at a high number, rsync is still walking the tree, not stuck.

Lab 4 · Read the change codes 7 minutes · local only · fully disposable
  1. Fresh tree, first sync:

    cd "$(mktemp -d)" && mkdir -p src/docs dst
    printf 'alpha\n' > src/a.txt ; printf 'beta\n' > src/b.txt
    printf 'note\n'  > src/docs/note.md
    rsync -avi src/ dst/

    Expect: >f+++++++++ a.txt, cd+++++++++ docs/, and .d..t...... ./ for the destination root itself.

  2. Make three different kinds of change at once:

    printf 'alpha CHANGED\n' > src/a.txt   # content + size
    chmod 600 src/b.txt                    # permissions only
    printf 'gamma\n' > src/c.txt            # brand new
    rsync -avi src/ dst/

    Expect exactly:

    .d..t...... ./
    >f.st...... a.txt
    .f...p..... b.txt
    >f+++++++++ c.txt

    Read each: a.txt transferred with size and time changes; b.txt not transferred at all (leading .) but its permissions were fixed; c.txt is new.

  3. Delete a source file and dry-run the deletion:

    rm src/b.txt
    rsync -avin --delete src/ dst/

    Expect: *deleting b.txt and a summary line ending (DRY RUN).

  4. Verify the dry run changed nothing, then commit:

    ls dst/ ; rsync -avi --delete src/ dst/ ; ls dst/

    Expect: b.txt present in the first listing, absent in the second.

Checkpoint: given .f...p....., say whether any bytes crossed the wire. (No — position 1 is .. Only the mode was adjusted.)

Sources: rsync(1) — --itemize-changes, --info, --dry-run

8 · Filter rules: the part everyone re-reads

Filter rules are rsync's second real language, and they are re-read every time because they have three behaviors that violate intuition: first match wins, directories are pruned before their contents are examined, and a pattern's anchoring changes with a single leading slash. Learn those three and the rest is syntax.

Rule 1: first match wins

rsync evaluates rules in the order given and stops at the first one that matches. Not most-specific-wins. Not last-wins. First. Verified:

# WRONG order — the exclude fires first, include never runs
rsync -ain --exclude='*.log' --include='debug.log' proj/ out/
(no line for debug.log — it was excluded)

# RIGHT order — the include is reached first and wins
rsync -ain --include='debug.log' --exclude='*.log' proj/ out/
>f+++++++++ docs/debug.log
Misconception: "--include adds files back." It does not add anything. There is no set to add to — every file is included by default. --include means "if you reach this rule, stop and keep the file", and it only has an effect when it is positioned before an exclude that would otherwise catch the file. An --include with no exclude after it is a no-op.

Rule 2: directories are pruned, not searched

If a rule excludes a directory, rsync never descends into it — so a later include for something inside it can never fire. This is why the "include only .md files" idiom needs the seemingly redundant --include='*/':

rsync -ain --include='*/' --include='*.md' --exclude='*' proj/ out/
cd+++++++++ ./
cd+++++++++ .git/
cd+++++++++ build/
cd+++++++++ docs/
>f+++++++++ docs/readme.md
cd+++++++++ node_modules/
cd+++++++++ node_modules/pkg/
cd+++++++++ src/

Read it: --include='*/' keeps every directory so rsync can walk in; --include='*.md' keeps the files you want; --exclude='*' drops everything else. It works — but look at all those empty directories. Add -m (--prune-empty-dirs):

rsync -ainm --include='*/' --include='*.md' --exclude='*' proj/ out/
cd+++++++++ ./
cd+++++++++ docs/
>f+++++++++ docs/readme.md

This four-part incantation — -m, include dirs, include what you want, exclude the rest — is worth memorizing verbatim. It is the answer to "copy only the X files from this tree."

Rule 3: anchoring

PatternMatchesDoes not match
buildbuild anywhere in the tree, at any depth
/buildbuild only at the transfer rootsrc/build
build/only when build is a directorya file named build
*.logany .log at any depth (* stops at /)
**/tmptmp at any depth (** crosses /)
src/**.o.o at any depth under src, including src/main.olib/x.o; note src/**/*.o would also miss src/main.o** cannot match nothing before a /

The leading slash is relative to the transfer root, not your filesystem root and not your working directory. In rsync -a ~/proj/ backup:/srv/, the rule /build means ~/proj/build.

Where rules can live

On the command line

--exclude=PAT and --include=PAT. Fine for two or three rules. Always quote the pattern so the shell does not expand it.

In a file

--exclude-from=FILE, one pattern per line, # comments allowed. This is where a real project's rules belong — under version control, next to the deploy script.

As --filter rules

The full language: -f '- *.log' excludes, -f '+ keep.log' includes, -f 'P /uploads/' protects from deletion, -f 'H pattern' hides from the sender.

Per-directory, merged

-f 'dir-merge /.rsync-filter' — or its shorthand -F — reads a .rsync-filter file in each directory it visits, applying it to that subtree. Rules live with the content they govern.

--exclude-from, the practical default

cat excludes.txt
node_modules/
build/
*.log
.git/

rsync -ain --exclude-from=excludes.txt proj/ out/
cd+++++++++ ./
cd+++++++++ docs/
>f+++++++++ docs/readme.md
cd+++++++++ src/
>f+++++++++ src/main.c

Per-directory merge files

Drop a .rsync-filter into any directory and its rules govern that subtree:

cat proj/docs/.rsync-filter
- *.log

rsync -ain --filter='dir-merge /.rsync-filter' proj/ out/   # fresh out/
created directory out
cd+++++++++ ./
>f+++++++++ app.log             ← root .log survives: rule is scoped to docs/
cd+++++++++ docs/
>f+++++++++ docs/.rsync-filter  ← the rule file itself gets copied
>f+++++++++ docs/readme.md      ← docs/debug.log was excluded
cd+++++++++ src/
>f+++++++++ src/main.c

Doubling the shorthand — -FF instead of -F — also excludes the filter files themselves from transfer:

rsync -ain -FF proj/ out/   # fresh out/
created directory out
cd+++++++++ ./
>f+++++++++ app.log             ← root .log still survives
cd+++++++++ docs/
>f+++++++++ docs/readme.md      ← no .rsync-filter, no debug.log
cd+++++++++ src/
>f+++++++++ src/main.c
Opinionated default Use --exclude-from= a version-controlled file for anything you run more than twice. Reserve -F for trees where different subdirectories genuinely need different rules and the rules should travel with the content — a documentation repo, a shared media library. Do not reach for --filter's full syntax until P (protect) is what you actually need; the short forms cover the rest.

Debugging rules that will not fire

Three moves, in order:

  1. -ain and read the file list. If a file appears, no rule excluded it. If it is missing, some rule did.
  2. Ask rsync to explain itself: --debug=FILTER2 prints each rule as parsed and names every path it hides and why. This is the fastest path from "why is this still copying" to an answer. (Level 1 is nearly silent; use FILTER2.)
  3. Check for shell expansion. Run echo in front of your command. If --exclude=*.log came back as --exclude=app.log, your quoting is the bug, not your rule.
Lab 5 · Bend the filter language to your will 12 minutes · local only · fully disposable
  1. Build a realistic project tree:

    cd "$(mktemp -d)"
    mkdir -p proj/src proj/build proj/node_modules/pkg proj/docs proj/.git
    printf 'code\n' > proj/src/main.c ; printf 'obj\n'  > proj/build/main.o
    printf 'dep\n'  > proj/node_modules/pkg/index.js
    printf 'doc\n'  > proj/docs/readme.md ; printf 'log\n' > proj/app.log
    printf 'log2\n' > proj/docs/debug.log ; printf 'gitobj\n' > proj/.git/HEAD
  2. Exclude the obvious junk:

    rsync -ain --exclude='node_modules' --exclude='*.log' proj/ out/

    Expect: src/main.c, docs/readme.md, build/main.o, .git/HEAD — and no node_modules or .log anywhere.

  3. Demonstrate first-match-wins by running the same two rules in both orders:

    rsync -ain --exclude='*.log' --include='debug.log' proj/ out/ | grep debug.log
    rsync -ain --include='debug.log' --exclude='*.log' proj/ out/ | grep debug.log

    Expect: nothing from the first command; >f+++++++++ docs/debug.log from the second.

  4. Copy only Markdown, with and without pruning:

    rsync -ain  --include='*/' --include='*.md' --exclude='*' proj/ out/
    rsync -ainm --include='*/' --include='*.md' --exclude='*' proj/ out/

    Expect: the first lists eight lines including empty node_modules/pkg/; the second lists exactly three — ./, docs/, docs/readme.md.

  5. Scope a rule to one subtree with a merge file:

    printf -- '- *.log\n' > proj/docs/.rsync-filter
    rsync -ain -F  proj/ out/ | grep -E 'app.log|debug.log|rsync-filter'
    rsync -ain -FF proj/ out/ | grep -E 'app.log|debug.log|rsync-filter'

    Expect: -F keeps app.log (root scope, untouched by the docs rule) and copies docs/.rsync-filter; -FF keeps app.log but omits the filter file itself. Neither copies docs/debug.log.

  6. Ask rsync to show its reasoning:

    rm proj/docs/.rsync-filter
    rsync -ain --debug=FILTER2 --exclude='node_modules' --exclude='*.log' proj/ out/ 2>&1 | head -11

    Expect (paths abbreviated):

    [client] add_rule(- node_modules)
    [client] add_rule(- *.log)
    [sender] pushing local filters for …/proj/
    [sender] hiding directory node_modules because of pattern node_modules
    [sender] hiding file app.log because of pattern *.log
    [sender] pushing local filters for …/proj/.git/
    [sender] pushing local filters for …/proj/build/
    [sender] pushing local filters for …/proj/docs/
    [sender] hiding file docs/debug.log because of pattern *.log

    hiding … because of pattern … is the line you came for: it names the file and the exact rule that caught it.

Checkpoint: from memory, write the command that copies only .jpg and .png files out of a deep tree, with no empty directories in the result.

Sources: rsync(1) — FILTER RULES, INCLUDE/EXCLUDE PATTERN RULES, MERGE-FILE FILTER RULES

9 · Deleting safely

The wrong question is "is --delete dangerous?" The right question is "what exactly does rsync consider extraneous?" Answer that and --delete becomes ordinary. Guess at it and you will eventually remove something you wanted. One fear you can drop now: --delete only ever touches the receiving side. Your source is never at risk — unless you explicitly ask with --remove-source-files, covered below.

What --delete means

It means: on the receiving side, remove files that are not in the sending side's file list. The emphasis matters. "Not in the file list" is not the same as "not in the source directory", because filter rules and --exclude shape the file list before deletion ever looks at it.

rm src/b.txt
rsync -avi --delete --dry-run src/ dst/
sending incremental file list
*deleting   b.txt

sent 211 bytes  received 30 bytes  482.00 bytes/sec
total size is 33  speedup is 0.14 (DRY RUN)

The five deletion timings

OptionWhen deletions happenChoose it when
--deletealias for --delete-during in 3.xDefault. Just use this.
--delete-beforewhole destination scanned and pruned firstDestination is nearly full and you need the space before writing.
--delete-duringper-directory, as the transfer walksLowest memory, no up-front scan. The modern default.
--delete-delaycomputed during, applied at the endWant deletions batched but not a pre-scan.
--delete-afterafter all transfers completePublishing a site: new files land before old ones vanish.
Opinionated default Use plain --delete for backups and mirrors. Use --delete-after for deploys, so a failed transfer leaves the old content intact rather than a half-emptied directory. Those two cover essentially every real case; the other three are for situations you will recognize when you are in them.

The guard rail: --max-delete

A source that failed to mount looks exactly like a source where you deleted everything. --max-delete=N is the cheap insurance:

rsync -avi --delete --max-delete=2 src/ dst/
*deleting   f3.txt
*deleting   f2.txt
Deletions stopped due to --max-delete limit (1 skipped)

sent 95 bytes  received 39 bytes  268.00 bytes/sec
rsync error: the --max-delete limit stopped deletions (code 25) at main.c(1356) [sender=3.4.4]
echo $?
25

Note the behavior precisely: it deletes up to the limit, then stops and exits 25. It does not roll back the deletions it already made. Treat exit 25 in a scheduled job as "investigate now" — that is exactly the signal you wanted.

The empty-source catastrophe rsync -a --delete /Volumes/Backup/ ~/Documents/ with the volume unmounted does not error. /Volumes/Backup/ exists as an empty directory, so rsync faithfully makes your Documents folder match it — by deleting everything. Three defenses, in order of value:
  1. Always pass --max-delete on any automated --delete job.
  2. Guard the script: mountpoint -q /mnt/backup || exit 1 on Linux; [ -f /Volumes/Backup/.backup-marker ] || exit 1 on macOS, where you place a marker file on the volume itself.
  3. Add --dry-run to the command in your editor and remove it as the last edit before saving.

Safety nets that keep the old bytes

--backup turns overwrite-and-delete into move-aside. With --backup-dir, the displaced files land in a parallel tree rather than littering the destination with ~ suffixes:

rsync -avi --backup --backup-dir=../attic --suffix='' src/ dst/
sending incremental file list
>f..t...... f.txt

cat attic/f.txt
original          ← the pre-overwrite content, preserved

--backup-dir is relative to the destination, so ../attic means a sibling of dst. Combine with a dated directory for a poor man's version history:

rsync -a --delete --backup --backup-dir="../attic/$(date +%F)" src/ dst/
Related but different: --remove-source-files This deletes from the sending side after a successful transfer — an rsync-flavored mv that verifies before removing. Verified caveat: it removes files only, never directories. After the run your source tree is an empty skeleton of directories:
rsync -av --remove-source-files src/ dst/
find src
src
src/d            ← directories remain, files are gone
Follow with find src -type d -empty -delete if you want the skeleton gone too.

Deletion and filters interact — badly, if you are careless

An excluded file is not in the file list, so from --delete's point of view it is extraneous at the destination and gets removed. That is usually wrong: you excluded node_modules from the transfer, not from existence. Three flags resolve the ambiguity:

FlagEffect on excluded files at the destination
--delete-excludedDelete them. Use when the exclusion means "this must not exist here".
--exclude=PAT (plain)Excluded files are protected from deletion on the receiver — the exclusion applies to both sides. This is the default and it is usually what you want.
--filter='protect PAT' (-f 'P PAT')Explicitly shield destination paths from --delete without excluding them from transfer logic.

Verified, with a destination that holds app.log (matching the exclude) and junk.txt (not matching):

rsync -avi --delete --exclude='*.log' src/ dst/
*deleting   junk.txt
>f+++++++++ keep.txt
ls dst/
app.log  keep.txt        ← the excluded file survived

rsync -avi --delete --delete-excluded --exclude='*.log' src/ dst/
*deleting   app.log
ls dst/
keep.txt                 ← now it is gone

The practical rule: if you want the destination to keep something rsync never sends — a .env, an uploads directory, a build cache — say so explicitly with a protect rule rather than relying on exclusion semantics you would have to re-derive next year.

# Deploy: replace the code, never touch runtime state
rsync -av --delete-after \
      --filter='P /uploads/' --filter='P /.env' \
      --exclude='.git/' ./build/ web:/srv/app/
Lab 6 · Delete without fear 9 minutes · local only · fully disposable
  1. Build a mirror with five files:

    cd "$(mktemp -d)" && mkdir -p src dst
    for i in 1 2 3 4 5; do printf "x\n" > src/f$i.txt; done
    rsync -a src/ dst/ && ls dst/
  2. Remove three sources and preview the damage:

    rm src/f1.txt src/f2.txt src/f3.txt
    rsync -avin --delete src/ dst/

    Expect: three *deleting lines and (DRY RUN) in the summary.

  3. Now cap the damage and watch rsync refuse:

    rsync -avi --delete --max-delete=2 src/ dst/; echo "exit=$?"

    Expect: two deletions, then Deletions stopped due to --max-delete limit (1 skipped), then rsync error: the --max-delete limit stopped deletions (code 25) and exit=25. Confirm with ls dst/ that one of the three survived.

  4. Prove the empty-source catastrophe in a safe sandbox — read this step before running it:

    mkdir empty
    rsync -avin --delete empty/ dst/

    Expect: a *deleting line for every remaining file in dst. Nothing happens because of -n. This is precisely what an unmounted backup volume produces, and precisely why --max-delete belongs in every automated job.

  5. Add the safety net and repeat for real:

    rsync -avi --delete --backup --backup-dir=../attic --suffix='' src/ dst/
    ls attic/

    Expect: the deleted files reappear under attic/ instead of being gone. --backup catches deletions, not only overwrites.

Checkpoint: write, from memory, a --delete command that refuses to remove more than 20 files and keeps a dated copy of anything it removes.

Sources: rsync(1) — --delete*, --max-delete, --backup-dir, --remove-source-files

10 · Remote over SSH: what actually happens

rsync has no network code for the SSH case. It runs ssh host rsync --server … and talks to that process over the pipe. Once you have seen the command it constructs, remote rsync stops being a separate topic and becomes local rsync with a longer pipe.

The syntax

# push: local → remote
rsync -avh ~/site/ web.example.com:/srv/site/

# pull: remote → local
rsync -avh web.example.com:/srv/site/ ~/site/

# with a user, and a path relative to that user's home
rsync -avh ~/notes/ deploy@web.example.com:notes/

Exactly one side may carry a host: prefix. rsync cannot copy remote-to-remote directly; it would need to be on one of the machines. (You can fake it by running rsync on one of the hosts over SSH, but that is you orchestrating, not rsync.)

Remote paths without a leading slash are relative to the remote home host:notes/ means ~/notes/ on the remote, not /notes/. This is the same rule as scp and it is the source of the occasional "where did my files go?" — they went to the deploy user's home directory.

What rsync sends to the far side

Substitute a logging script for ssh with -e and rsync will show you its hand. These transcripts come from exactly that — a stand-in remote shell that prints its arguments and then runs the command locally. The argv is genuine; the transport is a pipe rather than a network:

# push
rsync -av -e ./logrsh src/ fakehost:/tmp/dst/
REMOTE-SHELL ARGV: fakehost rsync --server -vlogDtpre.iLsfxCIvu . /tmp/dst/

# pull — note --sender appears
rsync -av -e ./logrsh fakehost:/tmp/src/ dst/
REMOTE-SHELL ARGV: fakehost rsync --server --sender -vlogDtpre.iLsfxCIvu . /tmp/src/

# add -z --partial and watch the flags follow
REMOTE-SHELL ARGV: fakehost rsync --server -vlogDtprze.iLsfxCIvu --partial . /tmp/dst2/

Three things become obvious at once:

  • rsync must exist on the remote, in the remote login shell's PATH. That is the entire dependency. No daemon, no port, no configuration.
  • Your flags are forwarded. -z became z in -vlogDtprze…; --partial was passed through verbatim. A flag the remote does not understand fails there, giving exit code 4.
  • The trailing e.iLsfxCIvu is a capability string — the client telling the server which protocol features it supports. Apple's openrsync sends separate flags instead (--server -g -l -o -p -D -r -t -v --dirs), which is one visible sign you are talking to a different implementation.
rsync over an SSH transport your machine rsync (client) parses your args · is the sender ssh ~/.ssh/config applies here rsync never opens a socket itself. It forks ssh and speaks over the pipe. encrypted SSH channel file list · checksums literal data · deltas remote host sshd → login shell expands globs · sets PATH rsync --server … generator + receiver live here Must exist on PATH. Version may differ; the protocol negotiates down.
The remote login shell sits between ssh and rsync --server. That is why remote wildcards expand remotely, why a noisy .bashrc can corrupt the protocol stream, and why --rsync-path exists.

Setting up key authentication

rsync over SSH with a password prompt is unusable in scripts and irritating interactively. Fix it once:

# create a key if you don't have one
ssh-keygen -t ed25519 -C "rsync $(hostname)"

# install it on the remote (Ubuntu/WSL ships ssh-copy-id; macOS does not)
ssh-copy-id user@host
# macOS equivalent:
ssh user@host 'mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys' < ~/.ssh/id_ed25519.pub

# verify: this must print OK with no prompt
ssh -o BatchMode=yes user@host 'echo OK; command -v rsync'

That second command is the real test. If it prints OK and a path to rsync, every rsync command in this section will work. If it prints Permission denied (publickey), fix SSH before blaming rsync.

Configuring the transport with -e

NeedCommand
Non-standard portrsync -av -e 'ssh -p 2222' src/ host:dst/
Specific keyrsync -av -e 'ssh -i ~/.ssh/deploy_ed25519' src/ host:dst/
Jump hostrsync -av -e 'ssh -J bastion.example.com' src/ host:dst/
Remote rsync not on PATHrsync -av --rsync-path=/usr/local/bin/rsync src/ host:dst/
Remote write needs rootrsync -av --rsync-path='sudo rsync' src/ host:/etc/app/
Better: put it in ~/.ssh/config instead Every -e 'ssh …' above is configuration that belongs in one place:
Host web
    HostName web.example.com
    User deploy
    Port 2222
    IdentityFile ~/.ssh/deploy_ed25519
    ControlMaster auto
    ControlPath ~/.ssh/cm-%r@%h:%p
    ControlPersist 60
Then rsync -av src/ web:/srv/site/ just works — and the ControlMaster lines make repeated runs reuse one connection instead of renegotiating SSH every time, which is a real speedup for many-small-file transfers.

Remote paths go through a shell — plan accordingly

Because the remote side runs your command through a login shell, wildcards on the remote are expanded there:

rsync -av 'host:/srv/logs/*.txt' ./
receiving incremental file list
a.txt
c.txt

Quote it locally (so your shell leaves it alone) and the remote shell expands it. That is a feature until it is not: an unmatched glob produces a confusing error, and spaces need care. Since 3.2.4, rsync escapes spaces and other specials for you by default (--old-args reverts to the raw behavior) — on an older client like Ubuntu 20.04's 3.1.3, you must double-quote remote paths yourself:

# rsync 3.4.4 escapes the space itself
REMOTE-SHELL ARGV: fakehost rsync --server -vlogDtpre.iLsfxCIvu . /tmp/dst/a\ b/

# with -s (--secluded-args) the paths leave the command line entirely
rsync -avs -e ./logrsh "my docs/" "fakehost:/tmp/dst/c d/"
REMOTE-SHELL ARGV: fakehost rsync --server -svlogDtpre.iLsfxCIvu   ← no paths at all

-s sends filenames over the protocol instead of the command line, so the remote shell cannot mangle them. Use it when paths contain shell metacharacters — and remember that it also disables remote wildcard expansion, because there is no longer a command line for the shell to expand.

The classic remote failure: a chatty shell If the remote user's .bashrc prints anything — a banner, a fortune, an "unread mail" notice — that text lands in rsync's protocol stream and corrupts it. Symptoms are protocol version mismatch -- is your shell clean? or connection unexpectedly closed. Test with ssh host 'true' and confirm it produces zero output. Fix by guarding the interactive parts of the remote rc file:
case $- in *i*) ;; *) return;; esac   # put this near the top of ~/.bashrc

Errors you will actually see

# remote rsync missing or wrong path
bash: /nonexistent/rsync: No such file or directory
rsync: connection unexpectedly closed (0 bytes received so far) [sender]
rsync error: remote command could not be run (code 126) at io.c(232) [sender=3.4.4]

Two neighbouring exit codes cover this failure: 127, remote command not found, and 126, remote command found but could not be run. Which one you get depends on how the remote shell reports the failure — do not memorize the split; treat either as "the far side has no runnable rsync where I pointed". Check ssh host 'command -v rsync' and reach for --rsync-path= if the binary lives somewhere unusual — Homebrew on the far side is a common cause.

Bandwidth and metered links

# cap at ~2 MB/s so you can keep using the connection
rsync -avh --bwlimit=2000 --info=progress2 big/ host:/srv/big/

--bwlimit is in KiB/s by default; suffixes work (--bwlimit=2m). Pair with --partial so an interrupted transfer does not start over — §13 shows exactly what that buys.

Lab 7 · Remote rsync against your own machine 10 minutes · uses SSH to localhost · fully disposable

Using localhost as the "remote" exercises the entire SSH code path with zero risk and no second machine. Everything you learn transfers verbatim to a real host.

  1. Enable SSH to yourself. On macOS: System Settings → General → Sharing → turn on Remote Login. In WSL/Ubuntu: sudo apt install openssh-server && sudo service ssh start.

  2. Authorize your own key and verify a silent, password-free login:

    ssh-keygen -t ed25519 -N '' -f ~/.ssh/id_ed25519   # skip if you have one
    cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys
    chmod 600 ~/.ssh/authorized_keys
    ssh -o BatchMode=yes localhost 'echo OK; command -v rsync'

    Expect: OK followed by a path such as /usr/local/bin/rsync. If you get Permission denied (publickey), stop and fix SSH — rsync cannot help. The teardown step at the end of this lab reverts this change; do not skip it.

  3. Confirm the remote shell is silent — this is the check that prevents the most confusing rsync failure there is:

    ssh localhost 'true' | wc -c

    Expect: 0. Any other number means your shell startup prints something and will corrupt rsync's stream.

  4. Push a tree:

    cd "$(mktemp -d)" && mkdir -p src && printf 'hello\n' > src/a.txt
    rsync -avih src/ localhost:"$PWD/dst/"

    Expect: >f+++++++++ a.txt and a summary. Note the quoting: "$PWD/dst/" is expanded by your shell before rsync sees it, which is what you want when both sides are the same machine.

  5. Pull it back into a new directory and confirm the direction indicator flips:

    rsync -avih localhost:"$PWD/dst/" back/

    Expect: receiving incremental file list rather than sending.

  6. Break it deliberately, so the error is familiar when it is not deliberate:

    rsync -av --rsync-path=/nonexistent/rsync src/ localhost:"$PWD/dst/"; echo "exit=$?"

    Expect: rsync: connection unexpectedly closed (0 bytes received so far) [sender] and an error naming code 127 (remote command not found) or 126 (remote command could not be run), depending on how the remote shell reports the missing binary.

  7. Watch the connection get reused. Add the ControlMaster block from above to ~/.ssh/config for Host localhost, then time two runs:

    time rsync -a src/ localhost:"$PWD/dst/"
    time rsync -a src/ localhost:"$PWD/dst/"

    Expect: the second run noticeably faster, because SSH reuses the multiplexed connection instead of doing a fresh handshake. The saving is small here and large on a real network.

  8. Teardown — not optional. Steps 1, 2, and 7 made three persistent changes to your real machine, and each one widens its attack surface until reverted:

    # 1. close the multiplexed connection and remove the ControlMaster block
    ssh -O exit localhost 2>/dev/null
    #    then delete the "Host localhost" block you added to ~/.ssh/config
    
    # 2. remove the key you authorized (skip if the line predates this lab)
    grep -v -f ~/.ssh/id_ed25519.pub ~/.ssh/authorized_keys > /tmp/ak && mv /tmp/ak ~/.ssh/authorized_keys
    
    # 3. turn the SSH listener back off
    #    macOS: System Settings → General → Sharing → Remote Login OFF
    #    WSL/Ubuntu: sudo service ssh stop

    Expect: ssh -o BatchMode=yes -o ConnectTimeout=2 localhost true now fails. If you use SSH to this machine for other things, keep what you need — but decide that deliberately rather than by leaving the lab's residue in place.

Checkpoint: explain why rsync -av src/ host:'~/dst/' and rsync -av src/ host:dst/ reach the same place, and what would happen with host:/dst/.

Sources: rsync(1) — USAGE, CONNECTING TO AN RSYNC SERVER, --rsh, --rsync-path, --secluded-args · ssh_config(5) — ControlMaster, ProxyJump

11 · The rsync daemon

The daemon is rsync's other transport: a long-running server on TCP 873 that publishes named modules instead of filesystem paths. You reach it with a double colon or an rsync:// URL. It exists for cases SSH handles badly — anonymous public mirrors, high-volume transfers where SSH encryption is the bottleneck, appliances with no shell — and it is worth knowing precisely because those cases are narrow.

Two syntaxes, one protocol

# both of these mean "module pub on that host"
rsync rsync://localhost:8873/pub/
rsync --port=8873 localhost::pub/

The double colon is the tell. host:path is SSH; host::module is the daemon. Confusing the two produces a bewildering error, so read your own commands for that second colon.

A working configuration

This is a real, verified rsyncd.conf — running on a high port so it needs no privileges:

port = 8873
pid file  = /path/to/lab/rsyncd.pid
log file  = /path/to/lab/rsyncd.log
lock file = /path/to/lab/rsyncd.lock
use chroot = no
max connections = 4

[pub]
    path = /path/to/lab/share
    comment = read-only public mirror
    read only = yes
    list = yes

[drop]
    path = /path/to/lab/upload
    comment = authenticated write target
    read only = no
    auth users = labuser
    secrets file = /path/to/lab/rsyncd.secrets

The secrets file is user:password, one pair per line. rsyncd.conf(5) requires that it "normally not be readable by other" — chmod 600 satisfies that, and a rejected file means no logins are possible for the module. There is no default path; you must name one. Start the daemon:

rsync --daemon --config=./rsyncd.conf --no-detach
The error you will hit first @ERROR: failed to open lock file then rsync error: error starting client-server protocol (code 5). Cause: max connections requires a lock file, and the default path is /var/run/rsyncd.lock, which an unprivileged daemon cannot write. Fix: set lock file explicitly, as above. The daemon starts and lists modules fine without it — the failure only appears when a client actually opens a module, which makes it feel unrelated.

Using it

# what modules exist?
rsync rsync://localhost:8873/
pub            	read-only public mirror
drop           	authenticated write target

# what is in one?
rsync rsync://localhost:8873/pub/
drwxr-xr-x            128 2026/08/06 12:44:54 .
-rw-r--r--              5 2026/08/06 12:44:54 data.csv
-rw-r--r--             12 2026/08/06 12:44:54 readme.txt

# pull it
rsync -avi rsync://localhost:8873/pub/ ./pull/
receiving incremental file list
.d..t...... ./
>f+++++++++ data.csv
>f+++++++++ readme.txt

A bare module list is something SSH transport cannot do — there is no "what may I access?" query over SSH. That discoverability is the daemon's genuine advantage.

Authentication and refusals

# writing to a read-only module
rsync -a share/ rsync://localhost:8873/pub/
ERROR: module is read only
rsync error: syntax or usage error (code 1) at main.c(1168) [Receiver=3.4.4]

# writing to an auth module with no credentials
rsync -a share/ rsync://localhost:8873/drop/
Password: @ERROR: auth failed on module drop
rsync error: error starting client-server protocol (code 5) at main.c(1874) [sender=3.4.4]

# with credentials
RSYNC_PASSWORD=labpass123 rsync -avi share/ rsync://labuser@localhost:8873/drop/
sending incremental file list
<f+++++++++ data.csv
<f+++++++++ readme.txt

Note the itemize direction flipped to < — bytes going out to the remote rather than arriving. Same decoder (§7), position 1.

Daemon-mode security is not optional reading
  • The protocol is unencrypted and unauthenticated by default. A module without auth users is readable by anyone who can reach the port. The secrets file mechanism sends a challenge-response, not a password in clear — but the file contents are still plaintext on disk, so treat those credentials as low-value and never reuse a real password.
  • use chroot = no is convenient and materially weaker. The 3.4.3 release fixed multiple daemon-mode vulnerabilities that apply specifically to non-chroot operation, including CVE-2026-29518 (symlink race enabling local privilege escalation) and CVE-2026-43619. The lab config above uses no only because it must run unprivileged. A real daemon runs chrooted, as a dedicated unprivileged user, with hosts allow set.
  • Patch level matters more here than anywhere else in rsync. 3.4.0 fixed six CVEs including a heap overflow reachable from a malicious server (CVE-2024-12084); 3.4.3 fixed six more. If you expose a daemon to a network you do not control, run a current build — Ubuntu 24.04 LTS's 3.2.7 carries backported fixes, which is fine, but an unpatched 3.2.x is not.
  • Prefer SSH. For anything involving your own machines, SSH transport gives you encryption, real authentication, and no new listening port. Reach for the daemon when you need anonymous read access or when you have measured SSH's encryption as your bottleneck — not by default.

The middle path: daemon over SSH

You can get module semantics with SSH's security by having rsync launch a single-use daemon over an SSH connection, so nothing listens on a port:

rsync -av --rsh=ssh host::module/ ./local/

Three details the manual is emphatic about, and each of them will bite you:

  • --rsh must be on the command line. Setting RSYNC_RSH in the environment does not enable this mode.
  • The config comes from the remote user's home directory, not /etc/rsyncd.conf. The daemon is spawned fresh by that user.
  • user@host::module does not mean what you think. Here user@ is the rsync module username, not the SSH username. To set the SSH user you pass it to ssh: --rsh='ssh -l deploy'.

Because the daemon is started by an ordinary user, chroot and uid-changing are unavailable — you trade those for encryption and SSH authentication. This is still how you give someone access to exactly one directory without giving them a usable shell.

Lab 8 · Stand up a daemon on a high port 11 minutes · local only · unprivileged · fully disposable
  1. Build the lab and its config:

    cd "$(mktemp -d)" && mkdir -p share upload
    printf 'public file\n' > share/readme.txt ; printf 'more\n' > share/data.csv
    cat > rsyncd.conf <<EOF
    port = 8873
    pid file  = $PWD/rsyncd.pid
    log file  = $PWD/rsyncd.log
    lock file = $PWD/rsyncd.lock
    use chroot = no
    max connections = 4
    
    [pub]
        path = $PWD/share
        comment = read-only public mirror
        read only = yes
        list = yes
    
    [drop]
        path = $PWD/upload
        comment = authenticated write target
        read only = no
        auth users = labuser
        secrets file = $PWD/rsyncd.secrets
    EOF
    printf 'labuser:labpass123\n' > rsyncd.secrets && chmod 600 rsyncd.secrets
  2. Start it in a second terminal (or background it) and leave it running:

    rsync --daemon --config="$PWD/rsyncd.conf" --no-detach

    Expect: no output. If it exits with failed to lock pid file … Resource temporarily unavailable, an earlier daemon is still alive — pkill -f 'rsync --daemon' and retry.

  3. Discover what it offers:

    rsync rsync://localhost:8873/

    Expect: two lines, pub and drop, with their comments.

  4. List and then pull a module:

    rsync rsync://localhost:8873/pub/
    rsync -avi rsync://localhost:8873/pub/ ./pull/

    Expect: a directory listing, then >f+++++++++ data.csv and >f+++++++++ readme.txt.

  5. Try to write where you may not:

    rsync -a share/ rsync://localhost:8873/pub/

    Expect: ERROR: module is read only and exit code 1.

  6. Authenticate and write:

    RSYNC_PASSWORD=labpass123 rsync -avi share/ rsync://labuser@localhost:8873/drop/
    ls upload/

    Expect: <f+++++++++ data.csv and <f+++++++++ readme.txt, then both files present in upload/.

  7. Read the log, then shut it down:

    tail rsyncd.log
    pkill -f 'rsync --daemon'

    Expect: connection and module-access lines. Confirm the daemon is gone with pgrep -fl 'rsync --daemon' printing nothing.

Checkpoint: state the difference between host:/srv/data/ and host::data/, and which one needs a shell account on the far side.

Sources: rsyncd.conf(5) · rsync NEWS — 3.4.0 and 3.4.3 security releases

12 · Metadata, symlinks, and crossing platforms

-a preserves seven things and omits five — and one of the omissions means that if you back up a Mac home directory without -X, you are silently losing Finder tags, download provenance, and every other scrap of metadata that lives in extended attributes. No error will tell you. This section is the complete model that §5 deliberately simplified: what each omission costs, on which filesystem, and which flag buys it back.

What -a leaves behind

FlagPreservesMatters when
-H / --hard-linkshard-link relationships between files in the transferBacking up trees that already contain hard links — including previous rsync snapshot trees. Costs memory proportional to file count.
-A / --aclsPOSIX / macOS access control listsShared servers, macOS files with Finder-set ACLs. Implies -p.
-X / --xattrsextended attributesmacOS: Finder tags, Spotlight comments, quarantine flags, resource forks. Linux: SELinux labels, user.* attributes.
-U / --atimesaccess timesAlmost never. Reading a file changes its atime, so this rarely stays true.
-N / --crtimescreation ("birth") timesmacOS archival where Finder's "Date Created" must survive. Requires OS and filesystem support.
-S / --sparseholes in sparse filesVM disk images, database files, anything with large zero runs.

Extended attributes on macOS: verified

This is not theoretical. A file with Finder metadata, synced with plain -a:

xattr -l src/f.txt
com.apple.metadata:kTestKey: hello
com.apple.provenance:
user.custom: v1

rsync -a src/ dst/ && xattr -l dst/f.txt
com.apple.provenance:          ← everything else silently dropped

rsync -aX src/ dst2/ && xattr -l dst2/f.txt
com.apple.metadata:kTestKey: hello
com.apple.provenance:
user.custom: v1                 ← preserved
If you back up a Mac's home directory without -X, you are losing data Finder tags, Spotlight comments, the "where from" URL on downloads, and legacy resource forks all live in extended attributes. The files still open; the metadata is gone, silently, with no error. For Mac-to-Mac backups the reflex is -aX, and add -N if creation dates matter to you.

ACLs on macOS: a real trap with a real workaround

A file carrying an ACL that denies deletion breaks rsync's normal write path, because rsync writes to a temporary file and then renames it over the target — and the ACL forbids exactly that. Reproduced on macOS 15.6 (the chmod +a syntax is macOS-only; on Linux the equivalent experiment uses setfacl, though POSIX ACLs cannot express a deny-delete rule, so this specific trap is a Mac one):

chmod +a "everyone deny delete" src/f.txt
rsync -aA src/ dst/
rsync: [receiver] rename "…/dst/.f.txt.x9NXJX" -> "f.txt": Permission denied (13)
rsync error: some files/attrs were not transferred (see previous errors) (code 23) at main.c(1356) [sender=3.4.4]

Plain -a on the same file succeeds — because without -A the ACL is never applied to the temp file, so the rename is permitted. And the fix that keeps the ACL:

rsync -aA --inplace src/ dst/ && ls -le dst/f.txt
-rw-r--r--@ 1 you  staff  12 Aug  6 12:46 f.txt
 0: group:everyone deny delete

--inplace writes directly to the destination file instead of writing-then-renaming, sidestepping the ACL entirely. Understand the trade before you adopt it: §13 explains what --inplace costs you in crash safety.

Symlinks: five behaviors, pick deliberately

FlagBehavior
-l (in -a)Copy symlinks as symlinks. The default and usually right.
-L / --copy-linksFollow every symlink and copy what it points at, as a regular file.
-k / --copy-dirlinksFollow symlinks that point at directories only.
--safe-linksSilently drop symlinks pointing outside the transfer tree.
--munge-linksMangle link targets so they cannot be followed on the receiver. For untrusted sources.
# -a: symlinks stay symlinks, including ones that escape the tree
cL+++++++++ inside.lnk -> real.txt
cL+++++++++ outside.lnk -> /etc/hosts
cL+++++++++ sub/escape.lnk -> ../../../etc/passwd

# --safe-links: the escaping links are dropped without comment
cL+++++++++ inside.lnk -> real.txt       ← only the safe one survives

# -L: links are replaced by their contents; a broken link is an error
symlink has no referent: "…/sub/escape.lnk"
>f+++++++++ inside.lnk
>f+++++++++ outside.lnk
rsync error: some files/attrs were not transferred (code 23)
Pulling from a host you do not fully trust A symlink named config pointing at /etc/shadow is a legal thing for a source tree to contain, and -L will happily dereference it. When receiving from anywhere you do not control, use --safe-links, and consider --munge-links. Do not use -L on untrusted input. CVE-2024-12087 and CVE-2024-12088 in the 3.4.0 release were exactly this class of problem — another reason to be current.

Hard links: -H costs something and buys something

# src contains real.txt and hard.txt sharing one inode
rsync -a  src/ out4/   # without -H
rsync -aH src/ out5/   # with -H
without -H: 1 links / with -H: 2 links
du -sh out4 out5
8.0K	out4
4.0K	out5

Without -H, hard-linked files become independent copies — the tree still works, it just doubles in size. rsync must hold the full inode map in memory to detect links, so -H on a multi-million-file tree is a memory conversation. For snapshot backups (§14) it becomes essential, because those trees are made of hard links.

Sparse files

# a 64 MB file that occupies zero blocks
src apparent=67108864 blocks=0
rsync -a  src/ dst/    → no -S:   blocks=131072   ← fully materialized
rsync -aS src/ dst2/   → with -S: blocks=0        ← holes preserved

Without -S, a sparse 64 MB file becomes a real 64 MB file. With VM images this turns a 20 GB backup into 500 GB. Combining -S with --inplace is accepted (since 3.1.3) but may silently produce non-sparse files, depending on kernel and filesystem — verify with ls -ls if the combination matters to you.

Permissions: the surprise about the destination directory itself

Misconception: "src/ only touches what's inside dst." The trailing slash sends the contents — and the source directory's own attributes, applied to the destination directory. Verified:
before: src=755 dst=700
rsync -a src/ dst/
after:  src=755 dst=755   ← dst's own mode was rewritten
If the destination directory's permissions are load-bearing, use --no-perms, or --omit-dir-times if only timestamps are the problem, or set the mode explicitly with --chmod=D755,F644.

--chmod is the tool for imposing permissions rather than copying them. D prefixes apply to directories, F to files:

rsync -a --chmod=D755,F644 src/ dst/   # source file was 777
stat -f '%N %Lp' dst/f.txt
dst/f.txt 644

Ownership: the promise -a cannot keep

Misconception: "-a preserves ownership." It includes -o and -g, but owner-setting works only when the receiving rsync runs with privilege (root, or CAP_CHOWN). Without it, rsync does not error — it silently writes every file as the invoking user, and the failure surfaces months later, at restore time, when nothing belongs to who it should. Verified — the request is simply ignored:
rsync -a --chown=root:wheel src/ dst/ ; echo "exit=$?"
exit=0
ls -l dst/f.txt
-rw-r--r--  1 you       staff  3 Aug  7 10:30 f.txt   ← not root:wheel; no warning either

Four flags govern what happens to ownership when the two sides disagree about users:

FlagDoesReach for it when
--chown=USER:GROUPforce one owner/group on everything received (a shorthand for --usermap/--groupmap with a * pattern)Deploys: push as you, land as www-data. Needs privilege on the receiving side, like -o itself.
--numeric-idsskip name-matching; transfer raw UID/GID numbersFull-system backup and restore. Name-matching quietly remaps files when the two machines assign the same username different IDs — numeric is what you want when the destination will one day become the source again.
--usermap=FROM:TO, --groupmaprewrite specific owners in flight (names or IDs, * wildcards)Migrating between machines whose accounts differ by more than one name.
--fake-superinstead of chowning, record owner, group, and device info in private xattrs on the receiving side, and read them back on the return tripBacking up as a non-root user to a destination that supports xattrs. The backup stays restorable with correct ownership even though no privilege was ever involved. For a remote receiver, enable it on that side only with -M--fake-super (-M passes any option through to the remote rsync alone).

Cross-platform reality

RouteUseBecause
macOS → macOS-aXN (add -A only if you need ACLs)Finder metadata lives in xattrs; creation dates matter to Finder.
macOS → Linux-a, plus --iconv=utf-8-mac,utf-8 if filenames use accentsLinux will not use Apple's xattrs. Filename normalization can differ.
Linux → Linux-aHAXEverything is supported; preserve it all.
Any → NTFS via WSL /mnt/c-rlt --no-perms --no-owner --no-group --modify-window=1NTFS has no Unix mode bits; timestamps can be coarse.
Any → exFAT / FAT external drive-rlt --modify-window=1No ownership, no permissions, 2-second timestamp granularity.
Any → NAS mounted over SMB-rlt --no-perms --no-owner --no-group --modify-window=1The mount cannot represent Unix ownership or xattrs faithfully; see below.
The NAS question, settled A NAS share mounted at /Volumes/backup is a local path to rsync, with a foreign filesystem behind it. Three consequences. First, metadata flags lie: -A and -X either fail outright or store approximations the SMB layer invents, and ownership belongs to the mount, not to you — hence the --no-perms --no-owner --no-group row above, with --modify-window=1 because SMB servers often round timestamps. Second, because the path is local, rsync defaults to --whole-file (§6); forcing --no-whole-file makes rsync read the old copy back over the network to compute deltas, which is usually slower than just sending the file — measure before you assume delta transfer helps here. Third, the clean escape exists: if the NAS runs an SSH server, sync to nas:/volume1/backup/ instead of /Volumes/backup/. Then rsync runs natively on the NAS, delta transfer works as designed, ownership is applied by the far side, and --fake-super can record what an unprivileged NAS account cannot set.
Filename normalization, honestly The classic macOS↔Linux complaint is that café.txt written on a Mac uses decomposed Unicode (e + combining accent) while Linux keeps whatever bytes it was given, producing apparent duplicates. rsync's --iconv=utf-8-mac,utf-8 translates between the two. Tested on APFS for this guide, both encodings collapsed to a single file — the volume treats them as the same name, so the duplicate could not even be created locally. The problem is real when syncing to a Linux filesystem that keeps them distinct; it did not reproduce within macOS. Reach for --iconv if you see doubled filenames after a macOS→Linux sync, not preemptively.

Two path tools worth knowing

-R / --relative preserves the full source path inside the destination, and a /./ pivot chooses where the preserved portion starts:

rsync -aR a/b/c/f.txt dst/
dst/a/b/c/f.txt                ← whole path recreated

rsync -aR a/./b/c/f.txt dst2/
dst2/b/c/f.txt                 ← path preserved from the ./ onward

--files-from=FILE takes an explicit list, one path per line, relative to the source root. It implies -R and -d, and -a stops implying -r — add -r back explicitly if a listed directory's contents should come along. Paths land exactly where the list says:

cat list.txt
x/one.txt
y/two.txt
rsync -a --files-from=list.txt src/ dst/ && find dst -type f
dst/x/one.txt
dst/y/two.txt

This is how you drive rsync from the output of find, git ls-files, or a manifest. Add --from0 when the list is NUL-separated, which is the safe way to handle filenames with newlines.

Creating the destination path: --mkpath

rsync creates the final destination directory but not its missing parents. Verified:

rsync -a src/ deep/nested/dst/
rsync: [Receiver] mkdir "…/deep/nested/dst" failed: No such file or directory (2)
rsync error: error in file IO (code 11) at main.c(800) [Receiver=3.4.4]

rsync -a --mkpath src/ deep/nested/dst/
find deep -maxdepth 3 -type d
deep
deep/nested
deep/nested/dst

Before --mkpath existed (added in 3.2.3) the idiom was mkdir -p first, which still works and is more portable if either side might be older. Note that --mkpath and --dry-run together had a bug for file-to-file copies that 3.4.4 fixed — one more reason the version you run matters.

Lab 9 · Find out what your -a is dropping 10 minutes · local only · macOS-focused, Linux notes inline
  1. Create a file with metadata attached:

    cd "$(mktemp -d)" && mkdir -p src dst dst2
    printf 'tagged\n' > src/f.txt
    xattr -w user.custom 'v1' src/f.txt        # Linux: setfattr -n user.custom -v v1
    xattr -l src/f.txt

    Expect: at least user.custom: v1. macOS may also show com.apple.provenance.

  2. Sync with plain -a and inspect:

    rsync -a src/ dst/ && xattr -l dst/f.txt

    Expect: user.custom is gone. No warning was printed.

  3. Now with -X:

    rsync -aX src/ dst2/ && xattr -l dst2/f.txt

    Expect: user.custom: v1 present.

  4. Watch the destination directory's own mode get rewritten:

    mkdir -p pm/src pm/dst && chmod 755 pm/src && chmod 700 pm/dst
    printf 'x\n' > pm/src/f.txt
    stat -f '%Lp' pm/dst        # Linux: stat -c '%a' pm/dst
    rsync -a pm/src/ pm/dst/
    stat -f '%Lp' pm/dst

    Expect: 700 then 755. Re-run with --no-perms on a fresh pair and confirm 700 survives.

  5. Test symlink safety:

    mkdir -p ln/src && printf 'target\n' > ln/src/real.txt
    ln -s real.txt ln/src/inside.lnk
    ln -s /etc/hosts ln/src/outside.lnk
    rsync -ai ln/src/ ln/out/
    rsync -ai --safe-links ln/src/ ln/out2/

    Expect: the first run lists both cL+++++++++ links; the second lists only inside.lnk, dropping the escaping one with no message.

  6. Make a sparse file and prove -S matters:

    mkdir -p sr/src && dd if=/dev/zero of=sr/src/img bs=1m count=0 seek=64 status=none   # Linux: bs=1M
    rsync -a  sr/src/ sr/a/ && stat -f '%b' sr/a/img        # Linux: stat -c '%b'
    rsync -aS sr/src/ sr/b/ && stat -f '%b' sr/b/img

    Expect: a large block count (around 131072) without -S, and 0 with it.

Checkpoint: write the flag set you would use to back up your own home directory to an external drive, and justify each letter beyond -a.

Sources: rsync(1) — --archive, --xattrs, --acls, --hard-links, --sparse, --safe-links, --relative, --files-from, --iconv

13 · Speed, interruption, and resuming

rsync is fast by default and there are exactly four levers worth pulling. Pulling them without measuring usually makes things slower, so each one below comes with the condition under which it helps.

Lever 1: compression — narrower than you think

# 5 MB of highly compressible data, over a remote transport
rsync -a --stats -z --zc=zstd src/ host:dst/
Total bytes sent: 366
speedup is 12,468.83

rsync -a --stats src/ host:dst2/
Total bytes sent: 5,001,339
speedup is 1.00

That is a spectacular ratio on synthetic data and a misleading one. Real guidance:

  • Use -z when the link is slower than the CPU. Home upload links, cellular, VPNs. On a LAN or a local disk, compression is a net loss.
  • Choose the algorithm. rsync 3.2+ supports zstd, lz4, zlibx, zlib. --zc=zstd is the best general choice; --zc=lz4 when CPU is the constraint. Check availability with rsync --version | grep -A1 'Compress list'. If the far side is older, the negotiation falls back.
  • Skip already-compressed files. --skip-compress=gz/jpg/mp4/zst/xz/png/webm avoids burning CPU on data that will not shrink. A sensible default list is compiled in; override when your data is unusual.

Lever 2: --partial — the difference is stark

Interrupt a large transfer with Ctrl+C and look at the destination. Without --partial:

rsync error: received SIGINT, SIGTERM, or SIGHUP (code 20) at rsync.c(716) [sender=3.4.4]
ls -la dst/
total 0                       ← the partial file was discarded

With --partial:

ls -la dst/
-rw-r--r--  1 you  staff  6094848 Dec 31  1969 huge.bin

Six megabytes survived, and note the timestamp: 1969. rsync deliberately leaves the partial file with an ancient mtime so the quick check cannot mistake it for complete. On the next run it becomes the delta basis and only the remainder transfers.

Use --partial-dir, not bare --partial Bare --partial leaves the incomplete file at its final name. If your interrupted transfer is a live website, you have just published a truncated file. --partial-dir=.rsync-partial keeps fragments in a hidden subdirectory instead:
rsync -avh --partial-dir=.rsync-partial --info=progress2 big/ host:/srv/big/
For a relative --partial-dir value like this one, rsync appends a "perishable" exclude for the fragment directory itself, so --delete will not normally clean it up between attempts. Add your own --exclude='.rsync-partial' only if an earlier filter rule of yours would override that automatic one.

Lever 3: --inplace — a real trade, not a free win

What it buys

  • No temporary file, so no need for free space equal to the file size.
  • Preserves hard links to the destination file and its ACLs (§12).
  • Faster for huge files on nearly-full volumes.

What it costs

  • No atomicity. An interruption leaves a file that is neither old nor new.
  • Readers see a partially updated file mid-transfer.
  • With --sparse, may silently produce non-sparse files (accepted since 3.1.3, but hole-punching depends on kernel and filesystem). Breaks --link-dest snapshot sharing by modifying the shared inode.

Use it for a VM image on a full disk. Do not use it for a document tree, a website, or anything feeding a snapshot backup.

Lever 4: fewer round trips

  • SSH connection reuse. ControlMaster in ~/.ssh/config (§10) removes a full handshake per invocation. For a script that runs rsync in a loop, this is often the largest single win.
  • One rsync, not many. Ten thousand small files in one invocation beats a shell loop calling rsync per file by orders of magnitude — the file list is streamed and the connection is shared.
  • --bwlimit is a throttle, not a tuner. It exists to leave bandwidth for other work, not to go faster (units in §10).
  • --stop-after=MINS / --stop-at=y-m-dTh:m. Bound a transfer to a maintenance window; combine with --partial-dir so the next window resumes. Confirm support with rsync --version | grep stop-at.

Reading --info=progress2 output — including why to-chk grows early in a big transfer — is covered in §7; nothing about it changes at scale.

When rsync is slow and no flag will fix it rsync's cost is dominated by per-file work: stat, compare, open, close. A million tiny files is slow no matter what you pass, because the bottleneck is metadata operations rather than bytes. If that is your situation, the answer is not an rsync flag — it is transferring an archive (tar piped over ssh) for the initial seed, then using rsync for incremental updates where its per-file cost is amortized across far fewer changes.

Sources: rsync(1) — --compress, --compress-choice, --partial-dir, --inplace, --bwlimit, --stop-after

14 · Snapshot backups with --link-dest

This is rsync's best trick and the reason many people never buy backup software. With one extra flag you get a directory per backup — each one a complete, browsable, restorable copy of the whole tree — while unchanged files cost zero additional bytes. Time Machine's core idea, in a command you can read.

The mechanism: hard links

A hard link is a second name for the same inode. Two names, one set of blocks on disk, one reference count. Delete one name and the data survives while the other name exists. rsync's --link-dest=DIR says: when a file is unchanged from the copy in DIR, do not transfer it — hard-link to it instead.

How --link-dest snapshots share storage 2026-08-04 notes.md photo.jpg report.txt full copy · 1.0 MB used 2026-08-05 notes.md photo.jpg report.txt full listing · 0 MB new 2026-08-06 notes.md photo.jpg report.txt full listing · 4 KB new on disk 3 snapshot trees, but only the changed blocks are stored twice. du -sh backups → 1.0M inode #445333596 · notes.md · link count 3 · stored once inode #445333602 · report.txt (edited today) · link count 1
Each dated directory is a complete tree you can cd into, diff, and restore from with an ordinary cp. Unchanged files across snapshots are the same inode, so a hundred daily snapshots of a mostly-static tree cost barely more than one.

Proof from a real run

# snapshot 1: full copy of a tree containing a 1 MB blob
rsync -a --delete src/ backups/2026-08-06_1200/

# edit one small file, then snapshot 2 linked against snapshot 1
rsync -a --delete --link-dest=../2026-08-06_1200 src/ backups/2026-08-06_1300/

du -sh backups/*
1.0M	backups/2026-08-06_1200
4.0K	backups/2026-08-06_1300
du -sh backups
1.0M	backups                ← two full snapshots, one snapshot of space

stat -f '%N links=%l inode=%i' backups/*/stable.txt backups/*/report.txt
backups/2026-08-06_1200/stable.txt links=2 inode=445333596
backups/2026-08-06_1300/stable.txt links=2 inode=445333596   ← same inode
backups/2026-08-06_1200/report.txt links=1 inode=445333595
backups/2026-08-06_1300/report.txt links=1 inode=445333602   ← different: it changed
Read --link-dest paths carefully The path is interpreted relative to the destination directory, not your working directory. That is why the example uses ../2026-08-06_1200 while the destination is backups/2026-08-06_1300/. Getting this wrong fails silently — rsync finds no basis, links nothing, and quietly writes a full copy. If a snapshot is unexpectedly large, this is the first thing to check. Absolute paths sidestep the ambiguity entirely and are worth the verbosity in a script.

--link-dest has two siblings and a cousin

FlagWhen a file is unchanged vs the basis dirReach for it when
--link-dest=DIRhard-link to the copy in DIRSnapshot rotation — full trees, one snapshot of space. This section.
--copy-dest=DIRcopy it locally from DIR instead of transferringYou want the network savings but independent files — a destination filesystem where hard links are unwanted or unsupported.
--compare-dest=DIRskip it entirely; write nothingBuilding a delta directory: only what changed relative to a baseline lands in the destination.
-y / --fuzzy(no basis dir) — look for a similar-named file already in the destination to use as a delta basisRenamed files over a slow link, where a near-identical neighbour saves most of the transfer.

A complete, working snapshot script

This was run end to end against rsync 3.4.4 on macOS while writing this guide — including the mount guard, the link basis, and retention pruning. It uses no GNU-only tools, so it runs unchanged on Ubuntu/WSL.

#!/usr/bin/env bash
# snapshot-backup.sh — dated hardlinked snapshots with retention.
set -euo pipefail

SRC="${1:?usage: $0 SRC DEST_ROOT}"
DEST_ROOT="${2:?usage: $0 SRC DEST_ROOT}"
KEEP="${KEEP:-30}"
EXCLUDES="${EXCLUDES:-$HOME/.config/rsync/backup-excludes}"
STAMP="$(date +%Y-%m-%dT%H%M%S)"
TARGET="$DEST_ROOT/$STAMP"

# Refuse to run against an unmounted volume — see §9.
[ -e "$DEST_ROOT/.backup-marker" ] || { echo "destination not mounted: $DEST_ROOT" >&2; exit 1; }

# Newest existing snapshot becomes the link basis, if there is one.
# Relative to "$TARGET.incomplete/", so "../NAME" resolves inside DEST_ROOT.
link=()
latest="$(ls -1 "$DEST_ROOT" 2>/dev/null | grep -E '^[0-9]{4}-' | sort | tail -1 || true)"
[ -n "$latest" ] && link=(--link-dest="../$latest")

opts=(-aHAX --delete --delete-excluded --max-delete=500 --partial-dir=.rsync-partial)
[ -r "$EXCLUDES" ] && opts+=(--exclude-from="$EXCLUDES")

rsync "${opts[@]}" "${link[@]}" "$SRC/" "$TARGET.incomplete/"

# A dated directory exists only once the snapshot is complete.
mv "$TARGET.incomplete" "$TARGET"

# Retention: keep the newest $KEEP dated directories, delete the rest.
ls -1 "$DEST_ROOT" | grep -E '^[0-9]{4}-' | sort -r | tail -n +"$((KEEP + 1))" |
while IFS= read -r old; do
    echo "pruning $old"
    rm -rf "${DEST_ROOT:?}/${old:?}"
done

Verified behavior, run five times with KEEP=2:

pruning 2026-08-06T130655
pruning 2026-08-06T130656
pruning 2026-08-06T130703
ls dest
2026-08-06T130704
2026-08-06T130706
Four decisions in that script worth understanding before you trust it
  • --link-dest="../$latest" is relative to "$TARGET.incomplete/", which sits directly inside DEST_ROOT — so ../ lands on the sibling snapshots. Change the layout and you must recheck this path, because getting it wrong fails silently into full copies.
  • mv "$TARGET.incomplete" "$TARGET" makes completion atomic. A crashed run leaves an .incomplete directory, which the ^[0-9]{4}- filter ignores for both linking and retention.
  • --delete-excluded is correct here: a backup should not accumulate files you have since decided to stop backing up.
  • sort -r | tail -n +$((KEEP+1)) rather than head -n -$KEEP, because negative head counts are a GNU extension and macOS's head rejects them. This is the kind of detail that makes a script work on one of your two machines.
On macOS add -N to opts if creation dates matter. Do not add it on Linux, where the filesystem may not support setting birth times.

A starting backup-excludes file

The script reads ~/.config/rsync/backup-excludes. Here is a defensible starting set for a home directory on either platform — every entry is regenerated data that inflates snapshots without making them more restorable. Lines starting with # are comments; unanchored patterns match at any depth (§8):

# caches — rebuilt on demand, most of a home directory's churn
.cache/
Library/Caches/
node_modules/
.npm/
__pycache__/
.venv/

# trash and per-volume bookkeeping — recreated automatically
.Trash/
.DS_Store
.Spotlight-V100/
.fseventsd/
.TemporaryItems/

Two judgment calls to make deliberately rather than by omission: VM and container disk images (huge, change entirely every run — consider excluding them here and backing them up on their own schedule), and ~/Library beyond Caches on macOS (keep it — application state lives there, and losing it is what makes a restore feel incomplete).

Restoring

# one file, from a specific day
cp backups/2026-08-04T030000/notes.md ~/notes.md

# the whole tree, back to how it was — note --delete makes this exact
rsync -aHAXN --delete backups/2026-08-04T030000/ ~/restored/

# what changed between two snapshots?
rsync -ain --delete backups/2026-08-05T030000/ backups/2026-08-06T030000/

That last command is the quiet superpower: a dry run between two snapshots prints an itemized diff of your entire backup history, with no special tooling.

Limits you should know before trusting it

  • Hard links need one filesystem. Snapshots must live on the same volume as each other. You cannot link across a mount point.
  • Editing a file in a snapshot corrupts every snapshot. They are the same inode. Treat snapshot trees as read-only; do not use --inplace anywhere near them.
  • Deleting a snapshot is safe and only frees blocks whose last reference went away. rm -rf on an old snapshot is the correct retention mechanism.
  • Link counts have limits and rm -rf of a large snapshot is metadata-heavy. Hundreds of snapshots of millions of files will feel it.
  • This is not off-site. A hardlinked snapshot tree on the same disk survives your mistakes, not the disk's death or a ransomware event that can write to the volume. Pair it with a copy somewhere you cannot casually delete from.
  • openrsync supports --link-dest — verified to produce real shared inodes — so this technique still works if you are stuck with Apple's build. You lose -X, so Finder metadata will not be in the backup.
Lab 10 · Build a snapshot backup and prove the sharing 12 minutes · local only · fully disposable
  1. Create a source with one big file and one small one:

    cd "$(mktemp -d)" && mkdir -p src backups
    printf 'v1 of report\n' > src/report.txt
    printf 'stable\n' > src/stable.txt
    head -c 1048576 /dev/zero | tr '\0' 'B' > src/blob.bin
  2. Take the first snapshot (no basis exists yet):

    rsync -a --delete src/ backups/2026-08-06_1200/
    du -sh backups/2026-08-06_1200

    Expect: about 1.0M.

  3. Edit one small file and take a linked snapshot:

    printf 'v2 of report, edited\n' > src/report.txt
    rsync -a --delete --link-dest=../2026-08-06_1200 src/ backups/2026-08-06_1300/
    du -sh backups/* ; du -sh backups

    Expect: 1.0M for the first, 4.0K for the second, and 1.0M for the whole backups tree. Two complete snapshots, one snapshot of disk.

  4. Prove the sharing is real, not an illusion of du:

    stat -f '%N links=%l inode=%i' backups/*/stable.txt backups/*/report.txt
    # Linux: stat -c '%n links=%h inode=%i' backups/*/stable.txt backups/*/report.txt

    Expect: both stable.txt entries showing links=2 and the same inode number; the two report.txt entries showing links=1 and different inodes.

  5. Confirm each snapshot is independently complete:

    cat backups/2026-08-06_1200/report.txt
    cat backups/2026-08-06_1300/report.txt

    Expect: v1 of report and v2 of report, edited. Neither is a stub or a pointer; both are ordinary readable files.

  6. Diff your backup history with a dry run:

    rsync -ain --delete backups/2026-08-06_1200/ backups/2026-08-06_1300/

    Expect: an itemized line for report.txt only — the one file that differs between the two snapshots.

  7. Delete the old snapshot and confirm the newer one is unharmed:

    rm -rf backups/2026-08-06_1200
    cat backups/2026-08-06_1300/stable.txt ; du -sh backups

    Expect: stable still readable, and backups still about 1.0M — the blocks moved from "shared" to "owned by the survivor", they did not disappear.

Checkpoint: explain why step 7 did not lose data, using the words "inode" and "reference count".

Sources: rsync(1) — --link-dest, --compare-dest, --copy-dest

15 · Scheduling: making it run without you

An interactive rsync command and a scheduled one are different programs with the same name. The scheduled one has no terminal, a minimal environment, no SSH agent, and nobody watching its output. Every difference below is a way that unattended rsync fails while the same command works when you type it.

Exit codes — the complete list

From rsync 3.4.4's manual. Your script needs this table because "non-zero means broken" is too coarse:

CodeMeaningIn a backup job
0SuccessDone.
1Syntax or usage errorYour script is wrong. Alert.
2Protocol incompatibilityVersion mismatch too wide to bridge. Alert.
3Errors selecting input/output files, dirsUsually a bad path. Alert.
4Requested action not supportedA flag the remote does not know. Alert.
5Error starting client-server protocolDaemon auth or config. Alert.
6Daemon unable to append to log-fileDaemon-side permissions.
10Error in socket I/ONetwork. Retry is reasonable.
11Error in file I/ODisk full, unwritable destination. Alert.
12Error in rsync protocol data streamOften a chatty remote shell (§10).
13Errors with program diagnosticsRare.
14Error in IPC codeRare.
20Received SIGUSR1 or SIGINTInterrupted. Retry.
21Some error returned by waitpid()Rare.
22Error allocating core memory buffersOut of memory — often -H on a huge tree.
23Partial transfer due to errorSomething was unreadable. Usually tolerable — see below.
24Partial transfer due to vanished source filesNormal on a live system. Treat as success.
25--max-delete limit stopped deletionsYour guard fired. Investigate immediately.
30Timeout in data send/receiveNetwork. Retry.
35Timeout waiting for daemon connectionDaemon unreachable. Retry.
Codes 23 and 24 are the ones that matter to a backup script 24 means files disappeared while rsync was running — a browser cache, a temp file, a log rotation. On any live machine this is routine and treating it as failure means your backup "fails" nightly until you stop reading the alerts. 23 means something could not be read or written; it is worth logging and worth alerting on if it persists, but a single occurrence caused by one locked file is not a broken backup. Tolerate 24 unconditionally, tolerate 23 with logging, alert on everything else.
# The wrapper every scheduled rsync deserves
set +e
rsync "${opts[@]}" "$SRC/" "$DEST/"
rc=$?
set -e
case $rc in
  0)     echo "backup ok" ;;
  24)    echo "backup ok (some source files vanished mid-run)" ;;
  23)    echo "backup completed with unreadable files — check the log" ;;
  25)    echo "REFUSED: --max-delete tripped. Source may be missing." >&2; exit 25 ;;
  *)     echo "backup FAILED with code $rc" >&2; exit $rc ;;
esac

The four things that break unattended runs

1. PATH is not your PATH

cron and launchd give you a minimal PATH that does not include /usr/local/bin or /opt/homebrew/bin. On macOS this means your job silently runs openrsync instead of rsync 3.4.4, and flags start failing. Use absolute paths in scheduled jobs.

2. No SSH agent

Your interactive session has SSH_AUTH_SOCK; the scheduler does not. Use a passphrase-free key dedicated to the job, restricted on the far side.

3. No terminal

Drop --progress and -P; they write control sequences into your log. Use --stats and redirect to a file instead.

4. Overlapping runs

A backup that takes 70 minutes on an hourly schedule will eventually run against itself. Take a lock: flock on Linux, or a mkdir-based lock that works everywhere.

Restricting the key on the remote side

A key that can run any command is a key that can be stolen and used for anything. Restrict it to rsync in ~/.ssh/authorized_keys on the destination host:

command="rsync --server -logDtpre.iLsfxCIvu . /srv/backups/laptop/",restrict ssh-ed25519 AAAA… backup@laptop

The command= forces that exact invocation regardless of what the client asks for, and restrict disables port forwarding, agent forwarding, and PTY allocation. Get the exact --server string by running your real command once with -e 'ssh -v' and reading what rsync sends — the technique from §10. Be aware this pins your flag set: change the rsync options and you must update the forced command.

macOS: launchd

Save as ~/Library/LaunchAgents/com.you.rsync-backup.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>              <string>com.you.rsync-backup</string>
  <key>ProgramArguments</key>
  <array>
    <string>/Users/you/bin/snapshot-backup.sh</string>
    <string>/Users/you/Documents</string>
    <string>/Volumes/Backup/documents</string>
  </array>
  <key>StartCalendarInterval</key>
  <dict>
    <key>Hour</key>   <integer>3</integer>
    <key>Minute</key> <integer>15</integer>
  </dict>
  <key>RunAtLoad</key>          <false/>
  <key>StandardOutPath</key>    <string>/Users/you/Library/Logs/rsync-backup.log</string>
  <key>StandardErrorPath</key>  <string>/Users/you/Library/Logs/rsync-backup.log</string>
  <key>EnvironmentVariables</key>
  <dict>
    <key>PATH</key> <string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
  </dict>
</dict>
</plist>
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.you.rsync-backup.plist
launchctl kickstart -p gui/$(id -u)/com.you.rsync-backup   # run it now, once
launchctl print gui/$(id -u)/com.you.rsync-backup | grep -E 'state|last exit'
launchctl bootout gui/$(id -u)/com.you.rsync-backup       # unload
macOS will block your backup silently Under TCC (Privacy & Security), a background job cannot read ~/Documents, ~/Desktop, ~/Downloads, Photos, or Mail without Full Disk Access. What you see is rsync exiting 23 with Operation not permitted (1) on those paths — not a permission prompt, because there is no GUI session to prompt. Grant Full Disk Access to /bin/bash or /bin/zsh (whatever your script's interpreter is) in System Settings → Privacy & Security → Full Disk Access. Verify by running the job with launchctl kickstart and reading the log — not by running the script in Terminal, which has its own permissions and will succeed misleadingly.

Ubuntu / WSL: systemd timer

~/.config/systemd/user/rsync-backup.service:

[Unit]
Description=Hardlinked rsync snapshot backup
After=network-online.target

[Service]
Type=oneshot
ExecStart=/home/you/bin/snapshot-backup.sh /home/you/work /srv/backups/work
Nice=10
IOSchedulingClass=idle
# rsync exit 24 (vanished files) is not a failure
SuccessExitStatus=24

~/.config/systemd/user/rsync-backup.timer:

[Unit]
Description=Run rsync snapshot backup daily

[Timer]
OnCalendar=*-*-* 03:15:00
Persistent=true
RandomizedDelaySec=600

[Install]
WantedBy=timers.target
systemctl --user daemon-reload
systemctl --user enable --now rsync-backup.timer
systemctl --user list-timers rsync-backup.timer
journalctl --user -u rsync-backup.service -n 50

Persistent=true makes the job run after boot if the scheduled time was missed — essential for a laptop. RandomizedDelaySec spreads load if several machines back up to the same target. Add sudo loginctl enable-linger $USER so user timers run when you are not logged in.

WSL does not run systemd by default on older installs Check with systemctl --version. If it errors, enable systemd by adding [boot] / systemd=true to /etc/wsl.conf and running wsl --shutdown from Windows. Failing that, fall back to cron — and remember that WSL only runs when a WSL process is alive, so a "daily 3 a.m." job in WSL fires only if the distro happens to be running. For genuinely unattended backups from a Windows machine, Task Scheduler invoking wsl.exe -d Ubuntu -- /home/you/bin/snapshot-backup.sh … is the reliable arrangement.

Plain cron, if you prefer it

crontab -e
# m h  dom mon dow  command
PATH=/usr/local/bin:/usr/bin:/bin
15 3 * * *  /usr/bin/flock -n /tmp/rsync-backup.lock /home/you/bin/snapshot-backup.sh /home/you/work /srv/backups/work >> /home/you/logs/backup.log 2>&1

flock -n makes an overlapping run exit immediately rather than piling up. macOS has no flock command; use the portable lock instead:

# Portable mutual exclusion: mkdir is atomic everywhere
LOCK=/tmp/rsync-backup.lock
mkdir "$LOCK" 2>/dev/null || { echo "already running"; exit 0; }
trap 'rmdir "$LOCK"' EXIT

The lock's companion is --timeout=300 on the rsync command itself. Over a flaky link an unattended rsync can hang indefinitely on a dead connection — it will never produce the exit 30 in the table above unless you set a timeout, and a hung job holds the lock, so every later run exits "already running" until someone notices. Five minutes of I/O silence is a reasonable default for a nightly job; add --contimeout=60 for daemon connections, which covers the connection attempt itself.

Logging that is worth reading later

rsync -aHAX --delete --stats \
      --log-file=/var/log/rsync-backup.log \
      --log-file-format='%o %f %l %b' src/ dst/

%o is the operation (send/recv/del), %f the filename, %l the file length, %b the bytes actually transferred. Comparing %l against %b across a log is how you confirm delta transfer is doing its job in production.

Sources: rsync(1) — EXIT VALUES, --log-file-format · systemd.timer(5) · Apple — Creating launchd jobs

16 · Decision matrices

The wrong question is "which tool is best?" Every tool below is best at something. The right question is what property you actually need: exactness, continuity, versioning, or reach.

rsync versus everything else

ToolModelChoose it whenNot when
rsyncOne-shot reconciliation you invokeYou want a destination to match a source at a moment you choose, over SSH or locally, with exact control over what transfers.You want continuous, bidirectional sync without thinking about direction.
cpCopySmall, local, one-time. Genuinely faster for a handful of files.Anything repeated, remote, or large.
scpCopy over SSHNever, really. Modern scp is an sftp wrapper; rsync is better at the same job.Anything you might have to resume or repeat.
tar | sshStream an archiveInitial seed of a million small files. Beats rsync when nothing exists at the destination yet.Incremental updates — no delta, no skip.
SyncthingContinuous peer-to-peer syncMultiple devices you edit on, changes propagating both ways without you running anything.You need a point-in-time snapshot or precise control over when transfers happen.
rclonersync-shaped, for object storageThe far side is S3, B2, Google Drive, or similar. rsync cannot speak those protocols.Both sides are POSIX filesystems — rsync's delta algorithm has no rclone equivalent.
restic / borgDeduplicating, encrypted backup repositoryYou need encryption at rest, deduplication across machines, or an untrusted backup destination.You want a plain browsable directory tree you can restore with cp.
Time MachinemacOS system backupWhole-Mac restores including system state. It knows about macOS in ways rsync does not.Cross-platform, or scriptable, or restoring onto Linux.
gitContent-addressed version controlText you author and want history for.Large binaries, or a tree you want to mirror rather than version.
These compose better than they compete A defensible setup for the two machines you own: Syncthing for continuous propagation of live work between them, rsync --link-dest snapshots to an external drive for point-in-time recovery, and restic to an off-site target for the case where the house burns down. Three tools, three different failure modes covered. rsync alone covers exactly one of them.

Three smaller decisions — comparison mode, deletion timing, compression — were each settled where they were taught: §6, §9, and §13. The cheat sheet (§19) compresses all three to one line each.

Sources: Syncthing documentation · rclone documentation · restic documentation

17 · Capstone: a real backup and deploy system

Everything in one exercise. Four phases, each depending on the last, all disposable. Budget 35–45 minutes. If a phase's checkpoint fails, the section it exercises is named — go back to it rather than pushing forward.

Capstone · Phase 1 — the workspace 6 minutes · exercises §4, §5, §7
  1. Build a project that looks like something you would actually own:

    export LAB="$(mktemp -d)"; cd "$LAB"
    mkdir -p proj/src proj/assets proj/node_modules/dep proj/.git proj/dist
    printf 'int main(){}\n' > proj/src/main.c
    printf 'body{}\n'      > proj/assets/site.css
    head -c 2097152 /dev/zero | tr '\0' 'P' > proj/assets/hero.png
    printf 'junk\n' > proj/node_modules/dep/index.js
    printf 'ref\n'  > proj/.git/HEAD
    printf 'built\n' > proj/dist/app.js
    printf 'DEBUG\n' > proj/app.log
    mkdir -p backups deploy && touch backups/.backup-marker
  2. Write the exclude file and dry-run against it:

    cat > excludes.txt <<'EOF'
    node_modules/
    .git/
    *.log
    EOF
    rsync -ain --exclude-from=excludes.txt proj/ backups/preview/

    Expect: src/main.c, assets/site.css, assets/hero.png, dist/app.js. No node_modules, no .git, no app.log.

Checkpoint 1: the dry run lists exactly four files and three directories. If app.log appears, your exclude file has a quoting or newline problem (§8).

Capstone · Phase 2 — snapshot backups with retention 12 minutes · exercises §9, §12, §14
  1. Save the script from §14 as ./snap.sh, make it executable, and take the first snapshot:

    chmod +x snap.sh
    EXCLUDES="$LAB/excludes.txt" KEEP=3 ./snap.sh "$LAB/proj" "$LAB/backups"
    ls backups/

    Expect: one dated directory such as 2026-08-06T131500, plus .backup-marker — and no preview directory, because Phase 1's dry run created nothing.

  2. Change one small file and take a second snapshot:

    sleep 1; printf 'body{color:red}\n' > proj/assets/site.css
    EXCLUDES="$LAB/excludes.txt" KEEP=3 ./snap.sh "$LAB/proj" "$LAB/backups"
    du -sh backups/2*/ ; du -sh backups

    Expect: the first snapshot around 2 MB, the second only kilobytes, and the total close to the first alone. The 2 MB hero.png is shared, not copied.

  3. Prove sharing at the inode level:

    stat -f '%N links=%l inode=%i' backups/2*/assets/hero.png
    # Linux: stat -c '%n links=%h inode=%i' backups/2*/assets/hero.png

    Expect: two lines, same inode, links=2.

  4. Exercise the mount guard — the failure mode that eats real backups:

    mkdir -p notmounted
    ./snap.sh "$LAB/proj" "$LAB/notmounted"; echo "exit=$?"

    Expect: destination not mounted: …/notmounted and exit=1. Nothing was written.

  5. Drive retention past its limit:

    for i in 1 2 3; do sleep 1; printf "rev$i\n" > proj/src/main.c
      EXCLUDES="$LAB/excludes.txt" KEEP=3 ./snap.sh "$LAB/proj" "$LAB/backups"; done
    ls -1 backups/ | grep -c '^2'

    Expect: pruning … lines during the runs, and exactly 3 dated directories left.

  6. Diff two points in your own backup history:

    set -- backups/2*/ ; rsync -ain --delete "$1" "$3"

    Expect: an itemized line for src/main.c — the only file that changed between the oldest and newest surviving snapshot.

Checkpoint 2: three snapshots exist, disk usage is roughly one snapshot's worth, and you can name the file that differs between any two of them without opening either.

Capstone · Phase 3 — a deploy that protects runtime state 14 minutes · exercises §8, §9, §10, §13
  1. Seed a "server" with content the deploy must never touch:

    mkdir -p deploy/uploads
    printf 'user photo\n' > deploy/uploads/photo.jpg
    printf 'SECRET=1\n'   > deploy/.env
    printf 'stale\n'      > deploy/old-page.html
  2. Dry-run a naive deploy and read the damage:

    rsync -ain --delete proj/dist/ deploy/

    Expect: *deleting lines for uploads/photo.jpg, .env, and old-page.html. The naive command destroys production state.

  3. Add protect rules and dry-run again:

    rsync -ain --delete-after \
          --filter='P /uploads/' --filter='P /.env' proj/dist/ deploy/

    Expect: *deleting old-page.html only. uploads/ and .env are no longer listed for deletion.

  4. Deploy for real, then verify:

    rsync -avi --delete-after \
          --filter='P /uploads/' --filter='P /.env' proj/dist/ deploy/
    find deploy -type f | sort

    Expect: deploy/.env, deploy/app.js, deploy/uploads/photo.jpg. No old-page.html.

  5. Now do it over SSH, using localhost as the remote (setup in §10, Lab 7):

    rsync -avih --delete-after \
          --filter='P /uploads/' --filter='P /.env' proj/dist/ localhost:"$LAB/deploy/"

    Expect: a run with nothing to transfer — the destination already matches. That "nothing happened" is the reconciler model confirming itself, and it is what makes deploys idempotent.

  6. Interrupt a big transfer and resume it — §13's machinery, live. Build a deliberately slow push, then press Ctrl+C while the file is moving:

    mkdir -p bigpush
    dd if=/dev/zero of=bigpush/blob.bin bs=1m count=100 status=none   # Linux: bs=1M
    rsync -avh --partial-dir=.rsync-partial --bwlimit=5000 bigpush/ localhost:"$LAB/bigdst/"
    # … press Ctrl-C mid-file …
    echo "exit=$?"
    ls -l "$LAB/bigdst/.rsync-partial"

    Expect: exit=20§15's SIGINT code — and a fragment named blob.bin in the partial directory, carrying the ancient mtime from §13. Re-run the identical rsync command: it uses the fragment as its delta basis and completes, and the fragment disappears.

Checkpoint 3: explain why --delete-after rather than --delete for a deploy, and what P does that --exclude does not.

Capstone · Phase 4 — verification and failure 10 minutes · exercises §6, §7, §15, §18
  1. Verify the newest snapshot really matches the source, byte for byte:

    NEWEST=backups/$(ls -1 backups | grep '^2' | sort | tail -1)
    rsync -nic --exclude-from=excludes.txt proj/ "$NEWEST/"

    Expect: no itemized lines at all. Any output names a file whose contents differ — -c read every byte on both sides to prove it.

  2. Corrupt the backup and prove the check catches what a normal run would not:

    # same byte count as the original — 16 bytes — so only the content differs
    printf 'body{color:blu}\n' > "$NEWEST/assets/site.css"
    touch -r proj/assets/site.css "$NEWEST/assets/site.css"
    rsync -ni  --exclude-from=excludes.txt proj/ "$NEWEST/"   # quick check
    rsync -nic --exclude-from=excludes.txt proj/ "$NEWEST/"   # checksum

    Expect: the first prints nothing (size and mtime match); the second prints >fc........ assets/site.css. This is §6's lesson in your own backup. Notice what the corruption step itself just demonstrated: the > redirect wrote through the shared inode, so every snapshot hardlinking that file is now corrupt — the exact failure §14's "treat snapshots as read-only" rule exists to prevent. Fine here; the whole tree is disposable.

  3. Trip the deletion guard deliberately:

    mkdir -p emptysrc
    rsync -ai --delete --max-delete=2 emptysrc/ deploy/; echo "exit=$?"

    Expect: at most two deletions, Deletions stopped due to --max-delete limit, and exit=25. Confirm with ls deploy/ that files survived.

  4. Produce a real exit 23 and see what your wrapper would do with it:

    rsync -a /var/db/dslocal/ "$LAB/perm/"; echo "exit=$?"
    # Linux equivalent: rsync -a /etc/ssl/private/ "$LAB/perm/"

    Expect: Permission denied (13) on at least one path, then rsync error: some files/attrs were not transferred … (code 23) and exit=23. That is the code your scheduled job must classify rather than blindly alert on (§15).

  5. Clean up:

    cd / && rm -rf "$LAB" && unset LAB

Final checkpoint: you can state, without looking anything up: which exit codes a nightly backup should tolerate; why --link-dest paths are relative to the destination; what >fc........ means and which flag makes it appear; and why a deploy uses --delete-after with protect rules.

Self-assessment rubric

LevelYou can…
Working fluencyCompose -avhn-style commands, get the trailing slash right first time, use --exclude-from, and dry-run before deleting.
PractitionerRead itemized output cold, choose deletion timing per job, run rsync over SSH with a restricted key, and diagnose exit codes.
MasteryBuild and schedule a hardlinked snapshot system with retention and guards, write filter rules that survive review, explain when the quick check is wrong, and say honestly when rsync is the wrong tool.

Sources: every step above was taught earlier in this guide; the single upstream reference behind all of it is rsync(1), 3.4.4

18 · Troubleshooting: symptom → cause → fix

Every error message below was produced by a real run while writing this guide, or is quoted from rsync 3.4.4's manual. Find your symptom, not your theory.

Transfers that go to the wrong place

SymptomCauseFix
Files landed in dst/src/… instead of dst/…Missing trailing slash on the sourcersync -a src/ dst/. Read the verbose output's first file line: prefixed paths mean no slash. (§4)
Files went to the remote home directory, not where you meantRemote path had no leading slashhost:/srv/app/ not host:srv/app/. (§10)
Destination is a file, expected a directorySingle-file source with no trailing slash on the destinationAdd the slash: rsync -a f.txt out/. (§4)

Nothing transfers, or too much does

SymptomCauseFix
A file you edited was not copiedSame size and same mtime — quick check skipped it-c to compare contents. Confirm with rsync -nic first. (§6)
Every file re-copies on every runDestination filesystem stores coarse timestamps (FAT/exFAT/NTFS via WSL)--modify-window=1. (§3)
Every file re-copies to a NAS or NTFS targetrsync cannot set ownership/permissions, so it retries forever--no-perms --no-owner --no-group, or --chmod=D755,F644. (§12)
Large file re-sends entirely every timeLocal-to-local implies --whole-file--no-whole-file when the "local" destination is a slow network mount. (§6)
--exclude is ignoredShell expanded the pattern before rsync saw itQuote it: --exclude='*.log'. Diagnose with --debug=FILTER2. (§8)
--include has no effectAn earlier --exclude matched firstMove the include before the exclude. First match wins. (§8)
Result is full of empty directories--include='*/' keeps every directoryAdd -m / --prune-empty-dirs. (§8)

Permissions and metadata

MessageCauseFix
opendir "…" failed: Permission denied (13) → code 23Source path unreadable by youRun with the right privileges, or exclude the path. On macOS this is often TCC, not Unix permissions. (§15)
mkdir "…" failed: Operation not permitted (1) → code 11Destination is protected (SIP, read-only volume)Choose a writable destination. Do not try to defeat SIP.
rename "…/.f.txt.x9NXJX" -> "f.txt": Permission denied (13)An ACL on the destination file denies delete, blocking rsync's temp-then-rename--inplace, which writes directly and skips the rename. (§12)
Finder tags / Spotlight comments missing after a backup-a does not include -X-aX. Verify with xattr -l. (§12)
Destination directory's own permissions changedTrailing-slash form transfers the source directory's attributes too--no-perms, or --chmod to set them explicitly. (§12)
Backup is enormous; source has VM imagesSparse files were materialized-S. With --inplace it may silently not sparse. (§12)

Remote and network

MessageCauseFix
remote command not found (code 127) or could not be run (code 126)No runnable rsync on the remote's PATHssh host 'command -v rsync', then --rsync-path=/full/path. (§10)
protocol version mismatch -- is your shell clean?The remote shell prints something at loginVerify ssh host 'true' | wc -c is 0; guard the interactive part of the remote rc file. (§10)
connection unexpectedly closed (0 bytes received so far)Remote rsync died at startup — missing binary, or shell outputSame two checks as above.
an option was specified that is supported by the client and not by the server → code 4Version skewDrop the flag, or upgrade the older side. (§3)
failed to connect to localhost … Connection refused (61) → code 10No daemon listening on that portStart the daemon; check the port. (§11)
@ERROR: failed to open lock file → code 5max connections set without a writable lock fileSet lock file explicitly in rsyncd.conf. (§11)
@ERROR: auth failed on module … → code 5Wrong credentials, or a secrets file rsync rejectedCheck auth users, and that the secrets file is not readable by other. (§11)
ERROR: module is read only → code 1Writing to a read only = yes moduleUse a writable module. (§11)
failed to lock pid file … Resource temporarily unavailableA daemon is already running with that pid filepkill -f 'rsync --daemon', then restart.

Interruption and scheduling

SymptomCauseFix
received SIGINT, SIGTERM, or SIGHUP (code 20), destination emptyNo --partial, so the incomplete file was discarded--partial-dir=.rsync-partial so the next run resumes. (§13)
Partial file has a 1969 timestampDeliberate — it stops the quick check treating it as completeNothing. Run again; rsync finishes it.
Backup exits 24 nightlySource files vanished mid-run. Normal on a live system.Treat 24 as success in your wrapper. (§15)
Exit 25 from a scheduled job--max-delete tripped — often an unmounted sourceInvestigate before re-running. This guard did its job. (§9)
Works in Terminal, fails under launchd with permission errorsmacOS TCC: the background job lacks Full Disk AccessGrant it to the script's interpreter. Test with launchctl kickstart, not by running the script yourself. (§15)
Cron job runs the wrong rsynccron's minimal PATH excludes HomebrewAbsolute paths in scheduled jobs, or set PATH at the top of the crontab. (§15)
Two backups running at onceJob takes longer than its intervalflock -n, or the portable mkdir lock. (§15)
Out of memory on a huge tree → code 22-H holds an inode map for every fileDrop -H, or split the transfer.

Two failures that look like rsync but are not

"rsync deleted my files" Almost always one of three things: a missing trailing slash combined with --delete; an unmounted source directory; or --delete-excluded where a plain --exclude was meant. All three are visible in a dry run, which is why the dry run is not optional. rsync has no undo.
"rsync is corrupting my files" rsync verifies every transferred file with a strong checksum before renaming it into place, so silent corruption in transit is not a realistic failure mode. What is realistic: a flaky disk on either end, or --inplace plus an interruption leaving a file half-updated. Verify with rsync -nic src/ dst/, which reads both sides in full and names any file that actually differs.

Sources: rsync(1) — DIAGNOSTICS, EXIT VALUES · rsyncd.conf(5)

19 · Cheat sheet

Recall scaffolding, not a replacement for the sections. Every line links to where it was taught — if a flag here surprises you, that link is the fix, not this page.

The universal starting pattern

# Look, then leap. Press ↑ and delete the n.
rsync -avhn --delete SRC/ DEST/
rsync -avh  --delete SRC/ DEST/

Mirror a directory

rsync -avh --delete --max-delete=100 \
      ~/work/ /Volumes/Backup/work/

Push over SSH

rsync -avhz --partial-dir=.rsync-partial \
      ~/site/ web:/srv/site/

Pull from a server

rsync -avh --info=progress2 \
      web:/srv/data/ ~/data/

Deploy, protecting state

rsync -avh --delete-after \
      --filter='P /uploads/' --filter='P /.env' \
      ./dist/ web:/srv/app/

Dated snapshot

rsync -aHAX --delete \
      --link-dest=../$(ls /bk | tail -1) \
      ~/docs/ /bk/$(date +%F)/

Verify two trees match

rsync -nic src/ dst/
# no output = byte-identical

Move (copy then remove source)

rsync -avh --remove-source-files src/ dst/
find src -type d -empty -delete

Only certain file types

rsync -avhm --include='*/' \
      --include='*.jpg' --include='*.png' \
      --exclude='*' src/ dst/

WSL → Windows drive

rsync -rlt --no-perms --no-owner --no-group \
      --modify-window=1 --delete \
      ~/proj/ /mnt/c/Users/you/proj/

Mac → Mac, keep everything

rsync -aXN --delete \
      ~/Documents/ /Volumes/Backup/Documents/

Diff two snapshots

rsync -ain --delete /bk/2026-08-05/ /bk/2026-08-06/

Why did it skip that file?

rsync -ain --debug=FILTER2 src/ dst/ 2>&1 \
  | grep hiding

Flag reference

FlagMeaningSection
-a-rlptgoD: recurse + preserve links, perms, times, group, owner, devices§5
-v -vvverbose; twice for more§7
-hhuman-readable sizes§5
-ndry run§7
-iitemize changes§7
-ccompare by checksum, not size+mtime§6
-uskip files newer on the receiver§6
-z --zc=zstdcompress in flight; choose algorithm§13
-P--partial --progress§13
-H -A -X -N -Shard links · ACLs · xattrs · creation times · sparse§12
-L --safe-linksfollow symlinks · drop escaping ones§12
-mprune empty directories§8
-Rpreserve the source path; /./ sets the pivot§12
-e --rsync-pathremote shell · remote rsync binary§10
-ssend filenames over the protocol, not the shell§10
--delete --delete-after --delete-excludedremove extraneous destination files§9
--max-delete=Nstop after N deletions, exit 25§9
--backup --backup-dir=Dmove displaced files aside instead of losing them§9
--exclude= --exclude-from= -Ffilter rules: inline · from a file · per-directory§8
--filter='P pat'protect destination paths from deletion§9
--link-dest=Dhard-link unchanged files from D§14
--partial-dir=Dkeep interrupted fragments in D§13
--inplacewrite directly; no temp file, no atomicity§13
--modify-window=Ntreat mtimes within N seconds as equal§3
--chmod=D755,F644impose permissions rather than copy them§12
--files-from=Ftransfer exactly the listed paths§12
--mkpathcreate missing destination path components§12
--bwlimit=Nthrottle to N KiB/s (suffixes allowed)§13
--stats --info=progress2summary · one running total§7
--debug=FILTER2explain which rule hid which file§8
--no-whole-fileforce delta transfer on a local copy§6

Itemize codes at a glance

YXcstpoguax  path
│││││││││││
││└─────────  c  checksum differs (needs -c) / changed symlink value
││ └────────  s  size differs
││  └───────  t  mtime differs (T = set to transfer time)
││   └──────  p  permissions differ
││    └─────  o  owner differs
││     └────  g  group differs
││      └───  u  atime (u) · crtime (n) · both (b) — needs -U / -N
││       └──  a  ACL differs      (needs -A)
││        └─  x  xattrs differ    (needs -X)
│└──────────  X  f file · d dir · L symlink · D device · S special
└───────────  Y  > received · < sent · c created · h hardlinked
               . attrs only · * message (e.g. *deleting)

Sources: rsync(1) — OPTION SUMMARY

Glossary

archive mode
-a, equivalent to -rlptgoD. Recursion plus preservation of symlinks, permissions, times, group, owner, and device/special files. Excludes ACLs, xattrs, atimes, crtimes, and hard-link detection. (§5)
basis file
The existing destination file the delta algorithm describes and builds upon. Without one, a transfer is entirely literal. Supplied implicitly by an existing file, or explicitly via --link-dest, --compare-dest, or --fuzzy. (§6)
block size
The chunk length the delta algorithm divides files into. Scales with file size by default; forced with -B. Determines the granularity of what counts as "unchanged". (§6)
daemon mode
rsync running as a server on TCP 873 (by default), publishing named modules. Addressed with host::module or rsync://host/module. Distinct from SSH transport. (§11)
delta-transfer algorithm
rsync's namesake: the receiver sends checksums describing the blocks it already has; the sender transmits only unmatched literal data plus references. Disabled by default for local-to-local copies. (§6)
dry run
-n. Makes every decision, performs none. Output is a plan, not a transcript — it reports directories it did not create. (§7)
extended attribute (xattr)
Out-of-band metadata attached to a file. On macOS holds Finder tags, Spotlight comments, quarantine flags, and resource forks. Preserved only with -X. (§12)
extraneous file
A file present at the destination but absent from the sender's file list. What --delete removes. Note that filter rules shape the file list first. (§9)
file list
The set of paths rsync intends to reconcile, built by walking the source and applying filter rules. Streamed incrementally in rsync 3.x, which is why large transfers start immediately. (§7)
filter rule
An include, exclude, protect, or hide directive. Evaluated in order; first match wins. Expressed via --exclude, --include, --filter, --exclude-from, or per-directory merge files. (§8)
generator
The rsync role that compares the file list against the destination and decides what to skip, update, create, or delete. Emits block checksums for the sender. (§1)
A second directory entry pointing at the same inode. Costs no additional data blocks. The basis of --link-dest snapshots and the reason -H exists. (§14)
in-place write
--inplace. Writes updates directly to the destination file rather than to a temporary file that is renamed. Saves space and preserves hard links and ACLs; forfeits atomicity. (§13)
itemized change string
The eleven-character code emitted by -i, e.g. >f.st....... Positions 1–2 give direction and file type; 3–11 name which attributes differ. (§7)
literal data
Bytes that had to be sent because no matching block existed at the destination. Reported by --stats alongside matched data. (§6)
merge file
A per-directory filter file, conventionally .rsync-filter, read when rsync enters that directory. Enabled with -F or --filter='dir-merge …'; -FF also excludes the file itself. (§8)
module
A named, path-mapped share published by an rsync daemon, defined by a [section] in rsyncd.conf. Carries its own permissions and optional authentication. (§11)
openrsync
An independent rsync reimplementation originating in OpenBSD and shipped by Apple as /usr/bin/rsync on current macOS. Speaks protocol 29. Lacks -X, --info=progress2, and modern compression; its -i output uses the older 9-character format. (§3)
partial file
An incomplete transfer preserved by --partial or --partial-dir, deliberately given an ancient mtime so the quick check cannot mistake it for complete. Becomes the basis on the next run. (§13)
protocol version
The wire-format level two rsyncs negotiate. 3.4.4 speaks 32; openrsync speaks 29. Mismatches negotiate down; unsupported options, not protocol, are what actually fail. (§3)
quick check
rsync's default test for whether a file needs transferring: size differs or mtime differs. Contents are not read. Overridden by -c, --size-only, or -I. (§6)
receiver
The rsync role that reassembles files at the destination, writing to a hidden temporary file and renaming it into place. (§1)
rolling checksum
A cheap checksum computable incrementally as a window slides one byte at a time. Lets the sender find matching blocks at arbitrary offsets, so inserted bytes do not defeat matching. (§6)
sender
The rsync role that reads the source tree, builds the file list, and computes deltas. Announced as --sender in the remote invocation when you are pulling. (§1)
snapshot (hardlinked)
A dated directory produced with --link-dest: a complete browsable tree whose unchanged files are hard links to the previous snapshot, so they cost no extra space. (§14)
sparse file
A file whose zero runs are not stored as blocks. Preserved with -S; without it, holes are materialized into real bytes. (§12)
trailing slash
On a source path, means "the contents of this directory" rather than "this directory". Irrelevant on a destination path for directory sources; significant for single-file sources. (§4)
transfer root
The directory that anchored filter patterns are relative to — the source as rsync sees it after applying the trailing-slash rule. /build means "build at the transfer root". (§8)
whole-file mode
-W. Sends files entire, skipping delta computation. Implied for local-to-local copies, where reading both files costs more than writing one. (§6)

Index

  • A
  • -a, --archive§5, §12
  • -A, --acls§12
  • ACL rename failure (macOS) — §12, §18
  • anchoring, pattern — §8
  • --atimes, -U§12
  • authorized_keys, forced command — §15
  • B
  • --backup, --backup-dir§9
  • --block-size, -B§6
  • --bwlimit§10, §13
  • C
  • -c, --checksum§6
  • --chmod§12
  • --chown§12
  • --compare-dest, --copy-dest§14
  • --compress, -z§13
  • --compress-choice, --zc§13
  • --config (daemon) — §11
  • --contimeout§15
  • ControlMaster (SSH) — §10
  • --crtimes, -N§12
  • cron — §15
  • D
  • daemon, --daemon§11
  • daemon over SSH — §11
  • --debug=FILTER2§8
  • --delete and variants — §9
  • --delete-after§9
  • --delete-excluded§9
  • delta-transfer algorithm — §6
  • direction (> vs <) — §7, §11
  • --dry-run, -n§7
  • E
  • -e, --rsh§10
  • empty-source catastrophe — §9
  • --exclude, --exclude-from§8
  • exit codes (full table) — §15
  • exit 23 / 24 / 25 — §15, §9
  • exit 126 / 127 (remote command) — §10
  • F
  • -F, -FF (merge files) — §8
  • --fake-super, -M--fake-super§12
  • --files-from§12
  • --filter§8
  • first match wins — §8
  • flock / portable lock — §15
  • Full Disk Access (macOS TCC) — §15
  • --fuzzy, -y§14
  • G
  • generator (role) — §1
  • glob expansion, remote — §10
  • --groupmap, --usermap§12
  • H
  • -H, --hard-links§12
  • hard link — §14
  • Homebrew rsync — §3
  • I
  • -i, --itemize-changes§7
  • --iconv§12
  • --ignore-existing, --existing§6
  • --include§8
  • --info=progress2§7, §13
  • --inplace§12, §13
  • L
  • -L, --copy-links§12
  • launchd — §15
  • --link-dest§14
  • lock file (rsyncd.conf) — §11
  • --log-file-format§15
  • M
  • -m, --prune-empty-dirs§8
  • man page, wrong (macOS) — §3
  • --max-delete§9
  • --mkpath§12
  • -M, --remote-option§12
  • --modify-window§3, §6
  • module (daemon) — §11
  • --munge-links§11
  • N
  • --no-whole-file§6
  • --no-detach (daemon) — §11
  • --no-perms, --no-owner, --no-group§3, §12
  • --numeric-ids§12
  • O
  • openrsync — §3
  • P
  • -P§5
  • --partial, --partial-dir§13
  • --progress§7
  • protect rule (P) — §9
  • protocol version — §3
  • Q
  • quick check — §6
  • quoting patterns — §2, §8
  • R
  • -R, --relative§12
  • receiver (role) — §1
  • --remove-source-files§9
  • rolling checksum — §6
  • --rsync-path§10
  • rsyncd.conf§11
  • RSYNC_PASSWORD§11
  • S
  • -s, --secluded-args§10
  • -S, --sparse§12
  • --safe-links§12
  • secrets file — §11
  • sender (role) — §1
  • --server (internal) — §10
  • shell, chatty remote — §10
  • --size-only§6
  • --skip-compress§13
  • snapshot script — §14
  • SSH key setup — §10
  • --stats§7
  • --stop-after, --stop-at§13
  • systemd timer — §15
  • --timeout§15
  • T
  • trailing slash — §4
  • transfer root — §8
  • U
  • -u, --update§6
  • W
  • -W, --whole-file§6
  • WSL /mnt/c§3, §12
  • X
  • -X, --xattrs§12
  • Z
  • -z, --zc, --zl§13

Retrieval quiz

Answer out loud before revealing. Retrieval builds durable memory in a way re-reading does not — this is the highest-value ten minutes in the guide. Aim for 16/19 before you consider rsync learned; revisit a day later and again a week later.

Score: 0 learned · 0 review · 0/17 answered
Q1rsync does not "copy files". What does it do, and what one sentence explains --delete, idempotency, and the trailing slash at once?
It makes a destination match a source while moving as few bytes as possible. Because "match" is the goal: extra destination files are ambiguous (hence opt-in --delete), a second run finds nothing to do, and the slash answers "which destination path should hold this source?" (§1)
Q2What is rsync's default test for whether a file needs transferring?
The quick check: transfer if the size differs or the mtime differs. Contents are never read. A same-size, same-timestamp edit is silently skipped until you add -c. (§6)
Q3rsync -a src dst/ versus rsync -a src/ dst/ — what does each produce, and does the destination slash matter?
No source slash → dst/src/…. Source slash → contents land directly in dst/…. The destination slash is irrelevant for directory sources; it matters only when the source is a single file and the destination does not yet exist (file vs directory). (§4)
Q4What does -a expand to, and name three things it does not preserve.
-rlptgoD. It omits ACLs (-A), extended attributes (-X), access times (-U), creation times (-N), and hard-link detection (-H). On macOS the missing -X silently drops Finder tags and Spotlight metadata. (§5, §12)
Q5Decode >f.st...... and .f...p...... Did bytes cross the wire in each case?
First: a regular file received (>f) whose size and mtime differed — yes, data transferred. Second: leading . means no transfer; only the permissions were adjusted on an existing file. (§7)
Q6Why does a big local-to-local copy send the whole file after a tiny edit, while the same copy over SSH sends almost nothing?
Local-to-local implies --whole-file: computing deltas means reading both files, which costs more than writing one when both are on your disks. Add --no-whole-file when the "local" destination is really a slow network mount. Measured: 10,485,760 literal bytes versus 1,152. (§6)
Q7Your --include has no effect. What is the most likely reason?
An earlier --exclude matched first. Filter rules are evaluated in order and first match wins; an include only helps when it precedes the exclude that would catch the file. (§8)
Q8Write the incantation that copies only .md files out of a deep tree, leaving no empty directories.
rsync -avm --include='*/' --include='*.md' --exclude='*' src/ dst/. The */ include lets rsync descend; -m prunes the directories that end up empty. (§8)
Q9What exactly does rsync run on the remote host, and what is the one dependency that implies?
It runs ssh host rsync --server … — verified as rsync --server -vlogDtpre.iLsfxCIvu . /path/, with --sender added when pulling. The only dependency is an rsync binary on the remote's PATH; no daemon, no port. (§10)
Q10A remote transfer dies with protocol version mismatch -- is your shell clean? What is wrong and how do you confirm it?
The remote shell prints something at login — a banner or motd — and that text corrupts rsync's protocol stream. Confirm with ssh host 'true' | wc -c; it must be 0. Fix by guarding the interactive part of the remote rc file. (§10)
Q11host:/srv/data/ versus host::data/ — what is the difference?
One colon is SSH transport addressing a filesystem path and needs a shell account. Two colons is daemon transport addressing a named module on TCP 873, which needs an rsync daemon and no shell at all. (§11)
Q12Your unmounted backup volume leaves an empty directory at the destination path. What does rsync -a --delete do, and what stops it?
It faithfully makes the destination match an empty source — deleting everything, with no error. Stop it with --max-delete=N (exits 25 after N deletions), a mount-marker guard in the script, and a dry run before any edited --delete command. (§9)
Q13How does --link-dest give you a hundred full backups for roughly the size of one, and what is --link-dest's path relative to?
Unchanged files are hard-linked to the previous snapshot's inodes rather than copied, so each snapshot is a complete browsable tree costing only its changed files. The path is relative to the destination directory — get it wrong and rsync silently writes full copies. (§14)
Q14Which exit codes should a nightly backup treat as success rather than failure, and why?
0 and 24 unconditionally — 24 means source files vanished mid-run, which is routine on a live machine. 23 (something unreadable) is worth logging but is usually not a broken backup. Alert on everything else, and treat 25 as urgent. (§15)
Q15Your rsync works in Terminal and fails under launchd with permission errors on ~/Documents. What is happening?
macOS TCC. The background job has no Full Disk Access and cannot prompt, so it exits 23 with Operation not permitted. Grant Full Disk Access to the script's interpreter and test with launchctl kickstart — running the script yourself inherits Terminal's permissions and succeeds misleadingly. (§15)
Q16On macOS, rsync --version says 3.4.4 but man rsync disagrees with it. Why, and what do you do?
man -w rsync resolves to /usr/share/man/man1/openrsync.1 — Apple's page for its own implementation — regardless of PATH. Read the right one: man "$(brew --prefix rsync)/share/man/man1/rsync.1", or fix MANPATH. (§3)
Q17Name one situation where rsync is the wrong tool, and what you would use instead.
Several are defensible: continuous bidirectional sync between devices → Syncthing; object storage destinations → rclone; encrypted, deduplicated, off-site backup → restic or borg; seeding a million small files into an empty destination → tar piped over ssh. rsync is a one-shot reconciler you invoke, and it is unbeaten at exactly that. (§16)
Q18You interrupt a large transfer with Ctrl+C. What decides whether the next run resumes where it left off — and which form must a live website use?
Without --partial, the incomplete file is discarded and the next run starts that file over. Bare --partial keeps the fragment at its final name — on a live site you have just published a truncated file. --partial-dir=.rsync-partial keeps the fragment hidden, stamped with an ancient mtime, and the next run uses it as the delta basis. (§13)
Q19When does -z compression make a transfer faster, and when is it pure cost?
It pays only when the link is slower than the CPU can compress — a home upload, VPN, or cellular link carrying text, code, or logs. On local disks and LANs it burns CPU for nothing, and on already-compressed data (photos, video, archives) it can never win — --skip-compress covers mixed trees. (§13)

Sources

Checked with rsync 3.4.4, protocol version 32 (Homebrew) and openrsync, protocol version 29 (Apple, /usr/bin/rsync) on macOS 15.6 · 2026-08-07. Every transcript in this guide was captured from a real run in a temporary directory; where a mechanism could not be reproduced locally it is labelled as such. Remote-shell transcripts were produced with a logging stand-in for ssh, so the --server argv is genuine while the transport is a local pipe. External links require network access; the guide itself works offline.

  • rsync(1) manual page — the authoritative reference for every flag in this guide. Best used for: exact option semantics, FILTER RULES, and EXIT VALUES. Read this one, not the openrsync page macOS gives you.
  • rsyncd.conf(5) — daemon configuration. Best used for: module parameters, auth users, secrets file rules, chroot and strict modes.
  • The rsync algorithm — Tridgell & Mackerras technical report — the original 1996 paper. Best used for: understanding rolling checksums and why block matching survives insertions. Short, readable, and still accurate.
  • rsync home page — Best used for: confirming the current stable version and reading release announcements. 3.4.4 was released 2026-06-08.
  • rsync NEWS file — Best used for: what changed between your version and current, and which CVEs a release fixed. 3.4.0 fixed six; 3.4.3 fixed six more.
  • openrsync(1) — Best used for: knowing what Apple's /usr/bin/rsync actually supports, when you cannot install Homebrew rsync.
  • Ubuntu rsync package versions — Best used for: confirming which rsync your Ubuntu or WSL release ships before you rely on a version-gated flag.
  • CVE-2024-12084 (NVD) — Best used for: the concrete reason to run a patched rsync if you ever connect to a server you do not control. Heap buffer overflow in checksum parsing, fixed in 3.4.0.
  • CVE-2026-29518 (NVD) — Best used for: understanding why use chroot = no is a real risk in daemon mode. TOCTOU symlink race, fixed in 3.4.3.
  • systemd.timer(5) — Best used for: OnCalendar syntax, Persistent=true semantics for laptops, and randomized delays.
  • Apple — Creating launchd jobs — Best used for: plist keys and the modern launchctl bootstrap / bootout verbs. Archived but still the clearest description of the model.
  • ssh_config(5) — Best used for: ControlMaster connection reuse and ProxyJump, both of which belong in your config rather than in -e strings.