The Shell Game Part 4: Fish, the Outsider
Part 3 left Zsh looking like a clear upgrade over Bash — full compatibility with a real, years-old .bash_aliases file, plus autosuggestions and syntax highlighting for two apt install commands. Fish is a different proposition entirely. It isn’t POSIX-compatible, so this post can’t run the same “does your existing stuff still work” test from Part 2 and Part 3 and expect a clean pass. What it gets instead is a real look at where that incompatibility actually bites, and what Fish gives you in exchange.
Fish was already on debian1 from an earlier apt run, so there’s no fresh install output to show — fish --version reports fish, version 4.0.2, which is what the rest of this post is testing against.
The compatibility test fails, loudly
Sourcing the same real .bash_aliases file that ported to Zsh without a single change:
$ fish
$ source ~/.bash_aliases
- (line 1): ${ is not a valid variable in fish.
function path --wraps 'echo -e ${PATH//:/\\n}' --description 'alias path=echo -e ${PATH//:/\\n}'; echo -e ${PATH//:/\n} $argv
^
from sourcing file -
called on line 70 of file /usr/share/fish/functions/alias.fish
in function 'alias' with arguments 'path=echo\ -e\ \$\{PATH//:/\\n\}'
called on line 21 of file ~/.bash_aliases
from sourcing file ~/.bash_aliases
source: Error while reading file "<stdin>".
The exact same path alias that Zsh inherited cleanly in Part 3 — alias path='echo -e ${PATH//:/\n}', using Bash’s ${VAR//pattern/replacement} parameter expansion — is what breaks Fish’s parser outright. Fish doesn’t recognise ${...} substitution syntax at all; it has its own, unrelated syntax for string manipulation.
What’s worth noting is what didn’t break. Fish’s alias builtin converts each alias name='...' line into a Fish function one at a time as the file is read, rather than parsing the whole file as one Bash script. So simpler aliases defined earlier in the file survive even though the file as a whole fails partway through:
$ type mnt
mnt is a function with definition
# Defined via `source`
function mnt --wraps='mount | awk -F\' \' \'{ printf "%s\\t%s\\n",$1,$3; }\' | column -t | egrep ^/dev/ | sort' --description '...'
mount | awk -F' ' '{ printf "%s\t%s\n",$1,$3; }' | column -t | egrep ^/dev/ | sort $argv
end
$ mnt
/dev/sda1 /
$ type ll
ll is a function with definition
# Defined via `source`
function ll --wraps='ls -al' --description 'alias ll=ls -al'
ls -al $argv
end
$ ll
total 172
drwx------ 16 anon anon 4096 Sep 12 15:04 ./
...
So the honest answer isn’t a flat “your aliases don’t work in Fish” — plain alias name='some pipeline' lines get auto-translated into working Fish functions just fine, quoting and pipes included. It’s specifically Bash’s own expansion syntax, the kind that shows up once a .bash_aliases file has been added to over the years, that Fish has no path for at all.
A Bash for loop doesn’t error — it hangs
This is the one that actually surprised me. Pasting a standard Bash-style loop straight into Fish:
$ for i in $(seq 1 3); do echo $i; done
No output, no error, no prompt back. Fish’s for loop doesn’t use do/done — it’s for ... ; ... ; end — so it parsed for i in $(seq 1 3); as the start of a valid block and is sitting in a multi-line continuation waiting for a closing end that was never going to arrive. do and echo $i; done just got swallowed as the loop body. The fix is Ctrl+C to break out, then writing it the way Fish actually expects:
$ for i in (seq 1 3)
echo $i
end
1
2
3
That’s a genuinely different failure mode to anything Bash or Zsh produced across this whole series. Both of those either ran a piece of syntax correctly or threw an immediate, specific error. Fish, here, does neither — it just waits, silently, for syntax that isn’t coming. For an audience that spends a fair amount of time pasting one-liners from documentation, forums, or someone else’s terminal session, that’s worth knowing about before it happens mid-task rather than in a test.
Completion works instantly, no packages required
Re-running the same test from Part 2 (Bash, needed bash-completion installed) and Part 3 (Zsh, needed compinit):
$ sudo apt install fire<TAB>
Fish drops straight into a colourised, paginated completion menu — package names in one column, each with its actual description alongside it (firebird3.0-common — common files for firebird 3.0 server, client and utilities, and so on through the firefox-esr-l10n-* locale packages), with a rows 1 to 44 of 136 counter at the bottom letting you page through the rest. No package install, no compinit, no zstyle tuning — this is what Fish ships with by default. It’s a meaningfully richer experience than either of the previous two shells managed without extra configuration.
Autosuggestions, also out of the box

Typing ls -la and pausing produces that greyed suggestion — pulled straight from shell history — with no zsh-autosuggestions equivalent package needed. This is the same feature Zsh needed a separate apt install for in Part 3; Fish just has it running from the first launch.
Globbing: ** recurses here too
Same globtest directory used in Parts 2 and 3, a/deeper/four.conf included:
$ cd ~/globtest
$ echo */*.conf
a/one.conf b/two.conf
$ echo **/*.conf
a/deeper/four.conf a/one.conf b/two.conf
Same result as Zsh — ** recurses by default, no option needed, unlike Bash which needs globstar switched on. Whatever else separates these three shells, that particular piece of modern glob behaviour is now something both Zsh and Fish get right out of the box and Bash doesn’t.
Where this leaves things
Fish is, without qualification, the nicest shell in this series to sit down and start typing in. Rich completions, working autosuggestions, syntax highlighting, all present from a stock install with nothing configured — Zsh needed two packages and a few .zshrc lines to get close to this in Part 3, and Bash never got there at all. If this series were graded purely on “what does day-one interactive use feel like,” Fish would win it outright.
But it isn’t graded on that alone, and the incompatibility isn’t a minor footnote. A real .bash_aliases file — not a contrived example, the one I’ve actually built up over years — broke on the first genuinely useful alias in it. A standard Bash loop construct didn’t just fail to run, it hung silently waiting for syntax that was never coming. For a network engineer’s or pentester’s daily work — SSH sessions onto other people’s boxes that don’t have Fish installed, one-liners copied straight out of documentation or someone else’s terminal, exploit PoCs and reverse shell payloads that assume sh/bash syntax — that incompatibility isn’t theoretical. It’s the difference between a shell you can drop into anywhere and one that needs to already be there, configured, before it’s useful.
Which is exactly the verdict Part 1 landed on before any of this was tested: Zsh for daily interactive use, Bash for anything that has to run on a box I don’t control or a script that has to run on someone else’s, and Fish left as a genuinely impressive tool I’m not going to make my daily driver, because the cost of its incompatibility is higher than the interactive experience it buys back.
What’s next
Part 5 wraps the series up as a migration guide — porting the real dotfiles and aliases used across this series onto the rest of the Debian fleet, keeping #!/bin/bash shebangs on anything that has to run as a script, and a look at exactly how Kali’s own shipped ~/.zshrc differs from the bare Zsh setup Part 3 was tested against, since getting debian1’s Zsh to actually feel like Kali’s default turned out to be its own small investigation.