The usual failure when moving two large directories is not “forgot a flag.” It is stuffing both paths into one command, or wiping the source disk because the terminal printed 100%.
Safer: map each source to its own destination, --dry-run first, keep partial files and a log on the real run, then do a read-only compare. An empty compare only means size, mtime, and the attributes you asked for match. Content equality needs --checksum.
This is a verified rewrite of KnightLi’s rsync guide. Flags and exit codes were checked against the rsync manual and upstream errcode.h.
The job:
/mnt/disk1/photos/ → /mnt/disk2/photos/
/mnt/disk1/archive/ → /mnt/disk2/archive/
Don’t put both trees in one command
The last positional argument is the destination; everything before it is a source:
rsync -a /source/a/ /source/b/ /target/
That merges both sources into /target/. It does not mean a → target/a and b → target/b. Two destinations means two runs, or a serial script.
Separate runs also give you independent logs and exit statuses, and sequential I/O on a spinning disk. If both sources really must land in one tree, watch for colliding relative paths—two 2026/report.pdf files and the later run wins.
Confirm the mount first
A dangerous failure: the destination disk never mounted, but the path still exists. rsync then writes into a normal directory on the root filesystem until / fills up.
lsblk -o NAME,SIZE,FSTYPE,UUID,MOUNTPOINTS
findmnt -T /mnt/disk1/photos
findmnt -T /mnt/disk2/photos
findmnt -T /mnt/disk1/archive
findmnt -T /mnt/disk2/archive
df -hT /mnt/disk1 /mnt/disk2
readlink -f "$SRC1" "$DST1" "$SRC2" "$DST2"
Check: expected devices, enough space, not read-only, not two names for the same filesystem, destination not inside the source. Do a tiny write test and delete only the file you just created.
Trailing slashes change the layout
rsync -a /source/ /target/
rsync -a /source /target/
The first copies contents of /source/. The second creates target/source/. For two already-matching trees, put / on both sides. Use --dry-run --itemize-changes before the first real run.
A command you can actually run
SRC1=/mnt/disk1/photos
DST1=/mnt/disk2/photos
SRC2=/mnt/disk1/archive
DST2=/mnt/disk2/archive
sudo mkdir -p "$DST1" "$DST2"
Dry-run:
sudo rsync -aHAXn \
--numeric-ids \
--itemize-changes \
"$SRC1/" \
"$DST1/"
If paths and the change list look right, drop -n. Skip --delete on the first migration so extra files on the destination stay until you have verified structure and content.
sudo rsync -aHAX \
--numeric-ids \
--partial \
--partial-dir=.rsync-partial \
--info=progress2,stats2 \
--log-file=/var/log/rsync-photos.log \
"$SRC1/" \
"$DST1/"
Repeat for the second pair with its own log. Run them serially; two jobs on the same HDD mostly fight over the actuator.
What the flags actually do
Base set: -aHAX.
| Flag | Role | Caveat |
|---|---|---|
-a | Archive: recurse and keep common attrs | Does not include hard links, ACLs, xattrs |
-H | Hard links | Lots of them cost RAM |
-A | POSIX ACLs | Destination must support ACLs |
-X | Extended attributes | Destination must support xattr |
--numeric-ids | Keep numeric UID/GID | Right for Linux disk moves |
--partial | Keep unfinished files | Next run continues |
--partial-dir=.rsync-partial | Park partials in a dir | Don’t delete until verify is done |
--info=progress2 | Whole-job progress | Better than --progress for huge trees |
--log-file | Detailed log | Log directory must be writable |
-a is -rlptgoD—the manual is explicit. FAT, exFAT, and some network filesystems cannot keep owners, ACLs, or xattrs. More flags are not automatically safer. Check both filesystems with findmnt -no FSTYPE -T .... Drop -HAX if the data never had those features.
What “incremental” copies
By default rsync uses size and mtime. On a rerun: new files copy, changed files update, unchanged files skip, destination-only files stay (without --delete), interrupted files resume.
This is not a snapshot. If the source keeps changing, the destination can mix timestamps. Photo libraries can get a second incremental pass; databases, VM images, and live app data should be quiesced or snapshotted first.
--itemize-changes in short:
>f+++++++++— missing on dest, will create>fplus attribute letters — content or attrs will update.d— directory attrs only*deleting— extra dest paths, only with--delete
Unexpected top-level names usually mean a missing trailing /. Mass retransfers usually mean timestamp precision, mount options, or files still being rewritten.
Long jobs belong in tmux
Terabytes take hours. Don’t rely on an SSH window:
tmux new -s rsync-copy
# Ctrl+b, then d to detach
tmux attach -t rsync-copy
In a wrapper script, set -Eeuo pipefail so the second tree never runs after the first failed. Rerunning the same command is resume; you rarely need a special “continue” flag if --partial / --partial-dir were on.
Then echo $?. 0 means this run reported no errors—not that every byte was checksummed. Exit codes match upstream errcode.h:
| Code | Meaning |
|---|---|
| 11 | File I/O error |
| 12 | Protocol data-stream error |
| 23 | Partial transfer |
| 24 | Source files vanished |
| 30 | Send/receive timeout |
IO error, Permission denied, failed, or vanished file in the log still matter even if the last line shows total bytes.
Three verification layers
Layer 1 — dry-run again with --delete (still -n, so nothing is deleted):
sudo rsync -aHAXn \
--numeric-ids \
--delete \
--itemize-changes \
"$SRC1/" \
"$DST1/"
Empty output usually means nothing to add or update, no extra dest paths, and requested attrs match. If you only see .rsync-partial/, finish or inspect leftovers. *deleting means extra dest files—decide before dropping -n.
Layer 2 — add -c (--checksum). rsync reads both sides and uses a checksum to decide whether to transfer. With -n it only reports. This is rsync’s transfer-decision checksum (negotiated; often MD5 or xxHash), not a SHA-256 audit list. On multi-TB trees it rereads both disks; don’t hammer a dying drive.
Layer 3 — compare file counts and logical byte sums with find -xdev. Do not treat du -sh as proof: sparse files, compression, hard links, and block size all diverge.
For an auditable content list, SHA-256 both trees by relative path and diff. That covers regular-file content and paths only—not permissions, ACLs, hard links, or xattrs. Pair it with rsync -aHAXnc for a full Linux move.
Leave --delete for last
--delete mirrors the destination to the source. Dry-run first: paths not swapped, mount online, every *deleting expected, nothing unique on dest you still need, another recoverable copy exists. --backup --backup-dir=... if you want deleted files recoverable; keep that dir outside the source tree.
NAS junk (#recycle/, @eaDir/, .Trash-*/) can be excluded. Quote '#recycle/' or the shell eats # as a comment. Use the same excludes on copy and verify.
Local disk to local disk usually does not need -z. Photos, video, and archives won’t compress; you just burn CPU. On a live server, nice / ionice; over the network, --bwlimit (modern rsync accepts suffixes like 80M).
Typical failures
- Permission denied — read source, write dest, or set owner/ACL/xattr. Don’t
chmod -R 777the whole dest. - No space left — check
df -hTanddf -i; tiny files exhaust inodes first. - Input/output error —
dmesgand SMART immediately. Protect remaining readable data; don’t full-scan a failing disk. - file has vanished (24) — source moved or deleted between scan and copy. Quiesce or copy from a snapshot.
- Everything transfers again — timestamp precision, clocks, mount options, or files re-packed.
--itemize-changesshows whether content, size, time, or perms triggered it.
When you may delete the source
Not before: both rsyncs exited 0, logs have no unresolved I/O / permission / space errors, ordinary dry-runs are clean, important data passed -nc or independent SHA-256, and key files open from the destination. A restore drill is better.
Copy finished, verify passed, and restore works are three different stages. 100% on the progress bar is not permission to unmount the source.