Skip to content
daniel@cosenza:~/blog
FreeBSDDeep Dive April 20, 2026 5 min read

ZFS on FreeBSD: Pools, Datasets, and Snapshots Explained

How ZFS's storage pools, datasets, and copy-on-write snapshots fit together on FreeBSD, with the commands you'll actually use day to day.

ZFS has been a first-class citizen on FreeBSD since version 7, and it’s the default root filesystem offered by the installer. Unlike traditional Unix storage stacks — where a volume manager, a RAID layer, and a filesystem are three separate pieces of software that barely know about each other — ZFS collapses all three into a single, consistent system. That integration is precisely why its vocabulary (pools, datasets, vdevs, ARC) doesn’t map cleanly onto UFS or ext4 concepts, and why it’s worth understanding from the ground up.

The storage pool: your pool of raw devices

A zpool is the layer that turns physical disks into usable storage. You give it one or more vdevs (virtual devices), and it presents a single pool of space to everything above it.

# A pool built from a single mirror vdev
zpool create tank mirror /dev/da0 /dev/da1

# Check pool health
zpool status tank

A vdev can be a single disk, a mirror, or a RAID-Z group (ZFS’s own parity scheme, avoiding the RAID-5 “write hole”). Pools can contain multiple vdevs, and ZFS stripes data across them — which is also why redundancy is chosen per vdev, not per pool: losing an entire non-redundant vdev takes down the whole pool, even if other vdevs in it are mirrors.

# Add a second, independent mirror vdev to an existing pool
zpool add tank mirror /dev/da2 /dev/da3

Datasets: filesystems that share a pool

Where a traditional setup might carve fixed-size partitions out of a disk, ZFS instead creates datasets — lightweight filesystems that all draw from the same shared pool of free space. There’s no need to pre-decide how large /var/log should be relative to /home; both just consume from tank as needed.

zfs create tank/home
zfs create tank/var
zfs create -o compression=zstd tank/var/log

Because datasets are cheap, FreeBSD’s default installation layout creates dozens of them (zroot/ROOT/default, zroot/usr/home, zroot/var/log, and so on) instead of one big root filesystem. Each dataset can have its own properties — compression, quotas, atime behavior, mount point — set independently:

zfs set quota=20G tank/home
zfs set atime=off tank/var/log
zfs get compression,quota tank/home

Properties are inherited down the dataset hierarchy unless explicitly overridden, so setting compression=zstd on tank applies to every child dataset created afterward unless one of them sets its own value.

Snapshots: free, instant, and read-only

Because ZFS is copy-on-write, taking a snapshot doesn’t copy any data — it simply freezes the current tree of block pointers and stops reclaiming the blocks they reference. The operation is effectively instantaneous regardless of dataset size.

zfs snapshot tank/home@before-upgrade

# List snapshots
zfs list -t snapshot

# Roll the live dataset back to the snapshot
zfs rollback tank/home@before-upgrade

A snapshot only “costs” space incrementally, as the live dataset diverges from it — every block the snapshot pins that the live filesystem later overwrites becomes space that can’t be freed until the snapshot itself is destroyed. zfs list -o space breaks down exactly how much space is USED, USEDSNAP, and USEDDS per dataset, which is the first place to look when a pool seems fuller than expected.

Clones: writable snapshots

A clone is a writable filesystem built directly on top of a snapshot. It starts out identical to the snapshot and only consumes additional space as it diverges — useful for spinning up a disposable copy of a dataset (a database, a jail template) without duplicating its contents up front.

zfs clone tank/home@before-upgrade tank/home-test

The one constraint: a snapshot can’t be destroyed while a clone depends on it, since the clone’s data is defined as a diff against that snapshot. zfs promote can invert this dependency if you later decide the clone should become the “real” dataset and the original should depend on it instead.

Send and receive: replication without rsync

zfs send serializes a snapshot (or the incremental delta between two snapshots) into a byte stream; zfs receive reconstructs it on the other end. This is how ZFS handles backups and replication — at the block level, not by walking the filesystem tree.

# Full send to a local backup pool
zfs send tank/home@weekly-01 | zfs receive backup/home

# Incremental send: only the blocks changed since the last snapshot
zfs send -i tank/home@weekly-01 tank/home@weekly-02 | zfs receive backup/home

Piped over ssh, the same mechanism replicates to a remote host, and because it operates on ZFS’s internal representation rather than comparing file trees, it’s dramatically faster than rsync for large datasets with small deltas.

The ARC: ZFS’s own cache

ZFS maintains its own Adaptive Replacement Cache (ARC) in RAM, separate from the FreeBSD buffer cache. top and vmstat won’t tell the whole story about memory usage on a ZFS system — sysctl kstat.zfs.misc.arcstats (or the friendlier arc_summary from the sysutils/zfs-stats port) shows how much RAM the ARC is currently holding and its hit ratio. This is expected behavior, not a leak: ZFS is designed to use available RAM aggressively for caching and release it under memory pressure via vfs.zfs.arc_max.

A sane starting layout

For a general-purpose FreeBSD server, a reasonable baseline is: mirror or RAID-Z for the pool’s redundancy, lz4 or zstd compression pool-wide (it usually improves throughput since less data hits disk), separate datasets for anything you want independent snapshot/quota policy on, and a cron job taking periodic snapshots pruned by age. ZFS gives you the primitives; the discipline of naming snapshots consistently and pruning them is what makes the system useful in practice rather than just impressive in theory.