fio (Flexible I/O Tester) is the standard tool for measuring how fast a disk or volume really is. It lets you control the block size, queue depth, read/write mix and caching behaviour, so you can reproduce the access pattern of a database, a file server or a backup job. In this tutorial you will install fio on Ubuntu 24.04, run the four core benchmarks (random and sequential, read and write), measure latency percentiles, simulate synchronous database writes and save the results as JSON for later comparison.

Prerequisites

To follow this guide you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS or dedicated server.
  • A non-root user with sudo privileges.
  • At least 5 GB of free space on the filesystem you want to test.

Step 1 - Installing fio

fio is packaged in the Ubuntu repositories. Install it together with jq, which you will use later to read the JSON results:

sudo apt update
sudo apt install fio jq

Check the installed version:

fio --version
fio-3.36

Step 2 - Preparing a test directory

fio needs a file to read from and write to. Create a dedicated directory on the filesystem you want to measure. If you want to benchmark a second disk mounted at /mnt/data, create the directory there instead of in your home:

mkdir -p ~/fio-test
cd ~/fio-test

Confirm which device backs that directory and how much space is free:

df -h ~/fio-test
Filesystem      Size  Used Avail Use% Mounted on
/dev/vda1        78G  6.1G   72G   8% /

Every test below uses these common options, so it helps to know what they do:

OptionMeaning
--ioengine=libaioLinux asynchronous I/O, required for queue depths above 1
--direct=1Bypass the page cache so you measure the disk, not RAM
--size=4GSize of the test file
--bsBlock size of each request (4k for random, 1M for sequential)
--iodepthNumber of outstanding requests per job
--runtime=60 --time_basedRun for exactly 60 seconds
--group_reportingReport all jobs as one result

Step 3 - Measuring random read and write IOPS

Random 4 KiB I/O is the pattern that matters most for databases, virtual machines and busy web applications. Run a random read test with a queue depth of 32:

fio --name=randread --filename=fio.dat --size=4G \
  --ioengine=libaio --direct=1 --rw=randread --bs=4k \
  --iodepth=32 --numjobs=1 --runtime=60 --time_based --group_reporting

The first run spends a few seconds creating the 4 GB file ("Laying out IO file"). When it finishes, look at the read: line and the clat percentiles block:

randread: (groupid=0, jobs=1): err= 0: pid=4112: Thu Sep 25 10:12:41 2026
  read: IOPS=78.9k, BW=308MiB/s (323MB/s)(18.1GiB/60001msec)
    clat (usec): min=61, max=5120, avg=403.12, stdev=98.40
    clat percentiles (usec):
     |  1.00th=[  239],  5.00th=[  277], 10.00th=[  302], 20.00th=[  330],
     | 50.00th=[  396], 90.00th=[  506], 95.00th=[  553], 99.00th=[  693],
     | 99.50th=[  766], 99.90th=[ 1123], 99.95th=[ 1369], 99.99th=[ 2245]

Now run the same test for random writes:

fio --name=randwrite --filename=fio.dat --size=4G \
  --ioengine=libaio --direct=1 --rw=randwrite --bs=4k \
  --iodepth=32 --numjobs=1 --runtime=60 --time_based --group_reporting

The result appears under a write: line. On NVMe storage random reads commonly reach tens or hundreds of thousands of IOPS, while a spinning HDD manages only 100 to 200.

To see how the disk scales with parallelism, repeat the read test with --numjobs=4. If IOPS grows, the device has spare capacity and your application benefits from issuing requests in parallel.

Step 4 - Measuring sequential throughput

Sequential reads and writes with large blocks show the maximum bandwidth, which matters for backups, log shipping and media files. Use 1 MiB blocks and a lower queue depth:

fio --name=seqread --filename=fio.dat --size=4G \
  --ioengine=libaio --direct=1 --rw=read --bs=1M \
  --iodepth=16 --runtime=60 --time_based --group_reporting
  read: IOPS=1712, BW=1712MiB/s (1795MB/s)(100GiB/60004msec)

Then the sequential write test:

fio --name=seqwrite --filename=fio.dat --size=4G \
  --ioengine=libaio --direct=1 --rw=write --bs=1M \
  --iodepth=16 --runtime=60 --time_based --group_reporting

For these tests read the BW= value: IOPS is not meaningful with 1 MiB blocks.

Step 5 - Simulating a database workload

Two patterns approximate real database behaviour better than pure reads or writes.

The first is a mixed random workload, here 70% reads and 30% writes with 16 KiB blocks (the InnoDB page size) and four parallel jobs:

fio --name=oltp --filename=fio.dat --size=4G \
  --ioengine=libaio --direct=1 --rw=randrw --rwmixread=70 --bs=16k \
  --iodepth=16 --numjobs=4 --runtime=60 --time_based --group_reporting

fio prints separate read: and write: lines for the mix.

The second is synchronous commit latency. A database flushes its write-ahead log with fsync() on every commit, one request at a time. This test issues a 4 KiB write followed by an fsync with a queue depth of 1:

fio --name=fsync-latency --filename=fio.dat --size=1G \
  --ioengine=sync --rw=write --bs=4k --fsync=1 \
  --runtime=30 --time_based
  write: IOPS=2987, BW=11.7MiB/s (12.2MB/s)(350MiB/30001msec)
  fsync/fdatasync/sync_file_range:
    sync (usec): min=198, max=8421, avg=305.44, stdev=110.21

The IOPS figure here is roughly the maximum number of single-threaded commits per second the disk can sustain. Consumer SSDs without power-loss protection often score very low on this test even when their random IOPS look impressive.

Step 6 - Reading latency percentiles

Averages hide slow outliers, so focus on the percentiles in the clat percentiles block:

  • 50.00th is the median: half of all requests completed faster than this.
  • 99.00th means 99% of requests completed within this time. This is a good figure to compare disks.
  • 99.90th and 99.99th show tail latency. Values far above p99 point to throttling, garbage collection on the SSD or contention with other workloads.

clat is the completion latency measured from when the request was submitted to the device. lat includes submission overhead as well; for comparisons, use the same one consistently.

Step 7 - Saving tests in a job file

Typing long command lines is error-prone. A job file keeps the whole suite in one place and makes runs repeatable. Create one:

nano ~/fio-test/suite.fio

Add the following content. The [global] section applies to every job, and stonewall makes each job wait for the previous one to finish so they do not compete:

[global]
filename=fio.dat
size=4G
ioengine=libaio
direct=1
runtime=60
time_based
group_reporting

[randread-4k]
rw=randread
bs=4k
iodepth=32
stonewall

[randwrite-4k]
rw=randwrite
bs=4k
iodepth=32
stonewall

[seqread-1m]
rw=read
bs=1M
iodepth=16
stonewall

[seqwrite-1m]
rw=write
bs=1M
iodepth=16
stonewall

[mixed-70-30]
rw=randrw
rwmixread=70
bs=16k
iodepth=16
numjobs=4
stonewall

Run the suite and write the results as JSON:

cd ~/fio-test
fio suite.fio --output-format=json --output=baseline.json

The suite takes about five minutes.

Step 8 - Extracting results with jq

The JSON output contains one entry per job in the jobs array. Extract IOPS, bandwidth (in KiB/s) and p99 completion latency (in nanoseconds) for each job:

jq -r '.jobs[] | [.jobname,
  (.read.iops | floor), (.write.iops | floor),
  .read.bw, .write.bw,
  (.read.clat_ns.percentile["99.000000"] // 0),
  (.write.clat_ns.percentile["99.000000"] // 0)] | @tsv' baseline.json
randread-4k	78921	0	315684	0	692224	0
randwrite-4k	0	41230	0	164920	0	1236992
seqread-1m	1711	0	1752064	0	0	0
seqwrite-1m	0	1189	0	1217536	0	0
mixed-70-30	20411	8752	326576	140032	1073152	1531904

Keep baseline.json. After you change something (a new disk, a different filesystem, a kernel update, a larger plan), run the suite again to after.json and compare the same fields. Always run each test at least twice: on shared or cloud storage results can vary between runs.

Step 9 - Cleaning up

Remove the test file so it does not occupy space:

rm ~/fio-test/fio.dat

Troubleshooting

fio: looks like your file system does not support direct=1/buffered=0: the filesystem (for example tmpfs) does not support O_DIRECT. Test on a real disk-backed filesystem such as ext4 or XFS.

Read results are unrealistically high (several GB/s on a small VPS): --direct=1 is missing, so reads are being served from the page cache. Add it back, or use a test file larger than the server's RAM.

No space left on device: reduce --size or free space on the filesystem. fio needs the full file size available.

Conclusion

You installed fio, measured random IOPS, sequential bandwidth, mixed and synchronous database workloads, and learned to read latency percentiles. With a job file and JSON output you now have a repeatable baseline to compare against after hardware or configuration changes. As next steps, benchmark your database directly with pgbench or sysbench, and watch device utilisation during a test with iostat -x 1 from the sysstat package.