Most ext4 vs XFS pages are feature lists: journaling, extents, “scales to exabytes”. None of that tells you which one is faster on the NVMe drive in your server, or what you give up by picking Btrfs or ZFS instead. So I formatted the same 400 GiB slice of a Samsung SM961 four ways and ran the same fio jobs and small-file test against each one, three times over.
The results cover ext4 vs XFS head to head on random and sequential I/O, fsync rate, 99th percentile latency, and metadata-heavy work (unpacking and deleting 105,000 files). Btrfs and ZFS ride along on the same disk, with ZFS tested at its default 128K recordsize and at 16K, because that one setting changes the picture more than anything else here. The same jobs then ran once on Ubuntu 24.04 to show what the older kernel and OpenZFS 2.2 change.
I ran all of this in October 2026 on Ubuntu 26.04 and 24.04, same drive and same VM size, and the exact fio jobs are in the method section if you want to repeat it on your own hardware.
1. Ext4 vs XFS vs Btrfs vs ZFS: the numbers
Every figure is the median of three runs on Ubuntu 26.04 (kernel 7.0, OpenZFS 2.4). Random tests use 4K blocks, sequential tests use 1M blocks, and all of them use O_DIRECT so the page cache is not doing the work (ZFS is a partial exception, covered in section 5).
| Test | ext4 | XFS | Btrfs | ZFS (128K) | ZFS (16K) |
|---|---|---|---|---|---|
| 4K random read, IOPS | 81,449 | 80,248 | 62,881 | 6,066 | 13,576 |
| 4K random write, IOPS | 87,983 | 82,863 | 15,588 | 2,316 | 6,808 |
| 70/30 random mix, read + write IOPS | 42,181 + 18,045 | 43,377 + 18,576 | 20,800 + 8,902 | 3,242 + 1,391 | 7,157 + 3,067 |
| 4K write + fsync, ops/s | 313 | 339 | 231 | 349 | 454 |
| Random read p99 latency | 0.61 ms | 0.65 ms | 1.45 ms | 7.2 ms | 4.2 ms |
| Random write p99 latency | 0.59 ms | 0.63 ms | 5.2 ms | 43.8 ms | 31.6 ms |
| 1M sequential read, MiB/s | 1,310 | 953 | 999 | 951 | 1,014 |
| 1M sequential write, MiB/s | 929 | 809 | 644 | 648 | 763 |
| Write a 16 GiB file, seconds | 10.7 | 10.4 | 44.2 | 20.3 | 55.4 |
The small-file test unpacks three copies of a tarball of /usr/share and /usr/include (105,028 files and directories in total), walks the tree with find on a cold cache, then deletes it. The untar and delete steps end with sync, so their times include getting the data onto the disk.
| Metadata test (seconds, lower is better) | ext4 | XFS | Btrfs | ZFS (128K) | ZFS (16K) |
|---|---|---|---|---|---|
| Untar 105k files | 4.60 | 6.69 | 6.10 | 9.16 | 7.85 |
| find over the tree, cold cache | 0.32 | 0.92 | 1.09 | 1.25 | 1.14 |
| rm -rf the tree | 1.91 | 4.35 | 3.84 | 4.79 | 6.22 |
| mkfs / zpool create | 3.69 | 1.26 | 47.96 | 0.49 | 0.46 |
| Usable space on 400 GiB (df) | 372.6 GiB | 392.1 GiB | 398.0 GiB | 385.6 GiB | 385.6 GiB |
Read those two tables together and the short version is this: ext4 and XFS trade blows on raw I/O, ext4 is clearly faster on lots of small files, Btrfs pays for copy-on-write on random writes, and ZFS at its default recordsize is the wrong tool for 4K random I/O, yet ZFS took the fsync crown, especially at 16K.
2. How I tested
The disk is a 400 GiB logical volume on a Samsung SM961 512 GB NVMe in my Proxmox host, handed to the guest as a second virtio-scsi disk with cache=none, aio=native, discard and an iothread. The guests had 4 vCPUs (host CPU type) and 4 GiB of RAM. The 16 GiB test file is four times the RAM, so nothing fits in a cache. Inside the guest the disk used the none I/O scheduler and showed up as rotational, because I left Proxmox’s SSD emulation flag off; that matters most for Btrfs, and section 4 measures what it costs.
Each run starts with blkdiscard on the whole disk, then a fresh filesystem with default options. Every one of these commands wipes /dev/sdb without asking. If you repeat this, use a dedicated scratch disk and confirm its name with lsblk first. The only non-default setting is ashift=12 on the ZFS pool, which is what you’d set on any modern SSD anyway. The script then waits until the disk has gone quiet (under 1 MiB written over 10 seconds) so background init work can’t leak into the numbers:
mkfs.ext4 -F -q /dev/sdb
mkfs.xfs -f -q /dev/sdb
mkfs.btrfs -f -q /dev/sdb
zpool create -f -o ashift=12 -m /mnt/t tank /dev/sdb
zpool create -f -o ashift=12 -O recordsize=16K -m /mnt/t tank /dev/sdb
After laying out the 16 GiB file, six fio jobs run for 60 seconds each after a 10 second ramp: sequential read and write (1M, 4 jobs at queue depth 2, each job on its own quarter of the file), random read and write (4K, 4 jobs at depth 8), a 70/30 random mix (4K, 4 jobs at depth 4) and a single-job 4K random write with fsync after every write. Caches are dropped before each one. For the 70/30 mix I took the read and write medians separately. The pair can come from different runs. This is the random read job as I ran it on ext4, with the JSON output flags dropped so you get the normal report:
fio --name=randread --filename=/mnt/t/fio.dat --size=16G --rw=randread --bs=4k --iodepth=8 --numjobs=4 --ioengine=libaio --direct=1 --refill_buffers --randrepeat=0 --norandommap --time_based --runtime=60 --ramp_time=10 --group_reporting
That capture settled at 84.1k IOPS, inside the 71.5k to 88.2k range of the three benchmark runs on ext4. The 99.00th percentile line is where the p99 latency column in the results table comes from:
randread: (g=0): rw=randread, bs=(R) 4096B-4096B, (W) 4096B-4096B, (T) 4096B-4096B, ioengine=libaio, iodepth=8
...
fio-3.41
Starting 4 processes
randread: Laying out IO file (1 file / 16384MiB)
randread: (groupid=0, jobs=4): err= 0: pid=49196: Thu Oct 8 01:46:12 2026
read: IOPS=84.1k, BW=329MiB/s (345MB/s)(19.3GiB/60001msec)
slat (usec): min=2, max=795, avg= 6.80, stdev= 5.87
clat (usec): min=50, max=13761, avg=372.78, stdev=269.44
lat (usec): min=82, max=13773, avg=379.59, stdev=269.51
clat percentiles (usec):
| 1.00th=[ 176], 5.00th=[ 221], 10.00th=[ 247], 20.00th=[ 285],
| 30.00th=[ 310], 40.00th=[ 334], 50.00th=[ 355], 60.00th=[ 379],
| 70.00th=[ 404], 80.00th=[ 437], 90.00th=[ 478], 95.00th=[ 523],
| 99.00th=[ 644], 99.50th=[ 742], 99.90th=[ 6063], 99.95th=[ 6325],
| 99.99th=[ 6849]
bw ( KiB/s): min=287312, max=390536, per=100.00%, avg=336649.18, stdev=5349.44, samples=480
iops : min=71828, max=97634, avg=84161.90, stdev=1337.33, samples=480
lat (usec) : 100=0.01%, 250=10.60%, 500=82.40%, 750=6.51%, 1000=0.24%
lat (msec) : 2=0.04%, 4=0.02%, 10=0.18%, 20=0.01%
cpu : usr=7.73%, sys=18.27%, ctx=1025861, majf=0, minf=148
IO depths : 1=0.0%, 2=0.0%, 4=0.0%, 8=100.0%, 16=0.0%, 32=0.0%, >=64=0.0%
submit : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
complete : 0=0.0%, 4=100.0%, 8=0.1%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
issued rwts: total=5046972,0,0,0 short=0,0,0,0 dropped=0,0,0,0
latency : target=0, window=0, percentile=100.00%, depth=8
Run status group 0 (all jobs):
READ: bw=329MiB/s (345MB/s), 329MiB/s-329MiB/s (345MB/s-345MB/s), io=19.3GiB (20.7GB), run=60001-60001msec
Disk stats (read/write):
sdb: ios=5891057/367, sectors=47128456/1380768, merge=0/22, ticks=1997256/2162, in_queue=1999492, util=99.91%
The snag that cost me a pass: my first short smoke run used one fio job at queue depth 32. Ext4 and XFS were happy with that (96,700 and 87,200 random read IOPS), but ZFS came back at 2,634. ZFS does not implement native async I/O, so libaio submissions block and a depth of 32 collapses to roughly one I/O in flight. Splitting the same load across four jobs is fair to all five setups and lifted ZFS at 16K from 3,606 to 14,847 random read IOPS in a short check run. If you benchmark ZFS with a single libaio job, you are measuring that limitation, not the disk.
All data is incompressible (--refill_buffers), so ZFS and Btrfs compression can’t flatter the write numbers. Each filesystem got three full passes on 26.04, interleaved (ext4, XFS, Btrfs, ZFS, ZFS 16K, then round again), so a slow patch on the host would hit all five rather than one.
3. Ext4 vs XFS: which one is faster?
On random I/O they are tied. Ext4 led random reads by 1.5% and random writes by 6%, and the run-to-run spread was wider than either gap (ext4 random writes ranged from 77,199 to 95,909 IOPS across the three runs, XFS from 74,567 to 93,966). The 70/30 mix tipped the other way, slightly in XFS’s favour. Sequential numbers were noisy enough that I wouldn’t rank them either: ext4’s sequential reads landed anywhere between 974 and 1,528 MiB/s depending on the run.
Where they really differ is metadata. Ext4 unpacked the 105k files in 4.60 seconds against 6.69 for XFS, walked the tree in a third of the time, and deleted it in 1.91 seconds against 4.35. That’s a 2.3x gap on rm -rf, and it’s the kind of workload a build server, a mail spool or a container image store throws at a disk all day. XFS spreads its metadata across allocation groups so many threads can work in parallel; a single tar or rm process doesn’t benefit from that.
XFS gave me 19.5 GiB more usable space on the same 400 GiB volume (392.1 vs 372.6 GiB). Both lose about 7 GiB to their own structures (XFS shows it as “Used” right after mkfs, ext4 hides it in a smaller “Size”). The rest of the gap is ext4 reserving 5% of its blocks for root, which df leaves out of “Avail”. On a pure data volume, tune2fs -m 1 hands most of that back. This is how the two looked straight after mkfs:
df -h /mnt/t
Ext4 first, then XFS on the same 400 GiB disk:
Filesystem Size Used Avail Use% Mounted on
/dev/sdb 393G 2.1M 373G 1% /mnt/t
Filesystem Size Used Avail Use% Mounted on
/dev/sdb 400G 7.7G 393G 2% /mnt/t
The 2.1M versus 7.7G “Used” is bookkeeping, not data: neither filesystem had a file on it yet.
Then there is resizing, which decides more real deployments than any benchmark. Ext4 grows online and shrinks offline with resize2fs. XFS grows online with xfs_growfs but effectively can’t shrink: the man page on 26.04 still says only shrinking the last allocation group “without removing it is implemented”. If there’s any chance you’ll need to hand space back to another logical volume, that settles it for ext4. The XFS and Btrfs resize guide and the ext4 and XFS root partition resize steps walk through both.
Distro defaults follow the same split: Ubuntu and Debian install on ext4, while RHEL, Rocky Linux and AlmaLinux install on XFS. The Proxmox VE installer offers ext4, XFS, ZFS and Btrfs; for a single-disk Proxmox host with LVM-thin on top, the filesystem only holds the OS and ISOs, so pick whichever one your recovery habits know best. The ext4 and XFS repair guide covers e2fsck and xfs_repair for when that day comes.
4. Btrfs: what copy-on-write costs on random writes
Btrfs delivered 15,588 random write IOPS, under a fifth of ext4’s 87,983, with p99 write latency of 5.2 ms against 0.59 ms. Every overwrite in Btrfs lands in a new location and updates the checksum and extent trees above it, and on a single disk the metadata is stored twice (DUP is the default profile for a single device, per the mkfs.btrfs docs). Random reads held up better at 62,881 IOPS, about 77% of ext4, with checksum verification on every block read.
The 16 GiB sequential fill took 44 seconds on Btrfs versus about 10.5 on ext4 and XFS, and Btrfs had the lowest fsync rate of the five (231 per second). On the metadata test it sat between ext4 and XFS: faster than XFS to delete the tree, slower to walk it.
Even mkfs.btrfs was slow on 26.04: a 48 second median across the benchmark runs, against under 4 for the others. It TRIMs the whole device by default, and with btrfs-progs 6.17 on kernel 7.0 a separately timed run took 53.6 seconds on a disk I had just discarded. The same step on 24.04 (btrfs-progs 6.6) took 0.75 seconds. If you’ve already discarded the disk, skip it with -K:
time mkfs.btrfs -f -K /dev/sdb
Without the TRIM it’s done in 65 ms. The block group profiles also show the single-device default of DUP metadata, with 1 GiB set aside for it on this 400 GiB disk:
btrfs-progs v6.17.1
See https://btrfs.readthedocs.io for more information.
Label: (null)
UUID: 8c95e73a-558a-402e-9c5f-c83e23ff619d
Node size: 16384
Sector size: 4096 (CPU page size: 4096)
Filesystem size: 400.00GiB
Block group profiles:
Data: single 8.00MiB
Metadata: DUP 1.00GiB
System: DUP 8.00MiB
SSD detected: no
Zoned device: no
Features: extref, skinny-metadata, no-holes, free-space-tree
Checksum: crc32c
Number of devices: 1
Devices:
ID SIZE PATH
1 400.00GiB /dev/sdb
real 0m0.065s
user 0m0.003s
sys 0m0.011s
Note the SSD detected: no line. Proxmox presents a virtual disk as rotational unless you tick SSD emulation (ssd=1), and Btrfs only switches on its ssd allocation mode for non-rotational devices. Every Btrfs number in the main table comes from that default, which is exactly what you’d get in a Proxmox guest without the flag. To see what it costs, I ran one extra 26.04 pass mounted with -o ssd: random reads rose from 62,881 to 80,574 IOPS (close to ext4) and random writes from 15,588 to 19,696, which is still under a quarter of ext4’s figure. If you run Btrfs inside VMs on flash, set the SSD flag on the virtual disk.
None of that makes Btrfs a bad pick. You’re paying for snapshots, send/receive, transparent compression and checksums that catch silent corruption, and for a desktop or a root filesystem with Snapper rollbacks those are worth far more than random write IOPS. Just don’t put a busy database or VM images on it with default settings. The Btrfs subvolumes and snapshots guide covers the day-to-day commands, and if you’re coming from ext4 there’s an in-place ext4 to Btrfs conversion.
5. ZFS: why recordsize decides 4K performance
At the default 128K recordsize, ZFS managed 6,066 random read IOPS and 2,316 random write IOPS. Those numbers look broken until you work out the I/O behind them. ZFS checksums whole records, so a 4K read pulls in the full 128K record: 6,066 × 128 KiB is about 758 MiB/s of actual disk reads, not far off what the sequential test managed. A 4K write into a 128K record is a read, modify and rewrite of the whole record.
OpenZFS 2.3 added Direct I/O, and on 26.04 the dataset property direct=standard is the default, so O_DIRECT requests skip the ARC. The catch is in man zfsprops: a write only bypasses the ARC when its offset and size are recordsize-aligned. A 4K write on a 128K dataset never is, so it falls back to the normal buffered path. You can see the defaults a fresh pool gets:
zfs get -H -o property,value recordsize,compression,direct,atime,sync tank
Compression is on by default, and that is the reason the fio jobs write incompressible data. Otherwise ZFS would be writing a fraction of the bytes the other filesystems write:
recordsize 128K
compression on
direct standard
atime on
sync standard
Dropping recordsize to 16K more than doubled both: 13,576 read and 6,808 write IOPS, and p99 write latency fell from 43.8 ms to 31.6 ms. It’s still nowhere near ext4, and there’s a price on large sequential writes. Laying out the 16 GiB file took 55 seconds at 16K against 20 at 128K, most likely because ZFS now writes and tracks eight times as many blocks. Set recordsize per dataset rather than pool-wide. The OpenZFS workload tuning page suggests 16K for InnoDB data files, 32K with lz4 for PostgreSQL (it dropped the old 8K advice once compression became the norm), and 1M for sequential data such as media.
Where ZFS won outright is fsync. At 16K it sustained 454 synchronous writes per second, 45% more than ext4, and even at 128K it beat ext4 and XFS. The ZFS intent log commits a small synchronous write without rewriting the whole record. On this consumer drive every flush still costs about 2 to 4 ms (the SM961 has no power-loss protection, so its cache flush is real work); an enterprise NVMe with PLP would push all five much higher.
Four GiB of RAM is on the small side for ZFS, but with a 16 GiB file and O_DIRECT the ARC can’t hold the working set either way, so a bigger VM wouldn’t change the random I/O ranking. If you’re planning a pool with more than one disk, the ZFS RAID levels on Proxmox and the RAID vs LVM vs ZFS comparison cover the layout side.
6. Ubuntu 24.04 vs 26.04: same disk, older stack
Ubuntu 24.04 is still what most servers run, so I moved the same logical volume to a 24.04.5 VM (kernel 6.8, OpenZFS 2.2.2, btrfs-progs 6.6) and ran one full pass. One run per filesystem is not enough to rank close results, so read this table for the big swings only. The small-file tarball was built from each VM’s own /usr. On 24.04 that came to 88,744 entries instead of 105,028, so compare those times within this table only.
| Ubuntu 24.04 (one run) | ext4 | XFS | Btrfs | ZFS (128K) | ZFS (16K) |
|---|---|---|---|---|---|
| 4K random read, IOPS | 87,977 | 86,355 | 81,159 | 9,140 | 13,633 |
| 4K random write, IOPS | 96,067 | 94,675 | 27,816 | 2,496 | 6,829 |
| 4K write + fsync, ops/s | 297 | 328 | 234 | 333 | 381 |
| 1M sequential read, MiB/s | 1,444 | 933 | 802 | 1,435 | 985 |
| 1M sequential write, MiB/s | 881 | 789 | 165 | 585 | 669 |
| Write a 16 GiB file, seconds | 10.3 | 10.2 | 10.0 | 11.0 | 15.9 |
| Untar / rm 89k files, seconds | 4.07 / 1.59 | 4.65 / 3.31 | 5.02 / 2.66 | 7.35 / 4.46 | 7.50 / 6.48 |
Ext4 and XFS came out a few percent higher on 24.04, at or just above the top of the spread I saw across the three 26.04 runs, so I’d call that a draw between kernels rather than a regression in 7.0. The ext4 lead on deleting files held: 1.59 seconds against 3.31 for XFS.
ZFS is where the two releases really behave differently. OpenZFS 2.2 ignores O_DIRECT and sends every request through the ARC, so on 24.04 the large writes were buffered: the 16 GiB file went down in 15.9 seconds at 16K recordsize, against 55.4 on 26.04, where Direct I/O writes aligned 1M requests straight to the disk. The 128K dataset also read faster on 2.2 (9,140 random read IOPS against 6,066), likely helped by the ARC and its prefetch. Caching alone can’t explain all of it, though. The working set was 16 GiB and the ARC defaults to about 2 GiB on a 4 GiB VM. If an application on 2.3 or newer opens its files with O_DIRECT and you preferred the buffered behaviour, man zfsprops says direct=disabled brings back the 2.2 handling for that dataset. I did not benchmark that setting, so test it with your own workload first.
Btrfs moved the most, and not all in one direction. Random writes were higher on 24.04 (27,816 against 15,588 IOPS) and the 16 GiB fill took 10 seconds instead of 44, yet the 1M sequential write test managed only 165 MiB/s. That’s one run, so I would not build a decision on it, but it does say Btrfs performance tracks the kernel version more than the other three do. Benchmark it on the kernel you will actually deploy.
7. Which filesystem I’d put where
After formatting the same disk more than 30 times, this is how I’d split it:
- General servers, VM guests, build boxes and mail spools: ext4. It matched XFS on random I/O, deleted 105k files in less than half the time (1.91 against 4.35 seconds), and unlike XFS it shrinks (offline, with
resize2fs). - RHEL-family hosts and big volumes that only ever grow: XFS. It’s within run-to-run noise of ext4 on random I/O, gave 19.5 GiB more usable space on 400 GiB, and it’s what the installer and Red Hat’s tooling expect.
- Desktops and root filesystems you want to roll back: Btrfs. Snapshots and checksums are the point. Keep databases and VM disk images off it unless you’ve tested them on your kernel, because random writes ran at 15,588 IOPS against ext4’s 87,983.
- Multi-disk storage where integrity matters, and fsync-heavy apps: ZFS. It had the best fsync rate here (454 per second at 16K). Give each workload its own dataset with a matching recordsize, because the 128K default delivered 2,316 random write IOPS on 4K writes.
If you’re torn between ext4 and XFS for a single disk, it hardly matters for raw throughput. Pick on resize needs and metadata workload: lots of small files or any chance of shrinking means ext4, everything else is a coin toss your distro has already made for you.
Thank you for your article.
That helped me make a decision. Our environment is VMWare and we have regular backups to Veeam and our NFS system also has regular snapshots. I think that in our environment the main advantage of XFS of journalling and data integrity isn’t as important, so I’ll opt to stick with ext4 as we migrate to Rocky Linux 8. You’re article was very helpful.
Awesome thanks.
Well considering that 99% of all modern machines are 64bit, XFS has the clear advantage considering also all the processes running in the background of your distro. Your machine would probably make better use of a file system that takes proper management of its resources into consideration.
Yeah sure.. Thanks for sharing your insights.