ZFS snapshots, clones, rollback and promote
Take, list and diff ZFS snapshots, roll back past newer ones, clone a snapshot, promote the clone so the origin can be destroyed, and read the space columns.
What a ZFS snapshot is, and what clones and promote add
A ZFS snapshot is a read-only, point-in-time copy of a dataset that costs no copying and almost no space, because ZFS never overwrites a block in place: a write allocates a new block, and the snapshot keeps the old one alive. A clone is a writable dataset built on a snapshot, sharing every block until you change it. zfs promote reverses which of the two depends on the other, so the original dataset can be destroyed and the clone lives on. zfs rollback throws away everything written since a snapshot, and refuses to jump past a newer snapshot until you tell it to destroy that one too.
That is the whole lifecycle. The rest of this guide runs it on one dataset, tank/app, and explains the three error messages and the four space columns that confuse most people. If pools, vdevs and datasets are still new to you, start with how ZFS works on FreeBSD and Linux. Every zfs command below is identical on both, because the tool is the same OpenZFS code.
A note on the listings. They are illustrative: they follow the OpenZFS 2.2 output format, the release Ubuntu 24.04 ships as of September 2026, but they were not captured from a live run, so treat every size and date as an example. The error messages are a different matter. Each one is quoted from the source of the zfs command, so the text on your terminal will match.
A practice pool you can destroy
Commands that destroy things are easy to run against the wrong target, so learn them on a pool made from a file. A 4 GiB sparse file holds a 1 GiB test file plus every snapshot that follows.
sudo apt update && sudo apt install -y zfsutils-linux
zfs version
sudo truncate -s 4G /var/tmp/tank.img
sudo zpool create tank /var/tmp/tank.img
sudo zfs create tank/app
sudo dd if=/dev/urandom of=/tank/app/blob.bin bs=1M count=1024 status=none
zfs list tank/appzfs version prints two lines, one for the userland tools and one for the kernel module, and on a stock 24.04 install both start with zfs-2.2. If zpool create instead prints The ZFS modules are not loaded, the image you installed from lacks the module package: install the linux-modules-extra package that matches your uname -r, then run sudo modprobe zfs. The dd from /dev/urandom matters. Random data does not compress, so if compression is on for the pool the file still occupies a real gibibyte and the space numbers later mean what they appear to mean. zfs list tank/app should show USED and REFER at about 1.00G.
If you already have a pool called tank, substitute your own pool name everywhere below. When you are done, sudo zpool destroy tank followed by sudo rm /var/tmp/tank.img removes every trace, provided tank is this practice pool and not a real one.
Take a ZFS snapshot, and take one of every dataset at once
sudo zfs snapshot tank/app@before-upgrade
zfs list -t snapshot -r tank/appThe name after @ is yours to choose. Letters, digits, _, -, . and : are allowed, and a name that says why the snapshot exists is more useful than a timestamp. The listing looks like this:
NAME USED AVAIL REFER MOUNTPOINT
tank/app@before-upgrade 0B - 1.00G -USED is 0B because nothing has changed yet. The snapshot references the same gibibyte the live dataset does, which is what REFER shows, but not one of those blocks belongs to the snapshot alone. zfs list hides snapshots unless you ask with -t snapshot (or -t all), and -r tank/app limits the output to that dataset and its descendants instead of every snapshot in every pool on the box.
Snapshots are per dataset. A pool holding tank/app and tank/app/db needs two, and if you take them one at a time a write can land between them, so the pair no longer describes one instant. -r takes all of them in one transaction group, at the same moment, and destroys them the same way:
sudo zfs snapshot -r tank@nightly
zfs list -t snapshot -r tank -o name,used,refer,creation -s creation
sudo zfs destroy -r tank@nightly-o picks the columns and -s creation sorts oldest first, which is the order you want when deciding what to prune. Note what -r means here: recurse into child datasets. The same letter means something else on zfs rollback, and that difference causes most rollback surprises, so it gets its own section below.
Restore one file without rolling anything back
Every snapshot is browsable as a directory. Set snapdir=visible and the hidden .zfs directory shows up in ls -a at the root of the mounted dataset:
sudo zfs set snapdir=visible tank/app
ls -l /tank/app/.zfs/snapshot/before-upgrade/Copying a file out of /tank/app/.zfs/snapshot/before-upgrade/ with cp is the right fix when one config file or one table went wrong. Rollback is the right fix when the whole dataset is wrong, and it costs you everything written since. Reach for the directory first. The default snapdir=hidden only hides the name from ls; the path works either way, and the snapshot is mounted on demand the first time something opens it.
What changed since the snapshot: zfs diff
Before you roll back, look at what you are about to throw away.
sudo zfs diff -F tank/app@before-upgradeWith one snapshot named, zfs diff compares it against the live dataset. With two, zfs diff tank/app@a tank/app@b, it compares the two points in time. Illustrative output, in the format the man page documents:
M / /tank/app/
- F /tank/app/blob.bin
+ F /tank/app/config.new
M F /tank/app/app.db
R F /tank/app/old.log -> /tank/app/archive.logThe first column is the change: + created, - removed, M modified, R renamed. -F adds the second column, the file type, F for a regular file and / for a directory. -H drops the header and the arrows and separates fields with tabs, for scripts. The command finds changed objects by block birth time rather than by reading file contents, so it can tell you that app.db changed but not what changed inside it. It needs root or the delegated diff permission, which is why the example uses sudo where zfs list does not.
zfs rollback, and why -r is needed when newer snapshots exist
Take a second snapshot and break something, then try to go back to the first:
sudo zfs snapshot tank/app@after-upgrade
sudo rm /tank/app/blob.bin
sudo zfs rollback tank/app@before-upgradeThe rollback is refused:
cannot rollback to 'tank/app@before-upgrade': more recent snapshots or bookmarks exist
use '-r' to force deletion of the following snapshots and bookmarks:
tank/app@after-upgradeThe reason is structural. A dataset's snapshots form one straight line. Each snapshot points at the one before it, and the live dataset sits at the end of the line. Rollback moves the live dataset back to before-upgrade. A snapshot taken after that point would now describe a state that is no longer in the dataset's history, a branch off the line, and ZFS has no way to represent a branch inside one dataset. So the newer snapshots must go, and -r is you saying that you understand that. A bookmark, made with zfs bookmark, is a named marker at a snapshot's point in time that holds no blocks and exists so an incremental zfs send still has a starting point after the snapshot itself is gone; rollback treats a newer bookmark exactly like a newer snapshot.
sudo zfs rollback -r tank/app@before-upgrade
ls -l /tank/app/blob.bin
zfs list -t snapshot -r tank/appblob.bin is back and after-upgrade is gone. Two more flags exist. -R also destroys any clones made from the snapshots being removed, and -f force-unmounts those clones if something has them open. Neither flag descends into child datasets. A recursive snapshot like tank@nightly is rolled back one dataset at a time, tank/app@nightly and then tank/app/db@nightly, because rollback has no recursion at all.
Stop whatever writes to the dataset before you roll it back. The application does not learn that its files changed underneath it, and its next write lands on the restored state as if the discarded writes never happened. And if you want to keep the newer state rather than destroy it, do not roll back. Clone the old snapshot and promote the clone, which the next two sections cover.
zfs clone: a writable copy that costs nothing until you change it
sudo zfs clone tank/app@before-upgrade tank/app-next
zfs list -o name,origin,used,refer -r tankNAME ORIGIN USED REFER
tank - 1.00G 96K
tank/app - 1.00G 1.00G
tank/app-next tank/app@before-upgrade 0B 1.00GThe clone is mounted at /tank/app-next and is fully writable. It shares every block with tank/app@before-upgrade, so USED starts at zero and grows only with what you change. ORIGIN records the dependency: the clone cannot exist without that snapshot, so the snapshot cannot be destroyed while the clone exists.
Run the upgrade against /tank/app-next. If it fails, sudo zfs destroy tank/app-next removes the clone and nothing on tank/app was touched. If it works, you have two choices: repeat the upgrade on the real dataset, or make the clone the real dataset. The second path is what zfs promote is for.
One trap. A clone takes its properties from its place in the tree, not from the snapshot. A mountpoint or quota set by hand on tank/app is not carried over. zfs get -s local all tank/app lists everything set locally, so you can copy what matters before the clone goes live.
zfs promote: flip the dependency so the original dataset can go
Try to remove the original while the clone still depends on it:
sudo zfs destroy -r tank/appcannot destroy 'tank/app': filesystem has dependent clones
use '-R' to destroy the following datasets:
tank/app-next-R would take the clone with it, the opposite of what you want. zfs promote swaps the roles instead:
sudo zfs promote tank/app-next
zfs list -o name,origin,used,refer -r tank
zfs list -t snapshot -r tankNAME ORIGIN USED REFER
tank - 1.00G 96K
tank/app tank/app-next@before-upgrade 0B 1.00G
tank/app-next - 1.00G 1.00GNAME USED AVAIL REFER MOUNTPOINT
tank/app-next@before-upgrade 0B - 1.00G -The snapshot moved. tank/app@before-upgrade is now tank/app-next@before-upgrade, and every older snapshot of tank/app would have moved with it: promote hands the cloned snapshot, and everything before it, to the clone. Snapshots of tank/app taken after the clone point stay where they are. The gibibyte of USED moved with the snapshot, which is why the man page warns that the promoted side needs room for it. No new space is consumed; only the accounting changes. tank/app is now the clone, and tank/app-next depends on nothing.
Finish with two renames, so paths do not change for anything that uses the dataset:
sudo zfs rename tank/app tank/app-old
sudo zfs rename tank/app-next tank/app
zfs list -o name,origin,mountpoint -r tankNAME ORIGIN MOUNTPOINT
tank - /tank
tank/app - /tank/app
tank/app-old tank/app@before-upgrade /tank/app-oldThe snapshot followed the rename and is tank/app@before-upgrade again, now owned by the promoted dataset, and the old dataset is the clone. A rename remounts the dataset at its new inherited mountpoint, so it fails with cannot unmount '/tank/app': pool or dataset is busy if a process still has a file open there; fuser -vm /tank/app names the process.
Promote has one precondition: the clone must not have a snapshot with the same name as any snapshot that is about to move to it. If tank/app had an older @monday and you had also taken tank/app-next@monday, promote stops with cannot promote 'tank/app-next': conflicting snapshot 'monday' from parent 'tank/app'. Rename one of them, sudo zfs rename tank/app-next@monday tank/app-next@monday-clone, and promote again.
This is also the non-destructive rollback. Clone the snapshot you want to return to and promote the clone, then swap the dataset names, and the state you rolled away from is still there in tank/app-old, snapshots included, until you choose to destroy it.
Destroy ordering: what the three errors mean
Three messages, three different causes. Run them in this order on the practice pool and each one appears.
Snapshot has dependent clones. tank/app-old is a clone of tank/app@before-upgrade, so the snapshot cannot go first:
sudo zfs destroy tank/app@before-upgradecannot destroy 'tank/app@before-upgrade': snapshot has dependent clones
use '-R' to destroy the following datasets:
tank/app-oldDestroy the clone first, or promote it so the snapshot moves to it and the dependency disappears. -R destroys the clone along with the snapshot, which is rarely what you want.
Filesystem has children. A dataset with snapshots, or with child datasets, refuses a plain destroy:
sudo zfs snapshot tank/app-old@final
sudo zfs destroy tank/app-oldcannot destroy 'tank/app-old': filesystem has children
use '-r' to destroy the following datasets:
tank/app-old@finalThe list under the message is exactly what -r will remove. Read it, then run sudo zfs destroy -r tank/app-old. -r recurses into snapshots and child datasets under the target; -R goes further and also destroys clones that live elsewhere in the tree. A dry run with -nv prints the same list with would destroy in front of each name and touches nothing.
Dataset is busy. A hold is a named lock on a snapshot. Backup tools place one so their reference point cannot be deleted from under them, and zfs send places a temporary one with a tag beginning .send- for as long as the stream runs:
sudo zfs hold keep tank/app@before-upgrade
sudo zfs destroy tank/app@before-upgradecannot destroy snapshot tank/app@before-upgrade: dataset is busyzfs holds tank/app@before-upgrade lists every tag on the snapshot with the time it was placed. sudo zfs release keep tank/app@before-upgrade removes the hold, and the destroy then goes through. If zfs holds shows nothing, the usual remaining cause is a process with the snapshot's .zfs/snapshot/before-upgrade directory open, often a shell that changed into it, so ZFS cannot unmount the snapshot to destroy it. zfs destroy -d is the alternative when you cannot release the hold yourself: it marks the snapshot for deferred destruction and returns success, and the snapshot disappears on its own when the last hold or clone lets go. A busy filesystem, as opposed to a snapshot, reports cannot unmount '/tank/app': pool or dataset is busy instead, and fuser -vm finds the process the same way.
Space accounting: why deleting files frees nothing
tank/app now holds blob.bin again and no snapshots, after the release and destroy above. Take a snapshot and delete the file, then look at the numbers:
sudo zfs snapshot tank/app@keep
sudo rm /tank/app/blob.bin
zfs list -o space tank/app
zfs list -t snapshot -r tank/appNAME AVAIL USED USEDSNAP USEDDS USEDREFRESERV USEDCHILD
tank/app 2.80G 1.00G 1.00G 96K 0B 0BNAME USED AVAIL REFER MOUNTPOINT
tank/app@keep 1.00G - 1.00G -The file is gone from the directory and AVAIL did not move. The four columns explain it. used is everything the dataset is responsible for: its live data, plus every snapshot and child dataset under it. referenced, shown as REFER, is the data reachable from this dataset right now, 96K of directory metadata for the live dataset and the full gibibyte for the snapshot. usedbysnapshots, shown as USEDSNAP, is the space that would come back if every snapshot of this dataset were destroyed. usedbydataset, shown as USEDDS, is the live data that no snapshot shares.
rm removed the name and the live dataset's reference to the blocks. The snapshot still references them, and a block is freed only when nothing references it. So the gibibyte moved from USEDDS to USEDSNAP and USED did not change at all. The chart walks the same sequence in whole mebibytes, with the kilobytes of metadata rounded away:
The data behind this chart
[
{
"label": "1 GiB file written",
"used_mib": 1024,
"refer_mib": 1024,
"usedbysnapshots_mib": 0
},
{
"label": "Snapshot taken",
"used_mib": 1024,
"refer_mib": 1024,
"usedbysnapshots_mib": 0
},
{
"label": "File deleted",
"used_mib": 1024,
"refer_mib": 0,
"usedbysnapshots_mib": 1024
},
{
"label": "Snapshot destroyed",
"used_mib": 0,
"refer_mib": 0,
"usedbysnapshots_mib": 0
}
]After the delete, used still reads 1024 MiB while refer has dropped to 0 MiB and usedbysnapshots has risen to 1024 MiB. Only the last row, where the snapshot is destroyed, brings used down to 0 MiB.
A snapshot's own USED is the space unique to it: what destroying that one snapshot would free. This is the number that misleads people. Two snapshots that both hold the deleted blob each show close to zero USED, because destroying either one alone frees nothing while the other still references the blocks. USEDSNAP on the dataset shows the true total, and a dry run tells you what a range would reclaim:
sudo zfs destroy -nv tank/app@keepwould destroy tank/app@keep
would reclaim 1.00GFor several snapshots, sudo zfs destroy -nv tank/app@first%last prices an inclusive range in one line. When a pool is filling up, deleting "the biggest snapshot" by its USED column usually frees little; deleting a range frees what USEDSNAP promised. zfs list -t snapshot -o name,used,refer,written -r tank/app adds written, the bytes changed between one snapshot and the one before it, which is the honest measure of how much each interval cost. Destroy tank/app@keep for real and zfs list -o space tank/app shows USED back near 96K.
Where snapshots stop helping
Snapshots live in the same pool as the data. A failed disk, a mistyped zpool destroy, a provider wiping the VPS, or ransomware with root on the box takes every snapshot along with the live data. The off-box copy is zfs send, and the snapshot chain you have been managing here is exactly what makes it cheap: send @before-upgrade once, then each later snapshot as an increment, to a storage VPS running zfs receive. The snapshot your provider takes of the whole virtual disk is a different object again, and it is not a backup either, for reasons that are mostly about where it is stored. A snapshot also does nothing against silent corruption, since it references the same blocks the live data does; only a scrub on a schedule reads them all and checks them against their checksums.
Automate the taking and the pruning together. zfs-auto-snapshot, packaged in Ubuntu, and sanoid both create snapshots on a timer and destroy the ones that age out. A pool that only ever gains snapshots is a pool where USEDSNAP grows until AVAIL reaches zero, and at that point even rm cannot help, for the reason the previous section explained.
FAQ
Why does zfs rollback say more recent snapshots or bookmarks exist?
zfs rollback only returns a dataset to its newest snapshot unless you say otherwise. A dataset's snapshots form one straight line, and rolling back past a newer snapshot would leave that snapshot describing a state outside the dataset's history, which ZFS cannot represent. The message lists exactly what zfs rollback -r will destroy. If the newer state is worth keeping, clone the older snapshot and promote the clone with zfs promote, then swap the dataset names instead of rolling back.
Why does deleting files not free space on a ZFS dataset?
Because a snapshot still references the deleted blocks, and ZFS frees a block only when nothing references it. zfs list -o space shows the space moving from the USEDDS column to USEDSNAP while USED and AVAIL stay put. Destroy the snapshots that hold the blocks, checking first with zfs destroy -nv dataset@first%last, which prints how much a range would reclaim without deleting anything.
What does zfs promote actually do?
It reverses the dependency between a clone and the dataset it was cloned from. The snapshot the clone came from, and every older snapshot of the original, move to the clone, and the original becomes a clone of the promoted dataset. No data is copied and no new space is used; the used accounting moves along with the snapshots. After promoting, the original dataset can be destroyed or renamed out of the way, which is how you make a tested clone the live dataset.
What does "dataset is busy" mean when I destroy a ZFS snapshot?
Something holds the snapshot. Most often it is a zfs hold, placed by a backup tool or by a running zfs send, and zfs holds dataset@snapshot lists the tag so you can zfs release it. If no hold is listed, a process has the snapshot's .zfs/snapshot directory open and ZFS cannot unmount it. zfs destroy -d marks the snapshot for deferred destruction so it goes away by itself once the hold or clone is released.
Is a ZFS snapshot a backup?
No. It lives in the same pool as the data, so a lost disk or a destroyed pool takes the snapshots too. It is the fastest undo you can have on one machine and the input to a real backup: zfs send streams a snapshot, or the difference between two snapshots, to another machine, where zfs receive rebuilds the dataset with its snapshot history intact.