Modern Linux Tools Part 2: Files With eza, fd, bat, dust and duf
This is Part 2 of the Modern Linux Tools series. Each part takes a classic command-line tool, installs its modern replacement on a clean Debian 13 VM, and runs both against the same data. I report the numbers as they came out, including the ones that go against the replacement.
This part covers the five tools you touch every day: ls, find, cat, du and df, against eza, fd, bat, dust and duf.
The test setup
Everything ran on a throwaway Debian 13 (trixie) VM with 2 vCPUs, 2 GB of RAM and kernel 6.12.115, fully updated before I installed anything. All five tools come from the Debian repositories, so there is no cargo and no downloaded binaries.
sudo apt install eza fd-find bat du-dust duf ripgrep hyperfine git
| Tool | Replaces | Debian package | Version tested |
|---|---|---|---|
| eza | ls | eza | 0.21.0 |
| fd | find | fd-find | 10.2.0 |
| bat | cat | bat | 0.25.0 |
| dust | du | du-dust | 1.2.0 |
| duf | df | duf | 0.8.1 |
Two of these install under different names on Debian. fd is installed as fdfind and bat as batcat, because other packages already own the names fd and bat. If you want the short names, link them into your own ~/.local/bin:
mkdir -p ~/.local/bin
ln -sf "$(command -v fdfind)" ~/.local/bin/fd
ln -sf "$(command -v batcat)" ~/.local/bin/bat
Debian’s default ~/.profile already adds ~/.local/bin to your PATH when the directory exists. Log out and back in, or run source ~/.profile. The commands in the rest of this post use the real binary names, fdfind and batcat, so they work without the links.
The test data is a tree of 105,003 files, built from a fixed random seed so it can be rebuilt exactly:
- 20
sitedirectories, each with 25devdirectories of 200 small files. The extensions areconf,log,py,md,txt,json,yamlandcfg. - 5,000 small
.ofiles in abuild/directory. - Three large binary files of 200 MB, 80 MB and 40 MB.
- A git repository with a
.gitignorecontainingbuild/and*.log, plus two uncommitted files.
Timings come from hyperfine with warm caches. They are means from a small VM, so read them as ratios rather than absolute speeds.
eza against ls
For a plain long listing the two are close, and eza reads better. The permission column starts with a dot for a regular file, the size column is right-aligned, and the date drops the year and the seconds.
$ ls -lh
-rw-rw-r-- 1 anon anon 200M Nov 2 19:21 big0.bin
-rw-rw-r-- 1 anon anon 80M Nov 2 19:21 big1.bin
drwxrwxr-x 2 anon anon 4.0K Nov 2 19:21 dev00
$ eza -l
.rw-rw-r-- 210M anon 2 Nov 19:21 big0.bin
.rw-rw-r-- 84M anon 2 Nov 19:21 big1.bin
drwxrwxr-x - anon 2 Nov 19:21 dev00
Look at the size of big0.bin. It is 200M in ls -lh and 210M in eza -l, and it is the same file. The file is exactly 209,715,200 bytes, which is 200 MiB.
ls -h counts in powers of 1024. eza counts in powers of 1000 by default, so 209,715,200 bytes is 210 MB. ls --si gives the same 210M as eza, which confirms it. If you move from ls -lh to eza -l without knowing this, every size you read is about 5% larger than the number you are used to. The fix is --binary, which prints 200Mi:
$ eza -l --binary big0.bin
.rw-rw-r-- 200Mi anon 2 Nov 19:22 big0.bin
The git column is the feature that ls has no answer for. eza -l --git shows the status of each file next to it. -N means untracked:
$ eza -l --git site03 | grep new.txt
.rw-rw-r-- 0 anon 2 Nov 19:22 -N new.txt
eza --tree replaces the separate tree command. eza -1 produced output identical to ls -1 on a test directory, so a simple listing in a pipe is safe.
Where eza loses
Recursive listing of the whole tree:
| Command | Mean time |
|---|---|
ls -R | 75.5 ms |
eza -R | 675.9 ms |
eza -R is about nine times slower than ls -R on 105,000 files. For an interactive listing of one directory you will never notice. In a script that walks a big tree, you will.
The git integration has a much bigger cost. --git-ignore hides anything your .gitignore excludes, and it is useful on small trees. On this one it was not usable:
| Command | Result | Time |
|---|---|---|
eza -R site05 | 5,075 lines | 0.056 s |
eza -R --git-ignore site05 | 4,420 lines | 10.7 s |
eza -R --git-ignore (whole tree) | 88,996 lines | 3 min 25 s |
The whole-tree run used one CPU at 100% for the full three and a half minutes. The same directory without --git-ignore took 56 ms, so the ignore check is the cost, at roughly 2 ms per file. This is version 0.21.0, so a later release may change it. For now, do not put --git-ignore in an alias for recursive listings of large repositories.
My verdict: use eza as your interactive ls, keep ls in scripts, and set --binary if you read sizes carefully.
fd against find
fd changes the defaults. By default it skips hidden files and anything matched by .gitignore. That is the right behaviour for a developer searching a project, and a surprise for anyone who expects find semantics.
find . -name "*.o" -> 5000 results
fdfind -e o -> 0 results
fdfind -e o -I -> 5000 results
find . -name "*.log" -> 12569 results
fdfind -e log -> 0 results
fdfind -e log -I -> 12569 results
find . -name HEAD -> 2 results (inside .git)
fdfind HEAD -> 0 results
fdfind -H HEAD -> 2 results
-I turns off ignore files, -H includes hidden files, and -HI together gives you back the search that find does. If fd returns nothing when you expect something, check these two flags first.
Matching *.conf returns 12,478 files in every variant, so the results agree when nothing is ignored. The timings:
| Search | Command | Mean time |
|---|---|---|
| Extension | find . -name "*.conf" | 82.9 ms |
| Extension | fdfind -e conf | 105.6 ms |
| Extension | fdfind -e conf -HI | 66.2 ms |
| Regex on name | find . -regex ".*file00[1-3]\.py" | 193.6 ms |
| Regex on name | fdfind 'file00[1-3]\.py' | 101.6 ms |
On a plain name match with default settings, fd was slower than find, because it reads and applies the ignore rules. With the ignore handling turned off it was about 25% faster. On a regular expression it was nearly twice as fast. So fd wins on regex searches and on convenience, and it does not win on a simple glob.
Running a command on every match is where the two tools differ most:
| Command | Mean time |
|---|---|
find . -name "*.conf" -exec wc -l {} + | 288.6 ms |
fdfind -e conf -X wc -l | 251.5 ms |
find . -name "*.conf" -exec wc -l {} \; | 19.04 s |
fdfind -e conf -x wc -l | 7.13 s |
Both tools have a batched form (find ... {} + and fd -X) that passes many files to one process, and a per-file form (find ... {} \; and fd -x) that starts one process per file. Batched is 66 times faster for find and 28 times faster for fd. In the per-file form, fd runs its jobs in parallel and finished in a little over a third of the time find needed. On this 2-vCPU VM the parallelism is limited, so a machine with more cores should do better.
A few differences in syntax matter:
- The argument is a regular expression, not a glob.
fdfind '*.conf'fails withregex parse error ... repetition operator missing expression. Use-e conffor an extension or-g '*.conf'for a glob. - The first argument is the pattern and the second is the path, which is the reverse of
find.fdfind conf site00searchessite00. - It is case-insensitive until your pattern contains an uppercase letter. I tested this with a real file.
fdfind file006.conf site00/dev00found it,fdfind FILE006.CONF site00/dev00found nothing, andfdfind -i FILE006.CONF site00/dev00found it again. - A pattern containing
/is refused with an error message. Use--full-pathto match against the whole path.
My verdict: fd is the better tool for searching a project by hand, mainly for the ignore handling and the shorter syntax. Keep find for scripts and for searches where you need every file, including ignored and hidden ones.
bat against cat
bat prints a file with syntax highlighting, line numbers and a git change marker. Its most important design decision is what it does when you pipe it. I compared the output of batcat and cat on the same files, piped through head, and the bytes were identical. When the output is not a terminal, bat drops the decoration and behaves like cat.
That also means the decorations vanish if you test them through a pipe. This command prints nothing but the file:
$ batcat --style=numbers,header site02/dev03/file001.conf | cat -A
echo more$
Add --decorations=always to force them on:
$ batcat --decorations=always --color=never --style=numbers,header,changes file001.conf
File: site02/dev03/file001.conf
1 echo more
The change marker compares the file with the git index. After I appended a line to a tracked file, the new line carried a +:
16 line 0
17 line 0
18 + extra line
batcat -A shows non-printing characters. A line containing a tab, a trailing space and a carriage return rendered like this, which is easier to read than cat -A:
a├─┤b·␍␊
Where bat loses
I timed both on a 105 MB file of 1.5 million lines, with the output sent to /dev/null:
| Command | Mean time |
|---|---|
cat | 17.3 ms |
batcat --style=plain --color=never | 1.417 s |
batcat --style=full --color=always | 4.971 s |
Even with every feature switched off, bat takes about 80 times longer than cat, because it processes the file line by line. With full decoration it takes nearly five seconds. That does not matter when you read a configuration file, and it matters a lot if you alias cat to bat and then run a script that cats a large file.
My verdict: use bat to read files and cat to move data. A good compromise is an alias with a different name, such as alias b=batcat, and leave cat alone.
dust against du
du prints a flat list and leaves the sorting to you. dust draws a tree with bars, sorted by size, and keeps it to one screen.
$ du -h --max-depth=1 . | sort -rh | head -6
1.1G .
340M ./site01
331M ./.git
40M ./site00
20M ./site19
20M ./site18
$ dust -d 1 -n 6 .
19M ┌── site18│██ │ 2%
19M ├── site19│██ │ 2%
19M ├── site02│██ │ 2%
39M ├── site00│███ │ 4%
330M ├── .git │██████████████████ │ 31%
339M ├── site01│██████████████████ │ 32%
1.0G ┌─┴ . │███████████████████████████████████████████████████████ │ 100%
The two tools agree, apart from rounding. du -h rounds up (1.1G) and dust shows 1.0G for the same tree. Both measure disk blocks, not file lengths. This tree holds 105,000 small files and each one takes a whole 4 KiB block, so the disk usage is far above the real content. Compare the three measures:
| Measure | Size |
|---|---|
du -sh . (disk blocks) | 1.1G |
dust -d 0 . (disk blocks) | 1.0G |
du -sh --apparent-size . (bytes of content) | 667M |
dust -s switches to apparent size, the same as du --apparent-size. If a directory of many small files looks too large, this is why.
Speed was a small win for dust:
| Command | Mean time |
|---|---|
du -s . | 240.5 ms |
dust -d 0 . | 192.6 ms |
du -h --max-depth=1 . | 241.2 ms |
dust -d 1 . | 192.8 ms |
dust is about 25% faster. It also has useful filters that du lacks: -r for a reversed tree, -X to exclude a path, -e to filter by regex, -F to list only the largest files, and -f to rank directories by file count instead of size.
My verdict: dust is a clear improvement for the question “what is using my disk”. I would use it every time.
duf against df
duf replaces df with a table that groups filesystems by kind. Run with no arguments on this VM, it printed a table of 2 local devices followed by a table of 11 special devices (/dev, /run, /dev/shm and the like). --only local keeps just the first group:
$ df -h -x tmpfs -x devtmpfs
Filesystem Size Used Avail Use% Mounted on
/dev/sda1 11G 2.2G 8.0G 22% /
/dev/sda15 124M 8.9M 115M 8% /boot/efi
$ duf --only local
╭──────────────────────────────────────────────────────────────────╮
│ 2 local devices │
├────────────┬────────┬──────┬────────┬────────┬──────┬────────────┤
│ MOUNTED ON │ SIZE │ USED │ AVAIL │ USE% │ TYPE │ FILESYSTEM │
├────────────┼────────┼──────┼────────┼────────┼──────┼────────────┤
│ / │ 10.6G │ 2.2G │ 8.0G │ 20.3% │ ext4 │ /dev/sda1 │
│ /boot/efi │ 123.7M │ 8.9M │ 114.8M │ 7.2% │ vfat │ /dev/sda15 │
╰────────────┴────────┴──────┴────────┴────────┴──────┴────────────╯
The numbers match df, and the filesystem type sits in the table without a second command. The grouping saves you from writing a df -x exclusion list. I did not time duf, because the command is too quick for the difference to matter.
My verdict: a small convenience. On a server with many mounts it is worth having.
Summary
| Tool | Replaces | Faster? | Verdict |
|---|---|---|---|
| eza | ls | 9x slower recursively | Use interactively, keep ls in scripts, avoid --git-ignore on large trees |
| fd | find | Slower on a glob, 2x faster on regex | Use for project searches, remember -HI |
| bat | cat | About 80x slower | Use for reading, never for moving data |
| dust | du | About 25% faster | Replace du |
| duf | df | Not measured | Small convenience |
Two of the five are drop-in improvements, dust and duf. The other three change behaviour in ways that can surprise you. eza counts in powers of 1000, fd hides ignored and hidden files, and bat turns its decoration off when piped. None of that is a defect, but it means the new tool is not always a safe alias for the old one.
The next part covers search: ripgrep against grep, and sd against sed.