
Switching to fish: what actually changes
The video above is DistroTube’s tour of fish, and it covers the feature list better than another feature list would. What follows is the part that only shows up after you have actually made fish your login shell: what improves, what breaks, and what you end up doing about it.
Everything here was checked against fish 4.6.0.
Two features do most of the work
Autosuggestions and syntax highlighting. Everything else fish offers is nice; these two are why people stop going back.
As you type, fish greys out the rest of the most recent matching command from
your history. Right arrow accepts it. In practice you stop retyping long
commands and stop reaching for Ctrl-R to find them.
The highlighting matters more than it sounds. A command that does not exist is red before you press enter. So is an unclosed quote, and a path that is not there. Most of my shell mistakes used to be typos I only found out about after running them — that category is mostly gone.
Neither needs configuring. They are on by default.
Installing it without breaking your login
Install fish, then make it your shell:
# Fedora
sudo dnf install fish
# Debian/Ubuntu
sudo apt install fish
The step people get wrong is the next one. chsh refuses any shell that is not
listed in /etc/shells, and the error is not obvious about why:
grep fish /etc/shells || echo /usr/bin/fish | sudo tee -a /etc/shells
chsh -s /usr/bin/fish
Log out and back in — chsh does not affect the session you run it from.
If that makes you nervous, don’t set it as your login shell at all. Put exec fish at the end of your terminal emulator’s startup command instead. You get
fish interactively, your login shell stays POSIX, and nothing that reads
$SHELL gets confused.
The incompatibility, and the one-line fix
Fish is not POSIX and does not try to be. Every install script on the internet
assumes otherwise. Anything with export FOO=bar, [[ ... ]], $?, or a
C-style for loop fails, usually with an error that does not explain itself.
There is exactly one coping strategy worth learning:
bash -c 'for f in *.md; do echo "${f%.md}"; done'
Keep fish for the interactive session, shell out to bash for anything you
copied from a README. Do not try to port it. This also applies to scripts you
write: a script with #!/bin/bash at the top runs under bash no matter what
your interactive shell is, so there is no reason to rewrite your existing ones.
Worth knowing: fish has quietly absorbed most of the syntax people expect.
&&, || and ! all work, and $(command) works alongside fish’s own
(command). The old advice to write and and or is out of date.
command -q rg && echo "ripgrep is installed"
echo "there are $(count $PATH) entries in PATH"
Config that earns its place
Fish reads ~/.config/fish/config.fish. There is no .bashrc/.profile
split to think about.
set -gx EDITOR nvim
set -gx PAGER less
fish_add_path ~/.local/bin
set -gx is fish’s export: -g global, -x exported. fish_add_path is the
one worth knowing — it appends to $PATH only if the entry is not already
there, so re-sourcing your config does not give you a $PATH with six copies of
the same directory.
$PATH being a real list rather than a colon-delimited string is a small thing
that keeps paying off:
count $PATH # 16
echo $PATH[1] # /home/zzz/.bun/bin
echo $PATH[-1] # last entry
Use abbreviations, not aliases
This is the one piece of fish-specific advice I would give someone on day one.
abbr -a gs --position command "git status"
abbr -a gco --position command "git checkout"
abbr -a bd --position command "bun run dev"
An abbreviation expands in place when you hit space or enter. You type gs, the
line becomes git status, and that is what runs and what lands in your history.
An alias hides the real command. Six months later you are reading shell history
trying to work out what gs meant at the time, or pasting it into a machine
that does not have your config. Abbreviations have neither problem — the
expansion is always visible before you commit to it, and history stays honest.
--position command stops the abbreviation firing when the word appears as an
argument rather than as the command.
Functions instead of scripts
Fish autoloads any function saved as ~/.config/fish/functions/NAME.fish, which
makes small utilities cheap enough that you actually write them:
function mkcd --description "mkdir -p and cd into it"
mkdir -p $argv[1] && cd $argv[1]
end
Define it at the prompt to try it, then funcsave mkcd to write it to the right
file. It is available in every new shell with no sourcing and no $PATH entry.
--description is not decoration — it is what shows up next to the function in
completions.
Completions from man pages
fish_update_completions
That parses the man pages on your system and generates completions from them. Every tool you have installed becomes tab-completable, including the ones nobody has written a completion script for. Run it once after installing fish, and again after a big package update.
The string builtin
Fish ships a real string manipulation builtin, which removes most of the reasons
to reach for sed or parameter expansion:
for f in notes.md todo.md
echo (string replace -r '\.md$' '' $f)
end
# notes
# todo
string split, string match -r, string join, string trim, string repeat
— all documented under string --help, all faster to get right than the sed
equivalent.
What I still use bash for
Anything that has to run on a machine I do not control, anything with a shebang, and anything copied from documentation. Fish is an interactive shell. Treating it as a scripting language you write portable code in is how people end up frustrated and switch back.
Keep that boundary and the rough edges mostly disappear.