If you’re already on ZFS, whether that’s TrueNAS, a Proxmox host, or a DIY OpenZFS box, the obvious tool for moving a data hoard to a second machine is rsync. It works, but it has a cost that gets worse as the hoard grows: every run has to walk the entire directory tree, stat every file, and figure out what changed before it transfers a single byte. On a pool with a few hundred thousand media files, that walk alone can take longer than the actual transfer. ZFS has a better answer built in, and it’s been there the whole time: zfs send and zfs receive.

Why this is a different kind of replication

rsync compares files. zfs send compares snapshots. That distinction is the whole reason it’s faster and the whole reason the mental model is different from anything file-based.

Every ZFS dataset’s data lives as a tree of blocks, and a snapshot is just a frozen pointer into that tree at a point in time. When you ask ZFS to send the difference between two snapshots, it isn’t scanning files to see what’s different, it’s walking its own block pointers to find exactly which blocks changed between the two frozen states, and streaming only those blocks. No stat() calls on a million small files, no directory walk, no guessing. It knows precisely what changed because it’s the thing that changed it.

That means the first send of a dataset (a full send) still has to transfer everything, there’s no way around that, but every send after it can be an incremental send: just the delta between the last snapshot the receiving side has and a new one you just took. On a media library where 99% of the files never change, an incremental send after the first one can be remarkably small and fast, regardless of how large the overall pool has gotten.

The basic shape of it

Three pieces, always:

  1. A snapshot on the source. zfs snapshot tank/media@2026-10-11 freezes the dataset’s current state under that name.
  2. zfs send, pointed at that snapshot, which produces a stream of the dataset’s data as a sequence of bytes, not a human-readable format, just a stream meant for zfs receive on the other end.
  3. zfs receive, which reconstructs the dataset (or updates an existing one) from that stream on the destination pool.

Locally, piping one straight into the other looks like this:

zfs snapshot tank/media@2026-10-11
zfs send tank/media@2026-10-11 | zfs receive backup/media

That’s a full send, useful for the very first copy or for moving a dataset to a brand-new destination pool. Once backup/media exists and has at least one snapshot in common with the source, every later send can be incremental instead:

zfs snapshot tank/media@2026-10-12
zfs send -i tank/media@2026-10-11 tank/media@2026-10-12 | zfs receive backup/media

The -i flag tells zfs send to only stream the blocks that changed between the two named snapshots. The receiving side fast-forwards backup/media to match the newer snapshot, using a fraction of the time and bandwidth a full resend would need.

Doing it over the network

Nothing above assumes both pools live in the same box. Pipe the stream through SSH instead of straight into a local zfs receive, and you’ve got replication to a second machine, which is the actual use case most data hoarders care about: a primary NAS and an off-site or at-least-different-room backup box.

zfs send -i tank/media@2026-10-11 tank/media@2026-10-12 | \
  ssh backup-host "zfs receive backup/media"

This is genuinely efficient over a slow link, since only the changed blocks cross the wire, SSH just adds encryption and a transport, not meaningful overhead on top of what the send stream already contains. For truly bandwidth-constrained off-site links, this is a real alternative to the cloud cold-storage providers covered in the cloud cold storage guide, if the “off-site” box is a second machine at a family member’s house on a residential connection rather than an object storage bucket.

Raw sends for encrypted datasets

If you’re running ZFS native encryption, covered in the encrypting your data hoard guide on this site, there’s a specific flag worth knowing: zfs send -w (raw send). A normal send of an encrypted dataset decrypts the data stream-side so the receiving pool can store it as plain ZFS blocks again. A raw send transmits the encrypted blocks exactly as they sit on disk, never decrypting them in transit or requiring the receiving side to hold the key at all.

That property matters specifically for an off-site replication target you don’t fully trust with physical access, a backup box at a friend’s place, a drive that leaves your control, anywhere the destination machine itself could be compromised or seized without your encryption key going with it. The destination holds real encrypted blocks it’s mathematically unable to read. Nothing else covered elsewhere on this site (rsync, Restic, Borg) gives you that property cleanly, since all of them need to see plaintext at some point in the pipeline unless you add a separate encryption layer around the whole backup.

Don’t hand-roll the snapshot bookkeeping, use syncoid

Everything above works, but doing it by hand means tracking which snapshot pair you last sent, pruning old snapshots on both ends so they don’t pile up forever, and handling the inevitable day a send gets interrupted halfway through. Nobody running this long-term should actually write that logic themselves. Sanoid and syncoid (same project, two tools) are the de facto standard here: sanoid handles automated snapshot creation and retention policy (hourly snapshots kept for a day, daily kept for a month, that kind of scheme), and syncoid wraps the whole send/receive dance, automatically finding the latest common snapshot, falling back to a full send when there isn’t one, and resuming cleanly after a failure. A single cron job calling syncoid tank/media backup-host:backup/media on a schedule replaces all of the manual bookkeeping above. zrepl is the other common option, Go-based instead of shell-based, with a more structured YAML config if you’re replicating many datasets with different retention rules and want that centrally defined rather than scripted per-dataset.

The gotcha that actually bites people

zfs receive refuses to apply an incremental stream if the destination dataset has been modified since its last matching snapshot, including something as small as someone browsing the replicated copy and it picking up an access-time-driven change, or a scheduled task touching a file on the destination by accident. The fix is either to never write to the destination dataset directly (treat it as pure replication target, read-only in practice even if not enforced at the filesystem level), or to pass -F to force a rollback of the destination to its last snapshot before applying the new one, which discards any divergent changes on the destination. Know which behavior you want before you need it: -F is the right call for a dedicated replication target, and the wrong call if you ever use the destination dataset for anything else.

What this doesn’t replace

Replication is not backup, the same caveat that applies to RAID and applies again here. If backup/media is kept in lockstep with tank/media on every sync, a ransomware event or an accidental rm -rf on the source replicates faithfully to the destination on the very next send. The thing that actually protects you is snapshot retention on the destination, keeping a window of older snapshots around (which is exactly what sanoid’s retention policy is for) so you can roll back to a point before the bad event, not just mirror the current state. Pair this with the independent, different-media leg of the 3-2-1 backup strategy rather than treating a single replicated ZFS target as the whole backup story. ZFS send/receive is the most efficient way to move a ZFS-based data hoard between boxes that exists today. It’s just one leg of the stool, not the whole stool.