How to Set Up ZFS on FreeBSD: Complete Guide 2026
ZFS is the single most compelling reason to run FreeBSD as a storage platform. While Linux treats ZFS as a third-party add-on constrained by licensing disputes, FreeBSD ships ZFS in its base system, integrated into the kernel, tested in the release cycle, and supported by the core project. If you are building a server that manages data -- a NAS, a backup target, a database host, a virtualization node -- FreeBSD with ZFS is the production-grade choice.
This guide covers everything from your first pool to advanced performance tuning. Every command is real. Every output block reflects what you will actually see on a FreeBSD 14.x system running OpenZFS 2.2.x.
Table of Contents
- Why ZFS on FreeBSD
- ZFS Concepts
- Creating Your First Pool
- Dataset Management
- Snapshots and Clones
- ZFS Send/Receive
- Boot Environments with bectl
- Performance Tuning
- Monitoring ZFS Health
- Common Maintenance
- Best Practices and Gotchas
- FAQ
Why ZFS on FreeBSD
ZFS arrived on FreeBSD in 2007 with version 7.0 and has been a first-class citizen ever since. FreeBSD was the first operating system outside of Solaris to ship ZFS, and the project has maintained that integration for nearly two decades.
Here is what that means in practice:
- Kernel-native integration. ZFS ships as a loadable kernel module in the FreeBSD base system. No DKMS. No out-of-tree builds. No license workarounds. It is built, tested, and released alongside the kernel.
- Boot from ZFS. FreeBSD supports root-on-ZFS out of the box. The installer offers it as a default option. Boot environments let you snapshot your OS before upgrades and roll back in seconds.
- OpenZFS upstream alignment. FreeBSD tracks the OpenZFS project directly. Features like block cloning, dRAID, and the latest compression algorithms land in FreeBSD shortly after upstream merges them.
- Mature tooling.
bectlfor boot environments,zfsdfor automated drive replacement, and deep integration withpoudriere,iocage, andbhyvemake ZFS the backbone of the FreeBSD ecosystem.
If you are building a FreeBSD NAS, ZFS is not optional -- it is the entire point.
ZFS Concepts
Before touching a single command, you need a mental model of how ZFS organizes storage.
Pools and Vdevs
A pool (zpool) is the top-level storage container. A pool is made of one or more vdevs (virtual devices). Each vdev provides a specific redundancy level:
| Vdev Type | Min Disks | Redundancy | Usable Capacity (4 disks) |
|-----------|-----------|------------|---------------------------|
| stripe | 1 | None | 4x |
| mirror | 2 | N-1 disks | 1x (2-way) or 1x (4-way) |
| raidz1 | 3 | 1 disk | 3x |
| raidz2 | 4 | 2 disks | 2x |
| raidz3 | 5 | 3 disks | 1x (with 4) |
A pool can contain multiple vdevs. Data is striped across vdevs. If a single vdev is lost entirely, the pool is lost. This is the most critical thing to understand: redundancy is per-vdev, not per-pool.
Datasets
A dataset is a filesystem within a pool. Datasets share pool capacity but can have individual properties: compression, quotas, record size, mount points. Datasets are cheap -- create as many as you need for organizational separation.
Properties
Every dataset and pool has properties that control behavior. Properties are inherited by child datasets unless explicitly overridden. Key properties include compression, atime, recordsize, quota, reservation, copies, and dedup.
ARC (Adaptive Replacement Cache)
The ARC is ZFS's read cache, held in RAM. It is the single most important performance factor. ZFS uses a sophisticated eviction algorithm that balances recently-used and frequently-used data. More RAM means a larger ARC, which means fewer disk reads.
Checksums and Self-Healing
Every block in ZFS is checksummed. On redundant vdevs (mirror, raidz), ZFS automatically repairs corrupted blocks by reading the correct copy and rewriting the damaged one. This happens transparently during normal reads and during scrubs.
Creating Your First Pool
Prerequisites
Enable ZFS at boot by adding this to /etc/rc.conf:
shell# sysrc zfs_enable="YES" zfs_enable: -> YES
Load the ZFS kernel module immediately (if not already loaded):
shell# kldload zfs
Verify it is loaded:
shell# kldstat | grep zfs 6 1 0xffffffff8284a000 5d8b78 zfs.ko
Identify Your Disks
List available disks with geom:
shell# geom disk list | grep -E "Geom name|Mediasize|descr" Geom name: ada0 Mediasize: 256060514304 (238G) descr: Samsung SSD 870 EVO 250GB Geom name: ada1 Mediasize: 2000398934016 (1.8T) descr: WDC WD2003FZEX-00SRLA0 Geom name: ada2 Mediasize: 2000398934016 (1.8T) descr: WDC WD2003FZEX-00SRLA0 Geom name: ada3 Mediasize: 2000398934016 (1.8T) descr: WDC WD2003FZEX-00SRLA0 Geom name: ada4 Mediasize: 2000398934016 (1.8T) descr: WDC WD2003FZEX-00SRLA0
In this example, ada0 is the boot SSD. ada1 through ada4 are four identical 2TB drives for the storage pool.
Mirror Pool (2 Disks)
A mirror is the simplest redundant configuration. Two disks, one copy of your data on each:
shell# zpool create tank mirror /dev/ada1 /dev/ada2 # zpool status tank pool: tank state: ONLINE config: NAME STATE READ WRITE CKSUM tank ONLINE 0 0 0 mirror-0 ONLINE 0 0 0 ada1 ONLINE 0 0 0 ada2 ONLINE 0 0 0 errors: No known data errors
You lose half your raw capacity but gain the best read performance and the fastest resilver times of any redundant layout.
RAIDZ1 Pool (3+ Disks)
RAIDZ1 tolerates one disk failure. With four disks, you get three disks of usable capacity:
shell# zpool create storage raidz1 /dev/ada1 /dev/ada2 /dev/ada3 /dev/ada4 # zpool status storage pool: storage state: ONLINE config: NAME STATE READ WRITE CKSUM storage ONLINE 0 0 0 raidz1-0 ONLINE 0 0 0 ada1 ONLINE 0 0 0 ada2 ONLINE 0 0 0 ada3 ONLINE 0 0 0 ada4 ONLINE 0 0 0 errors: No known data errors
RAIDZ2 Pool (4+ Disks)
RAIDZ2 tolerates two simultaneous disk failures. With four disks, you get two disks of usable capacity. This is the recommended minimum for drives larger than 2TB, where resilver times make a second failure during rebuild a real risk:
shell# zpool create vault raidz2 /dev/ada1 /dev/ada2 /dev/ada3 /dev/ada4 # zpool list vault NAME SIZE ALLOC FREE CKPOINT EXPANDSZ FRAG CAP DEDUP HEALTH ALTROOT vault 7.27T 564K 7.27T - - 0% 0% 1.00x ONLINE -
Stripe Pool (No Redundancy)
A stripe uses all disk capacity with zero redundancy. Only use this for scratch space or data that is fully backed up elsewhere:
shell# zpool create scratch /dev/ada1 /dev/ada2
Using GPT Labels (Recommended)
Using raw device names (ada1, ada2) works but ties your pool to specific SATA port assignments. GPT labels survive controller changes and cable swaps:
shell# gpart create -s gpt ada1 # gpart add -t freebsd-zfs -l disk1 ada1 # gpart create -s gpt ada2 # gpart add -t freebsd-zfs -l disk2 ada2 # zpool create tank mirror /dev/gpt/disk1 /dev/gpt/disk2
This is the production approach. Always label your disks.
Dataset Management
Creating Datasets
Create a dataset hierarchy for a file server:
shell# zfs create tank/data # zfs create tank/data/documents # zfs create tank/data/media # zfs create tank/data/backups # zfs list -r tank NAME USED AVAIL REFER MOUNTPOINT tank 444K 1.76T 96K /tank tank/data 96K 1.76T 96K /tank/data tank/data/backups 96K 1.76T 96K /tank/data/backups tank/data/documents 96K 1.76T 96K /tank/data/documents tank/data/media 96K 1.76T 96K /tank/data/media
Each dataset is automatically mounted at a path matching its name under the pool mountpoint. You can override this with the mountpoint property.
Setting Properties
Set compression on the parent dataset -- children inherit it:
shell# zfs set compression=lz4 tank/data # zfs get compression tank/data/documents NAME PROPERTY VALUE SOURCE tank/data/documents compression lz4 inherited from tank/data
For media files that are already compressed (video, JPEG, MP3), use zstd or disable compression:
shell# zfs set compression=off tank/data/media
Disable access time updates globally (reduces write amplification significantly):
shell# zfs set atime=off tank
Quotas and Reservations
Set a hard quota on a dataset to prevent it from consuming more than its share:
shell# zfs set quota=500G tank/data/media # zfs set quota=100G tank/data/documents
Set a reservation to guarantee minimum available space:
shell# zfs set reservation=200G tank/data/backups
Check quota usage:
shell# zfs get quota,used,available tank/data/media NAME PROPERTY VALUE SOURCE tank/data/media quota 500G local tank/data/media used 12.3G - tank/data/media available 487G -
Setting Record Size Per Workload
The default record size is 128K, which is good for sequential I/O (large files, streaming). For databases and VMs, smaller record sizes reduce write amplification:
shell# zfs create -o recordsize=16K tank/data/postgresql # zfs create -o recordsize=64K tank/data/vms
For large sequential workloads like video editing or backups:
shell# zfs create -o recordsize=1M tank/data/backups
Changing Mount Points
shell# zfs set mountpoint=/srv/media tank/data/media # zfs mount tank/data/media # df -h /srv/media Filesystem Size Used Avail Capacity Mounted on tank/data/media 500G 12G 487G 3% /srv/media
Snapshots and Clones
Snapshots are the killer feature that makes ZFS invaluable for any FreeBSD backup strategy. A snapshot is a point-in-time, read-only copy of a dataset. It is instantaneous, costs no additional space at creation time, and only grows as the active dataset diverges.
Creating Snapshots
shell# zfs snapshot tank/data/documents@2026-03-29 # zfs list -t snapshot NAME USED AVAIL REFER MOUNTPOINT tank/data/documents@2026-03-29 0 - 2.1G -
Create recursive snapshots across all child datasets:
shell# zfs snapshot -r tank/data@daily-2026-03-29 # zfs list -t snapshot -r tank/data NAME USED AVAIL REFER MOUNTPOINT tank/data@daily-2026-03-29 0 - 96K - tank/data/backups@daily-2026-03-29 0 - 156G - tank/data/documents@daily-2026-03-29 0 - 2.1G - tank/data/media@daily-2026-03-29 0 - 12.3G -
Browsing Snapshot Contents
Every dataset has a hidden .zfs/snapshot directory:
shell# ls /tank/data/documents/.zfs/snapshot/ 2026-03-29 daily-2026-03-29 # ls /tank/data/documents/.zfs/snapshot/2026-03-29/ contracts/ invoices/ proposals/ reports/
You can copy individual files out of a snapshot without performing a full rollback.
Comparing Snapshots (Diff)
See what changed between a snapshot and the live dataset:
shell# zfs diff tank/data/documents@2026-03-29 M /tank/data/documents/reports/ + /tank/data/documents/reports/q1-summary.pdf M /tank/data/documents/contracts/ - /tank/data/documents/contracts/old-vendor.pdf
M means modified, + means added, - means removed.
Rolling Back
Rollback discards all changes since the snapshot and restores the dataset to that exact state:
shell# zfs rollback tank/data/documents@2026-03-29
If intermediate snapshots exist between the target and the current state, you must use -r to destroy them:
shell# zfs rollback -r tank/data/documents@2026-03-29
Warning: Rollback is destructive. All data written after the snapshot is lost. Take a fresh snapshot before rolling back if you want to preserve the current state.
Clones
A clone creates a writable copy of a snapshot. It is instantaneous and shares blocks with the original:
shell# zfs clone tank/data/documents@2026-03-29 tank/data/documents-test # zfs list tank/data/documents-test NAME USED AVAIL REFER MOUNTPOINT tank/data/documents-test 18K 1.76T 2.1G /tank/data/documents-test
Clones are useful for testing changes before applying them to production data, or for creating development environments from production snapshots.
Destroying Snapshots
Remove a single snapshot:
shell# zfs destroy tank/data/documents@2026-03-29
Remove all snapshots matching a pattern:
shell# zfs destroy tank/data/documents@daily-%
Automating Snapshot Rotation
Install zfs-auto-snapshot from ports or use a cron job. A simple approach with cron:
shell# crontab -e
Add:
shell15 * * * * /sbin/zfs snapshot -r tank/data@hourly-$(date +\%Y\%m\%d-\%H) 0 0 * * * /sbin/zfs snapshot -r tank/data@daily-$(date +\%Y\%m\%d) 0 0 * * 0 /sbin/zfs snapshot -r tank/data@weekly-$(date +\%Y\%m\%d)
Then a cleanup script to remove old snapshots (keep 24 hourly, 30 daily, 12 weekly).
ZFS Send/Receive
ZFS send/receive is the native replication mechanism. It serializes a snapshot (or the difference between two snapshots) into a byte stream that can be piped to another pool, another machine, or a file.
Local Replication
Send a snapshot to a different pool on the same machine:
shell# zfs send tank/data/documents@2026-03-29 | zfs receive backup/documents
Incremental Send
After the initial full send, incremental sends transfer only the changes between two snapshots:
shell# zfs snapshot tank/data/documents@2026-03-30 # zfs send -i tank/data/documents@2026-03-29 tank/data/documents@2026-03-30 | \ zfs receive backup/documents
The -i flag specifies the incremental source. This is dramatically faster and smaller than a full send.
Remote Replication via SSH
Send to a remote FreeBSD host:
shell# zfs send tank/data/documents@2026-03-29 | \ ssh backup-server zfs receive offsite/documents
Incremental remote replication:
shell# zfs send -i tank/data/documents@2026-03-29 tank/data/documents@2026-03-30 | \ ssh backup-server zfs receive offsite/documents
Resumable Send
For large initial sends over unreliable links, use resumable send. If the transfer is interrupted, you can pick up where it left off:
shell# zfs send -v tank/data@2026-03-29 | ssh backup-server zfs receive -s offsite/data
If interrupted, get the resume token on the receiving side:
shellbackup# zfs get receive_resume_token offsite/data NAME PROPERTY VALUE SOURCE offsite/data receive_resume_token 1-bf31...a7e3 -
Resume from the sending side:
shell# zfs send -t 1-bf31...a7e3 | ssh backup-server zfs receive -s offsite/data
Compressed and Raw Send
Use -c to send compressed blocks without recompressing, and --raw to send encrypted datasets without decrypting:
shell# zfs send -c --raw tank/data@2026-03-29 | \ ssh backup-server zfs receive offsite/data
Replication Script Example
A practical daily replication script for your backup workflow:
sh#!/bin/sh # /usr/local/bin/zfs-replicate.sh POOL="tank/data" REMOTE="backup-server" DEST="offsite/data" TODAY=$(date +%Y-%m-%d) YESTERDAY=$(date -v-1d +%Y-%m-%d) # Create today's snapshot zfs snapshot -r "${POOL}@${TODAY}" # Incremental send zfs send -Ri "${POOL}@${YESTERDAY}" "${POOL}@${TODAY}" | \ ssh "${REMOTE}" zfs receive -Fdu "${DEST}" # Prune snapshots older than 30 days on both sides for snap in $(zfs list -H -t snapshot -o name -r "${POOL}" | grep '@20' | head -n -30); do zfs destroy "$snap" done
The -R flag sends a recursive replication stream (all child datasets). The -F flag on receive forces a rollback if the destination has diverged.
Boot Environments with bectl
Boot environments are one of FreeBSD's most powerful features for system administration. A boot environment is a ZFS clone of your root filesystem. You can create one before an upgrade, boot into it, and roll back instantly if something breaks.
Listing Boot Environments
shell# bectl list BE Active Mountpoint Space Created default NR / 4.2G 2026-01-15 10:30
N means active now. R means active on reboot.
Creating a Boot Environment Before an Upgrade
shell# bectl create pre-upgrade-2026-03-29 # bectl list BE Active Mountpoint Space Created default NR / 4.2G 2026-01-15 10:30 pre-upgrade-2026-03-29 - - 8K 2026-03-29 09:00
Now run freebsd-update fetch install or pkg upgrade. If something goes wrong:
Rolling Back
shell# bectl activate pre-upgrade-2026-03-29 # reboot
After reboot, you are back on the exact system state from before the upgrade. No reinstallation. No restoring from backups.
Mounting a Boot Environment for Inspection
shell# bectl mount pre-upgrade-2026-03-29 /mnt # ls /mnt/etc/rc.conf /mnt/etc/rc.conf # bectl umount pre-upgrade-2026-03-29
Destroying Old Boot Environments
shell# bectl destroy pre-upgrade-2026-03-29
Boot environments are cheap. Create one before every system change. There is no reason not to.
Performance Tuning
ZFS performance depends primarily on RAM (for the ARC), followed by the choice of special vdevs (L2ARC, SLOG), and then dataset properties. For a deep dive into system-level optimization, see our FreeBSD performance tuning guide.
ARC Size
By default, ZFS claims up to a large portion of system RAM for the ARC. On a dedicated file server, this is exactly what you want. On a multi-purpose machine running jails, databases, or VMs, you may need to cap it.
Check current ARC usage:
shell# sysctl vfs.zfs.arc_summary | head -5 ... (use arc_summary for full output) # sysctl kstat.zfs.misc.arcstats.size kstat.zfs.misc.arcstats.size: 6845210624
That is approximately 6.4GB of ARC.
Set ARC maximum to 8GB in /boot/loader.conf:
shellvfs.zfs.arc_max="8589934592"
Or at runtime (does not persist across reboots):
shell# sysctl vfs.zfs.arc_max=8589934592
Set ARC minimum (prevents ZFS from releasing too much cache under memory pressure):
shellvfs.zfs.arc_min="2147483648"
Rule of thumb: Give ZFS 1GB of ARC per TB of storage as an absolute minimum. More is always better. On a dedicated NAS, let ZFS have 80% or more of physical RAM.
L2ARC (Level 2 ARC)
L2ARC extends the read cache to a fast SSD. It is a second-tier cache -- data evicted from the ARC goes to L2ARC before being discarded entirely.
Add an L2ARC device:
shell# zpool add tank cache /dev/ada5 # zpool status tank pool: tank state: ONLINE config: NAME STATE READ WRITE CKSUM tank ONLINE 0 0 0 mirror-0 ONLINE 0 0 0 ada1 ONLINE 0 0 0 ada2 ONLINE 0 0 0 cache ada5 ONLINE 0 0 0 errors: No known data errors
When L2ARC helps: Workloads with a working set larger than RAM but smaller than the L2ARC device. Random read-heavy workloads.
When L2ARC does not help: Sequential reads (streaming video, backups). Working sets that fit entirely in the ARC. Write-heavy workloads.
L2ARC metadata consumes ARC space. On systems with less than 64GB of RAM and large L2ARC devices, the metadata overhead can actually reduce effective cache. Size your L2ARC at roughly 5-10x your ARC size.
SLOG (Separate Intent Log)
ZFS uses a write intent log (ZIL) to guarantee synchronous write durability. By default, the ZIL resides on the pool disks. A SLOG is a dedicated, fast, power-loss-protected device (like an Optane SSD or an enterprise NVMe with capacitors) that holds the ZIL.
Add a SLOG device:
shell# zpool add tank log mirror /dev/nvd0 /dev/nvd1
Always mirror your SLOG. Losing a SLOG device can lose the last few seconds of synchronous writes.
When SLOG helps: NFS with sync=standard, databases with fsync, ESXi datastores, any workload that issues synchronous writes.
When SLOG does not help: Workloads using async writes (most local file operations). Bulk data transfers.
Critical requirement: Your SLOG device must have power-loss protection. A consumer NVMe without capacitors defeats the purpose entirely -- data in the SLOG's volatile write cache will be lost on power failure, which is the exact scenario the ZIL is designed to protect against.
Compression
Compression is almost always a net win on ZFS. It reduces the amount of data written to disk and read from disk, which means less I/O. On modern CPUs, LZ4 compression is effectively free:
shell# zfs set compression=lz4 tank
Check compression ratio:
shell# zfs get compressratio tank/data NAME PROPERTY VALUE SOURCE tank/data compressratio 1.82x -
A ratio of 1.82x means your data occupies 55% of the space it would otherwise need. This is extra usable capacity and extra performance for free.
For cold storage or archival data where CPU time is cheap:
shell# zfs set compression=zstd tank/data/backups
zstd achieves significantly better ratios than LZ4 at the cost of higher CPU usage.
Record Size Tuning
Default is 128K. Tune per dataset based on workload:
| Workload | Recommended recordsize |
|----------|----------------------|
| General files | 128K (default) |
| PostgreSQL | 16K |
| MySQL/InnoDB | 16K |
| MongoDB | 64K |
| VM images (bhyve) | 64K |
| Large media files | 1M |
| Backup streams | 1M |
shell# zfs set recordsize=16K tank/data/postgresql
Special Allocation Class
On pools with both spinning disks and SSDs, you can add SSDs as a special vdev for metadata and small blocks. This dramatically accelerates metadata-heavy operations (directory listings, ls -la, find):
shell# zpool add tank special mirror /dev/nvd2 /dev/nvd3 # zfs set special_small_blocks=32K tank
Blocks smaller than 32K will be stored on the fast special vdev. Always mirror the special vdev -- losing it without redundancy loses the pool.
Key Tunables Summary
Add these to /boot/loader.conf as needed:
shell# ARC size limits vfs.zfs.arc_max="8589934592" vfs.zfs.arc_min="2147483648" # Prefetch tuning (disable if random I/O dominant) vfs.zfs.prefetch_disable="0" # Transaction group timeout (seconds) - lower means more frequent writes vfs.zfs.txg.timeout="5" # Scrub speed - increase for faster scrubs (default 0, unlimited) vfs.zfs.scrub_delay="0" # L2ARC population speed vfs.zfs.l2arc_write_max="67108864" vfs.zfs.l2arc_write_boost="134217728"
Monitoring ZFS Health
Pool Status
The most important command you will run:
shell# zpool status -v pool: tank state: ONLINE scan: scrub repaired 0B in 03:24:18 with 0 errors on Sun Mar 28 03:24:18 2026 config: NAME STATE READ WRITE CKSUM tank ONLINE 0 0 0 mirror-0 ONLINE 0 0 0 ada1 ONLINE 0 0 0 ada2 ONLINE 0 0 0 errors: No known data errors
Any non-zero value in READ, WRITE, or CKSUM columns demands investigation. CKSUM errors indicate data corruption on that device. WRITE errors indicate the device is failing to accept writes.
Pool I/O Statistics
Real-time I/O monitoring:
shell# zpool iostat tank 5 capacity operations bandwidth pool alloc free read write read write ---------- ----- ----- ----- ----- ----- ----- tank 1.23T 547G 245 128 30.2M 15.6M tank 1.23T 547G 312 89 38.9M 10.8M tank 1.23T 547G 198 156 24.7M 19.4M
Add -v for per-vdev breakdown:
shell# zpool iostat -v tank 5
ARC Statistics
The arc_summary tool provides a comprehensive view of ARC performance:
shell# arc_summary ------------------------------------------------------------------------ ZFS Subsystem Report Sat Mar 29 09:15:22 2026 ------------------------------------------------------------------------ ARC Summary: (HEALTHY) Memory Throttle Count: 0 ARC Misc: Deleted: 142.3k Mutex Misses: 89 Evict Skips: 12 ARC Size: 64.12% 6.41 GiB Target Size: (Adaptive) 100.00% 10.00 GiB Min Size (Hard Limit): 12.50% 1.25 GiB Max Size (High Water): 8:1 10.00 GiB ARC Size Breakdown: Recently Used Cache Size: 50.00% 3.21 GiB Frequently Used Cache Size: 50.00% 3.21 GiB ARC Hash Breakdown: Elements Max: 312.4k Elements Current: 78.32% 244.7k ARC Efficiency: 459.2k accesses Cache Hit Ratio: 97.43% 447.4k Cache Miss Ratio: 2.57% 11.8k ...
A hit ratio above 95% is good. Above 99% is excellent. Below 90% suggests your working set exceeds your ARC and you should add more RAM or an L2ARC.
Disk Health with SMART
Always monitor the physical health of your drives alongside ZFS:
shell# pkg install smartmontools # smartctl -a /dev/ada1 | grep -E "Reallocated|Current_Pending|Offline_Uncorrectable" 5 Reallocated_Sector_Ct 0x0033 100 100 010 Pre-fail Always - 0 197 Current_Pending_Sector 0x0012 100 100 000 Old_age Always - 0 198 Offline_Uncorrectable 0x0010 100 100 000 Old_age Offline - 0
Non-zero values on these three attributes mean the drive is developing bad sectors. Replace it proactively.
Common Maintenance
Scrub Schedule
A scrub reads every block in the pool and verifies its checksum. On redundant pools, it repairs any corruption it finds. Run scrubs at least monthly, weekly for critical data.
Add to cron:
shell# crontab -e
shell0 2 * * 0 /sbin/zpool scrub tank
This runs a scrub every Sunday at 2:00 AM.
Check scrub status:
shell# zpool status tank | grep scan scan: scrub in progress since Sun Mar 29 02:00:01 2026 1.23T scanned at 456M/s, 987G issued at 367M/s, 1.23T total 0B repaired, 80.24% done, 00:12:34 to go
Replacing a Failed Drive
When a drive fails:
shell# zpool status tank pool: tank state: DEGRADED status: One or more devices has been removed by the user. Sufficient replicas exist for the pool to continue functioning in a degraded state. config: NAME STATE READ WRITE CKSUM tank DEGRADED 0 0 0 mirror-0 DEGRADED 0 0 0 ada1 ONLINE 0 0 0 ada2 UNAVAIL 0 0 0 errors: No known data errors
Physically replace the failed drive. If the new drive appears as ada2:
shell# zpool replace tank ada2 /dev/ada2
If the new drive has a different device name (e.g., you swapped it into a different bay):
shell# gpart create -s gpt ada5 # gpart add -t freebsd-zfs -l disk2 ada5 # zpool replace tank ada2 /dev/gpt/disk2
Monitor the resilver:
shell# zpool status tank scan: resilver in progress since Sat Mar 29 10:15:00 2026 456G scanned at 234M/s, 123G issued at 145M/s, 1.23T total 123G resilvered, 10.00% done, 02:15:30 to go
Expanding a Pool
ZFS pools can be expanded by adding new vdevs. You cannot add individual disks to an existing vdev (with the exception of the relatively new RAIDZ expansion feature in OpenZFS 2.3+), but you can add entirely new vdevs to the pool:
shell# zpool add tank mirror /dev/ada5 /dev/ada6
This adds a second mirror vdev. The pool now stripes data across both mirrors, roughly doubling throughput and capacity.
Warning: Match your vdev types. Adding a stripe vdev to a mirrored pool means your pool is only as reliable as the stripe. If the stripe vdev dies, the entire pool is lost.
Upgrading Pool and Filesystem Features
After upgrading FreeBSD or OpenZFS, new features may be available:
shell# zpool upgrade This system supports ZFS pool feature flags. The following pools are formatted using feature flags, but do not have all supported features enabled: tank The following features are supported but not enabled: ...
Upgrade when ready:
shell# zpool upgrade tank # zfs upgrade -a
Note: Pool upgrades are one-way. Once upgraded, older versions of ZFS cannot import the pool. Ensure all systems that need to access this pool support the new features before upgrading.
Best Practices and Gotchas
Do
- Use mirrors for anything you care about. Mirrors resilver in hours instead of days, have the best read performance, and tolerate up to N-1 disk failures.
- Enable compression everywhere.
lz4is the minimum.zstdfor cold data. The CPU overhead is negligible and you gain both capacity and performance. - Set
atime=offon every pool. Access time updates generate a write for every read. Disable this unless you have a specific reason to track access times. - Use ECC RAM. ZFS checksums detect corruption on disk but cannot detect corruption in RAM. Non-ECC RAM can silently corrupt data in the ARC before it is written to disk. This is not ZFS-specific -- ECC RAM protects all workloads -- but ZFS users tend to care more about data integrity.
- Create datasets, not directories. If you would create a top-level directory, create a dataset instead. Datasets give you per-directory snapshots, quotas, compression settings, and send/receive capability.
- Snapshot before every system change. Boot environments for the OS, manual snapshots for data. Make it reflexive.
- Scrub monthly at minimum. Scrubs are the only way to detect latent corruption (bit rot) before it becomes unrecoverable.
- Monitor with
zpool statusdaily. Automate this with a cron job that emails you on non-ONLINE state.
Do Not
- Do not use RAIDZ1 with large drives (4TB+). Resilver times on large drives can exceed 24 hours. A second failure during resilver kills the pool. Use RAIDZ2 or mirrors.
- Do not enable deduplication unless you have measured your DDT size. Dedup requires approximately 5GB of RAM per TB of stored data for the deduplication table (DDT). If the DDT spills out of the ARC, performance collapses catastrophically. Most workloads do not benefit from dedup. Use compression instead.
- Do not use consumer SSDs as SLOG devices. Without power-loss protection (PLP), the SLOG cannot guarantee write durability. Use Intel Optane, enterprise NVMe with PLP capacitors, or skip the SLOG entirely.
- Do not mix vdev types in a pool. A pool with one mirror vdev and one stripe vdev has the reliability of a stripe.
- Do not use ZFS on systems with less than 8GB of RAM. ZFS works with less, but the ARC will be too small to cache meaningfully and performance will suffer.
- Do not put swap on ZFS. Use a separate partition or GEOM device for swap. ZFS and swap interact poorly under memory pressure -- ZFS needs RAM to free RAM, creating deadlock potential.
- Do not resize or repartition disks in an active vdev. Always replace the entire disk using
zpool replace.
FAQ
How much RAM does ZFS actually need?
The common claim is "1GB per TB." This is a rough minimum. ZFS itself needs at least 1-2GB for basic operation. The ARC (read cache) is what scales with data size. For a 20TB pool, 16GB of RAM is comfortable. 32GB or more is ideal. On a dedicated NAS, give ZFS as much RAM as you can afford -- the ARC turns spinning disks into what feels like SSD-class read performance.
Can I add a single disk to an existing RAIDZ vdev?
Historically, no. RAIDZ vdevs were fixed-width at creation. However, OpenZFS 2.3 introduced RAIDZ expansion, which allows adding one disk at a time to an existing RAIDZ vdev. FreeBSD 14.2+ with OpenZFS 2.3 supports this:
shell# zpool attach tank raidz1-0 /dev/ada5
This process rewrites all data in the vdev to redistribute across the new width. It is slow but it works. Check your OpenZFS version with zpool --version before relying on this feature.
Should I use RAIDZ1, RAIDZ2, or mirrors?
For fewer than 6 disks: mirrors. For 6-12 disks: RAIDZ2. For 12+ disks: RAIDZ3 or multiple mirror vdevs. RAIDZ1 is appropriate only for small drives (under 2TB) where resilver completes quickly. The cost of a second parity disk is trivial compared to the cost of data loss.
Is ZFS native encryption production-ready on FreeBSD?
Yes. OpenZFS native encryption (added in 0.8.0) is stable and production-ready. It encrypts data at the dataset level before it reaches disk, which means ZFS send/receive can replicate encrypted datasets to untrusted remote hosts without exposing plaintext:
shell# zfs create -o encryption=aes-256-gcm -o keyformat=passphrase tank/encrypted Enter new passphrase: Re-enter new passphrase:
Load the key at boot by adding to /etc/rc.conf:
shellzfs_keys="YES"
How do I check if my pool is healthy?
Run zpool status. If the state is ONLINE and all error counts are zero, the pool is healthy. Any state other than ONLINE (DEGRADED, FAULTED, UNAVAIL) requires immediate attention. Also check the last scrub date -- if it has been more than 30 days, run zpool scrub tank now.
Can I convert an existing UFS root to ZFS?
Not in-place. The cleanest path is to reinstall FreeBSD with ZFS root (the installer supports this directly) and restore your data. Alternatively, you can migrate a running system by creating a ZFS pool on separate disks, copying the root filesystem, installing the bootcode, and adjusting the bootloader. This is complex and error-prone -- a fresh install is almost always faster.
What is the difference between ZFS send and rsync?
zfs send operates at the block level, not the file level. It serializes the exact block-level difference between two snapshots. This makes it faster than rsync for most replication tasks because it does not need to scan directory trees to find changes. It also preserves all ZFS properties, snapshots, and metadata. Use zfs send for ZFS-to-ZFS replication. Use rsync when the destination is not ZFS or when you need file-level filtering.
Conclusion
ZFS on FreeBSD is a mature, battle-tested storage stack. With first-class kernel integration, boot environments, native encryption, and the full OpenZFS feature set, it provides capabilities that no other general-purpose operating system matches out of the box.
Start with a mirror pool, enable LZ4 compression, set atime=off, create datasets for each logical data group, snapshot regularly, replicate offsite with zfs send, and scrub monthly. This baseline covers 90% of storage use cases and protects your data against the failures that will inevitably occur.
For a complete server build around ZFS, see our FreeBSD NAS build guide. For system-level performance optimization beyond ZFS, see FreeBSD performance tuning.