rsync
Synchronise directory trees, copying only what changed
rsync copies a directory tree to somewhere else and, on every run after the first, copies only
the parts that differ. That is the whole idea: where cp -a rewrites every byte
each time, rsync compares the two sides first and transfers the difference. Pointing it at the
same destination a second time is cheap, which is what makes it the tool for a backup, a deploy,
or any copy you expect to repeat.
Although rsync can copy to a remote machine, most of what follows runs between two local
directories, because the flags behave identically either way and a local pair is easier to reason
about. A remote path is host:path, and the last section covers what genuinely changes once you
write one. The copy files between machines recipe covers
the workflow around a transfer, including how it resumes after an interruption.
The trailing slash decides what you get
This is the one piece of rsync syntax that catches everybody, and it is worth learning before
anything else, because both forms succeed and they produce different trees.
A trailing slash on the source means the contents of this directory. No trailing slash means this directory itself.
rsync -a site/ backup/ # backup/index.html
rsync -a site backup/ # backup/site/index.html
Run the second one twice believing it to be the first, and you get backup/site/site/. The slash
on the destination changes nothing, so the safe habit is to put one on both sides and be
deliberate about the source.
Reading what it did
-v prints filenames. -i (--itemize-changes) prints a reason for each one, which makes it the
more useful of the two once the first copy is behind you:
>f+++++++++ about.html
>f.st...... index.html
cd+++++++++ css/
*deleting stale.html
The first character is the update being made (> received, < sent to a remote host,
c created locally, * a message rather than a transfer), and the second is the file type
(f file, d directory, L symlink). A + in place of a letter means the item is being
created, so no comparison was made. Otherwise a letter marks a field that differs and a . marks
one that matches: s size, t time, p permissions, o owner, g group, c checksum. So
>f.st...... reads as "sending a file whose size and time differ", which is the ordinary case for
a file you edited.
Used alone, -i prints no summary line, which is why the examples below prefer it. Adding -v
appends a transfer rate that differs on every run.
How it decides what to copy
By default rsync compares size and modification time. This is quick to check, which is the
reason a second run costs very little. The same check is the one assumption here that can be
wrong: a file edited in place, to the same length, with its timestamp restored afterwards, matches
on both counts and is skipped.
-c (--checksum) reads both copies and compares them properly. It is correct where the quick
check is merely fast, but it requires a full read of both sides, so it belongs on the run where
you suspect something rather than on every run.
-a is a bundle, not a mode
-a (--archive) is shorthand for -rlptgoD: recurse, copy symlinks as symlinks, preserve
permissions, times, group, owner and device files. Almost every rsync command wants it, and the
examples below use it throughout.
Knowing what it expands to pays off when one component is wrong for the job. Owner and group need
privileges the copy may not have. -r on its own recurses and preserves none of the metadata,
which leaves every destination file stamped with the time it was copied rather than the time it
was written, so a later run has nothing stable to compare against.
Deleting, carefully
rsync adds and updates by default. A file deleted from the source stays in the destination for
ever, so the two drift apart in one direction only. --delete fixes that by removing anything in
the destination the source no longer has.
The catch is that --delete trusts the source absolutely. Point it at a path that does not hold
what you think, or at a directory that happens to be empty, and every file in the destination
qualifies as something the source no longer has. A mistyped source path is therefore an
instruction to empty the backup, which is then carried out immediately.
So run it under -n (--dry-run) first, and filter for the removals when the file list is long
enough to bury them:
rsync -ain --delete site/ backup/ | grep deleting
Then keep --max-delete on anything that runs unattended, which abandons the transfer rather than
proceeding once the count looks wrong.
Sample files used on this page
Every example below was run against these files. Recreate them to follow along.
site The tree every example copies. drafts/latest.html and drafts/v2.html are two names for one inode, which is the link count of 2 in the listing, and build.tmp is the artefact the exclude examples leave behind. Every example also starts with an empty backup/ directory alongside it.
site:
total 24
-rw-r--r-- 1 user user 15 Jan 5 2026 about.html
-rw-r--r-- 1 user user 17 Jan 5 2026 build.tmp
drwxr-xr-x 2 user user 4096 Jan 5 2026 css
drwxr-xr-x 2 user user 4096 Jan 5 2026 drafts
drwxr-xr-x 2 user user 4096 Jan 5 2026 img
-rw-r--r-- 1 user user 14 Jan 5 2026 index.html
site/css:
total 4
-rw-r--r-- 1 user user 19 Jan 5 2026 main.css
site/drafts:
total 12
-rw-r--r-- 2 user user 23 Jan 5 2026 latest.html
-rw-r--r-- 1 user user 34 Jan 5 2026 release-notes.html
-rw-r--r-- 2 user user 23 Jan 5 2026 v2.html
site/img:
total 4
-rw-r--r-- 1 user user 2048 Jan 5 2026 logo.png
Copying a tree
The trailing slash on the source is the first thing to get right: with one you copy the contents, without one you copy the directory itself.
Copy a directory's contents into another directory
rsync -ai site/ backup/
The trailing slash on site/ means the contents of, so backup/ ends up holding index.html rather than site/index.html. -a preserves permissions, times and symlinks; -i prints a line per item, where >f+++++++++ is a file being created.
Show output
>f+++++++++ about.html
>f+++++++++ build.tmp
>f+++++++++ index.html
cd+++++++++ css/
>f+++++++++ css/main.css
cd+++++++++ drafts/
>f+++++++++ drafts/latest.html
>f+++++++++ drafts/release-notes.html
>f+++++++++ drafts/v2.html
cd+++++++++ img/
>f+++++++++ img/logo.png
Copy the directory itself, keeping its name
rsync -ai site backup/
Drop the trailing slash and site is copied as a directory, so every path gains a site/ component. Both forms succeed, which is why running the wrong one twice gives you backup/site/site/.
Show output
cd+++++++++ site/
>f+++++++++ site/about.html
>f+++++++++ site/build.tmp
>f+++++++++ site/index.html
cd+++++++++ site/css/
>f+++++++++ site/css/main.css
cd+++++++++ site/drafts/
>f+++++++++ site/drafts/latest.html
>f+++++++++ site/drafts/release-notes.html
>f+++++++++ site/drafts/v2.html
cd+++++++++ site/img/
>f+++++++++ site/img/logo.png
Copy a single file
rsync -ai site/index.html backup/
A file source needs no slash rule. The file lands in backup/ under its own name.
Show output
>f+++++++++ index.html
Copy a file under a different name
rsync -ai site/index.html backup/homepage.html
ls backup/
Where the destination names a file rather than a directory, rsync renames as it copies, the same way cp does.
Show output
>f+++++++++ index.html
homepage.html
Create the destination directory if it is missing
rsync -ai site/ newdir/ | head -3
ls -ld newdir
rsync creates the last component of the destination path and reports that it has done so. It will not create missing parent directories, which needs --mkpath.
Show output
created directory newdir
cd+++++++++ ./
>f+++++++++ about.html
drwxr-xr-x 5 user user 4096 Jan 5 2026 newdir
Confirm the two trees really match
rsync -a site/ backup/
diff -r site backup && echo "trees match"
diff -r compares the two trees file by file and won't print anything when they agree, so this is the check that the copy did what you meant. See diff.
Show output
trees match
Seeing what it will do first
-n is dry-run mode. It is cheap to run, and it shows exactly what would happen without making any changes, which makes it good practice before any non-trivial copy.
Preview a transfer without writing anything
rsync -ain site/ backup/ | head -4
-n (--dry-run) does the comparison and prints the same itemised lines, leaving the destination untouched. Pair it with -i and you can read the plan before agreeing to it.
Show output
>f+++++++++ about.html
>f+++++++++ build.tmp
>f+++++++++ index.html
cd+++++++++ css/
See why a file is being sent
rsync -a site/ backup/
touch -d "2026-02-01 10:00:00" site/about.html
rsync -ai site/ backup/
Only the timestamp moved, so only t is lettered and the rest of the fields are .. Note what that does not say: the size matched and the timestamp differed, which is enough for rsync to resend the file without ever reading either copy. Whether the contents changed is a question only -c (--checksum) can answer.
Show output
>f..t...... about.html
Count what a transfer moved
rsync -a --stats site/ backup/ | grep -E '^(Number|Total file size)'
--stats totals the transfer. The lines are filtered here because the rest of the block reports a transfer rate, which differs on every run.
Show output
Number of files: 12 (reg: 8, dir: 4)
Number of created files: 11 (reg: 8, dir: 3)
Number of deleted files: 0
Number of regular files transferred: 8
Total file size: 2,193 bytes
Only what changed
The second run is the one that shows what rsync is for. It compares size and modification time, transfers the difference, and says how much it saved.
Watch the second run transfer nothing
rsync -a site/ backup/
rsync -ai site/ backup/
echo "(no itemised lines above)"
Everything already matches on size and time, so rsync writes nothing and prints nothing. Repeating a copy is close to free, which is what makes it safe to schedule.
Show output
(no itemised lines above)
Count what a first and second run each transferred
rsync -a --stats site/ backup/ | grep "regular files transferred"
rsync -a --stats site/ backup/ | grep "regular files transferred"
Eight files on the first run and none on the second, which is the whole point of using rsync over cp for a regular or scheduled copy. --stats also reports a speedup ratio and a byte total, both of which move between machines, so this asks for the line that does not.
Show output
Number of regular files transferred: 8
Number of regular files transferred: 0
Miss an edit that kept its size and timestamp
rsync -a site/ backup/
printf '<h1>Hom3</h1>\n' > site/index.html
touch -d "2026-01-05 09:00:00" site/index.html
rsync -ai site/ backup/
cat backup/index.html
The edit is the same length as the original and the timestamp was put back, so the quick check finds both sides identical and skips the file. The destination still holds the old contents. This is the one case where the default is wrong rather than merely fast.
Show output
<h1>Home</h1>
Catch it by comparing contents
rsync -a site/ backup/
printf '<h1>Hom3</h1>\n' > site/index.html
touch -d "2026-01-05 09:00:00" site/index.html
rsync -aic site/ backup/
cat backup/index.html
-c (--checksum) reads both copies and compares them, so the difference is found. The c in >fc........ is the checksum field, the only one that differs. It costs a full read of both sides, so use it where you suspect drift.
Show output
>fc........ index.html
<h1>Hom3</h1>
Leave a newer destination file alone
rsync -a site/ backup/
touch -d "2026-06-01 12:00:00" backup/index.html
rsync -aiu site/ backup/
echo "(nothing transferred)"
ls -l backup/index.html
-u (--update) skips any destination file with a newer timestamp than the source, so a copy edited at the far end is not overwritten by an older original.
Show output
(nothing transferred)
-rw-r--r-- 1 user user 14 Jun 1 12:00 backup/index.html
Skip files the destination already has
rsync -a site/ backup/
printf '<h1>Changed</h1>\n' > site/index.html
rsync -ai --ignore-existing site/ backup/
echo "(nothing transferred)"
cat backup/index.html
--ignore-existing compares nothing for a file that is already there, whatever its contents or age. Useful for topping up a destination without touching what it holds.
Show output
(nothing transferred)
<h1>Home</h1>
Update only files the destination already has
rsync -a --exclude=about.html site/ backup/
rsync -ai --existing site/ backup/
echo "(about.html was not created)"
--existing is the mirror of the flag above: it updates what is there and refuses to add anything new, so a partial destination stays partial.
Show output
(about.html was not created)
Choosing what to copy
Exclude patterns match against the path relative to the source, and a pattern ending in / matches directories alone.
Leave build artefacts behind
rsync -ai --exclude='*.tmp' site/ backup/
--exclude takes a glob matched against each path below the source. Quote it, or the shell expands it against the current directory before rsync sees it, which is the same trap quoting covers in general.
Show output
>f+++++++++ about.html
>f+++++++++ index.html
cd+++++++++ css/
>f+++++++++ css/main.css
cd+++++++++ drafts/
>f+++++++++ drafts/latest.html
>f+++++++++ drafts/release-notes.html
>f+++++++++ drafts/v2.html
cd+++++++++ img/
>f+++++++++ img/logo.png
Skip a whole directory
rsync -ai --exclude='drafts/' site/ backup/
The trailing slash restricts the pattern to directories, so a file called drafts would still be copied. The directory is not descended into at all.
Show output
>f+++++++++ about.html
>f+++++++++ build.tmp
>f+++++++++ index.html
cd+++++++++ css/
>f+++++++++ css/main.css
cd+++++++++ img/
>f+++++++++ img/logo.png
Keep the exclusions in a file
printf '*.tmp\ndrafts/\n' > exclude.txt
rsync -ai --exclude-from=exclude.txt site/ backup/
--exclude-from reads one pattern per line from the file. This is handy when there are many patterns, since it keeps the command readable, and it lets the list live under version control alongside the thing being copied.
Show output
>f+++++++++ about.html
>f+++++++++ index.html
cd+++++++++ css/
>f+++++++++ css/main.css
cd+++++++++ img/
>f+++++++++ img/logo.png
Copy an explicit list of paths
printf 'index.html\ncss/main.css\n' > list.txt
rsync -ai --files-from=list.txt site/ backup/
--files-from inverts the question: rather than excluding, it names exactly what to send. Parent directories in the list are created as needed.
Show output
>f+++++++++ index.html
cd+++++++++ css/
>f+++++++++ css/main.css
Copy one file type and drop the empty directories
rsync -aim --include='*/' --include='*.css' --exclude='*' site/ backup/
Include-then-exclude is the idiom for a whitelist: descend into every directory, keep the .css files, reject the rest. -m (--prune-empty-dirs) then removes the directories that matched nothing, which would otherwise all be created empty.
Show output
cd+++++++++ css/
>f+++++++++ css/main.css
Making the destination match
rsync adds and updates but never removes, so a destination only drifts in one direction until you ask for the other.
See that a deleted file survives in the destination
rsync -a site/ backup/
rm site/about.html
rsync -ai site/ backup/
ls backup/about.html
The file is gone from the source but still in the destination. rsync reports nothing about it. A backup taken this way accumulates everything ever deleted.
Show output
.d..t...... ./
backup/about.html
Preview the deletions before agreeing to them
rsync -a site/ backup/
rm site/about.html
rsync -ain --delete site/ backup/
Lines prefixed with *deleting show every file --delete would remove. Previewing them under -n is an excellent habit to adopt, and it is what separates a mirror from an accident.
Show output
*deleting about.html
.d..t...... ./
See what a wrong source path would destroy
rsync -a site/ backup/
mkdir -p empty
rsync -ain --delete empty/ backup/ | grep deleting
--delete trusts the source completely, so an empty or mistyped source means every file in the destination is one the source no longer has. rsync cannot tell that apart from a deliberate emptying, so it carries on: filtering the dry run for deleting is what shows the whole backup about to go.
Show output
*deleting img/logo.png
*deleting img/
*deleting drafts/v2.html
*deleting drafts/release-notes.html
*deleting drafts/latest.html
*deleting drafts/
*deleting css/main.css
*deleting css/
*deleting index.html
*deleting build.tmp
*deleting about.html
Mirror the source, removals included
rsync -a site/ backup/
rm site/about.html
rsync -ai --delete site/ backup/
ls backup/about.html 2>&1
--delete removes destination files the source no longer has, which is what makes the copy a mirror. Check the source path twice: a typo that resolves to an empty directory empties the destination.
Show output
*deleting about.html
.d..t...... ./
ls: cannot access 'backup/about.html': No such file or directory
Stop a mirror that wants to delete too much
rsync -a site/ backup/
rm site/about.html site/index.html
rsync -a --delete --max-delete=1 site/ backup/ 2>&1 | head -1
echo "exit status: ${PIPESTATUS[0]}"
--max-delete abandons the run once the count is exceeded, and exits 25 rather than 0. Worth setting on anything that runs unattended, where a sudden mass deletion is more likely to be a broken source path than a real change.
Show output
Deletions stopped due to --max-delete limit (1 skipped)
exit status: 25
Remove excluded files from the destination too
rsync -a site/ backup/
rsync -ai --exclude='*.tmp' --delete --delete-excluded site/ backup/
--delete alone protects excluded files, since they are not part of the comparison. --delete-excluded says to remove them as well. That is what you want when a file used to be copied and should now be cleaned out of the destination rather than left behind.
Show output
*deleting build.tmp
Keep a copy of every file replaced
rsync -a site/ backup/
printf '<h1>Home v2</h1>\n' > site/index.html
rsync -ai --backup --backup-dir=../old site/ backup/
ls old/
--backup moves the old version aside rather than overwriting it, and --backup-dir says where. The path is relative to the destination, so the ../ is what puts old/ beside backup/ rather than inside it. Put it inside and --delete finds a directory the source does not have and tries to remove it, while each run backs up the previous backup. Where it has to live inside, --exclude keeps it out of the comparison.
Show output
>f.st...... index.html
index.html
Move files rather than copy them
rsync -ai --remove-source-files site/index.html backup/
ls site/index.html 2>&1
--remove-source-files deletes each source file once it has been transferred and verified. Directories are left behind. Prefer it to mv when moving files between different filesystems: mv there copies and then deletes, so an interruption can leave a partial copy, where rsync removes the source only after the transfer is verified.
Show output
>f+++++++++ index.html
ls: cannot access 'site/index.html': No such file or directory
What gets preserved
-a covers permissions, times, symlinks, owner and group. Hard links and a few other properties are separate flags, because each one requires a bit more effort to detect.
Lose the timestamps by recursing without -a
rsync -r site/ backup/
if [ "$(stat -c %Y site/index.html)" = "$(stat -c %Y backup/index.html)" ]; then
echo "timestamp preserved"
else
echo "timestamp not preserved"
fi
-r copies the tree and preserves none of the metadata that -a would. Every destination file is stamped with the time it was copied rather than the time it was written, so the next run has no stable timestamp to compare and re-sends more than it should.
Show output
timestamp not preserved
Keep them with -a
rsync -a site/ backup/
if [ "$(stat -c %Y site/index.html)" = "$(stat -c %Y backup/index.html)" ]; then
echo "timestamp preserved"
else
echo "timestamp not preserved"
fi
-a expands to -rlptgoD, and the t is what makes repeat runs cheap. Almost every rsync command wants -a for this reason alone.
Show output
timestamp preserved
Watch -r refuse a symlink
ln -s index.html site/home.html
touch -h -d "2026-01-05 09:00:00" site/home.html
rsync -r site/ backup/ 2>&1 | head -1
Without the l that -a includes, a symlink is neither followed nor recreated. It is skipped with a warning, so the destination is quietly missing a file.
Show output
skipping non-regular file "home.html"
Store a symlink as a symlink
ln -s index.html site/home.html
touch -h -d "2026-01-05 09:00:00" site/home.html
rsync -ai site/ backup/ | grep home
ls -l backup/home.html
-a includes -l, so the link is recreated pointing at the same target. cL+++++++++ marks a symlink being created. See hard vs symbolic links.
Show output
cL+++++++++ home.html -> index.html
lrwxrwxrwx 1 user user 10 Jan 5 2026 backup/home.html -> index.html
Follow a symlink and copy what it points at
ln -s index.html site/home.html
touch -h -d "2026-01-05 09:00:00" site/home.html
rsync -aiL site/ backup/ | grep home
ls -l backup/home.html
-L (--copy-links) resolves each symlink and sends the file instead, so the destination is standalone. The cost is that a link and its target are stored twice.
Show output
>f+++++++++ home.html
-rw-r--r-- 1 user user 14 Jan 5 2026 backup/home.html
See two hard links arrive as two files
rsync -a site/ backup/
ls -l backup/drafts/latest.html backup/drafts/v2.html
latest.html and v2.html are one inode in the source, shown by the link count of 2 in the fixture listing. A plain -a copies the contents twice and the count drops to 1, so the destination is larger than the source.
Show output
-rw-r--r-- 1 user user 23 Jan 5 2026 backup/drafts/latest.html
-rw-r--r-- 1 user user 23 Jan 5 2026 backup/drafts/v2.html
Preserve the hard link with -H
rsync -aH site/ backup/
ls -l backup/drafts/latest.html backup/drafts/v2.html
-H (--hard-links) detects that two paths share an inode and recreates the link rather than the bytes, so the count stays 2. It is not in -a because finding them means tracking every inode in the transfer.
Show output
-rw-r--r-- 2 user user 23 Jan 5 2026 backup/drafts/latest.html
-rw-r--r-- 2 user user 23 Jan 5 2026 backup/drafts/v2.html
Set the destination permissions as you copy
rsync -ai --chmod=F640 site/ backup/ | grep index
ls -l backup/index.html
--chmod overrides what -a would have preserved. F640 applies to files alone and D750 to directories. The two can be combined into a single flag as --chmod=F640,D750.
Show output
>f+++++++++ index.html
-rw-r----- 1 user user 14 Jan 5 2026 backup/index.html
Build a snapshot that shares unchanged files
rsync -a site/ snap1/
printf '<h1>Home v2</h1>\n' > site/index.html
touch -d "2026-02-01 09:00:00" site/index.html
rsync -ai --link-dest=../snap1 site/ snap2/ | grep -v '^cd'
ls -l snap2/about.html snap2/index.html
--link-dest compares against a previous snapshot and hard-links anything unchanged instead of copying it. about.html has a link count of 2 because it is the same inode as in snap1; index.html was edited, so it is a real copy. A directory of dated snapshots costs one full tree plus the changes.
Show output
created directory snap2
>f.st...... index.html
-rw-r--r-- 2 user user 15 Jan 5 2026 snap2/about.html
-rw-r--r-- 1 user user 17 Feb 1 2026 snap2/index.html
Across a network
A remote path is host:path, and everything above behaves identically once you write one. What changes is the direction marker in the itemised output, the transport flags, and the byte accounting, which is where the delta algorithm becomes visible. The copy files between machines recipe covers the workflow around these, including resuming an interrupted transfer.
Push a tree to another machine
rsync -ai site/ deb1:backup/
deb1:backup/ is a path on the far end, relative to that account's home directory. The first character is now <, meaning the file was sent away from here, where a local copy reports > for received.
Show output
created directory backup
cd+++++++++ ./
<f+++++++++ about.html
<f+++++++++ build.tmp
<f+++++++++ index.html
cd+++++++++ css/
<f+++++++++ css/main.css
cd+++++++++ drafts/
<f+++++++++ drafts/latest.html
<f+++++++++ drafts/release-notes.html
<f+++++++++ drafts/v2.html
cd+++++++++ img/
<f+++++++++ img/logo.png
Pull a tree from another machine
rsync -a site/ deb1:backup/
rsync -ai deb1:backup/ pulled/
Put the remote path first and the transfer runs the other way. The marker returns to >, so the first character tells you which of the two copies overwrote the other.
Show output
created directory pulled
cd+++++++++ ./
>f+++++++++ about.html
>f+++++++++ build.tmp
>f+++++++++ index.html
cd+++++++++ css/
>f+++++++++ css/main.css
cd+++++++++ drafts/
>f+++++++++ drafts/latest.html
>f+++++++++ drafts/release-notes.html
>f+++++++++ drafts/v2.html
cd+++++++++ img/
>f+++++++++ img/logo.png
See how much actually crossed the link
rsync -a --stats site/ deb1:backup/ | grep -E '^(Total file size|Total bytes sent|Total bytes received)'
On a first copy the bytes sent exceed the file size, since the protocol carries the file list and checksums as well as the contents. Comparing the two figures is how you tell whether a transfer is dominated by data or by overhead.
Show output
Your output will differ: the two byte totals differ by a few bytes between machines and between runs, so the figures below are one real capture rather than a constant; the file size is fixed by the fixture
Total file size: 2,193 bytes
Total bytes sent: 2,820
Total bytes received: 191
Reach a host on a non-default port
rsync -ai -e 'ssh -p 2222' site/index.html deb1:backup/
-e replaces the whole remote shell command, so anything ssh accepts goes here: a port, an identity file, a jump host. Quote it, since it contains a space.
Show output
created directory backup
<f+++++++++ index.html
Write to a directory the remote account cannot
rsync -ai --rsync-path='sudo rsync' site/index.html deb1:/opt/tips/
stat -c '%U %G %n' /opt/tips /opt/tips/index.html
--rsync-path sets the command run at the far end, so the receiving side is root while the connection stays an ordinary user's. The directory is created as root; the file keeps its source ownership, because -a asks for owner and group to be preserved but only a root receiver is able to set them.
Show output
created directory /opt/tips
<f+++++++++ index.html
root root /opt/tips
user user /opt/tips/index.html