Switching your login shell to fish looks harmless, but chsh run through sudo does not stop you from setting a shell that isn’t there. Ordinary users are refused a shell missing from /etc/shells, but root only gets warnings: on our Debian 13 box chsh exited 0, and the next SSH login failed with “Permission denied (publickey)”. Most guides to install fish shell on Linux treat that step as a one-liner, so here it comes after the install.
You’ll install fish on Debian, Ubuntu, Rocky Linux, Fedora and Arch, or the current release from the fish project’s OBS repository on Debian. Then you’ll make it the default shell, fix bash habits that fail in fish, and set up abbreviations, a prompt and Oh My Fish. The other distros’ packages were only checked in containers, and the Debian steps below were run on Debian 13 with fish 4.9.3 in October 2026.
Choose a fish package for your distro
Every major distro ships fish, but the versions are far apart, and Rocky Linux does not carry it at all until you add EPEL. We checked each one in October 2026, so you know what you’ll get before you run the install. The last column matters as much as the version, because only the Debian builds were used as a real login shell.
| Distro | Where fish comes from | Version we got | How far we tested it |
|---|---|---|---|
| Debian 13 | Debian main repository | 4.0.2 | Installed and used |
| Debian 13 | openSUSE OBS shells:fish:release:4 | 4.9.3 | Installed and used as the login shell |
| Ubuntu 26.04 | Ubuntu archive | 4.2.1 | apt candidate checked in a container |
| Rocky Linux 10 | EPEL (not in BaseOS or AppStream) | 4.2.0 | Package lookup in a container |
| Fedora 43 | Fedora repository | 4.2.0 | Installed in a container |
| Arch Linux | Arch repository | 4.9.3 | Installed in a container |
The distro package is the right default for most people, because it gets security updates with the rest of the system and needs no extra repository. We would only add the OBS repository on Debian, where the stock package is still on the 4.0 line while Arch and OBS ship 4.9. Even there, do it only if you want the current fish release on a box you manage yourself. Ubuntu 24.04 wasn’t checked, so run apt-cache policy fish there before you assume a version.
If you’re still choosing between fish and zsh, the difference that matters day to day is syntax. Fish is not POSIX compatible, so bash snippets you paste from documentation will fail in it (the errors are covered below), while zsh runs most of them unchanged. Our roundup of the best shells for Linux and macOS compares the options, and if zsh wins, the zsh and Oh My Zsh setup is the matching guide.
Install fish shell on Debian and Ubuntu
On Debian and Ubuntu the package is called fish and it lives in the default repositories, so there is nothing to add first. Refresh the package index and install it.
sudo apt update
sudo apt install fish
On Debian 13, apt pulled in seven packages next to fish. None of them is large, but on a minimal server image it helps to see the list before you confirm, because lynx and mailcap are not packages you would expect a shell to need.
The following NEW packages will be installed:
bzip2 fish fish-common libpcre2-32-0 lynx lynx-common mailcap xsel
The package also registers itself in /etc/shells. That is why the old manual step from older guides is not needed on Debian. A missing entry in that file is what makes chsh complain later, so confirm the version and the registration together.
fish --version
grep fish /etc/shells
Debian 13 reports the 4.0 line, and /usr/bin/fish is already listed as a valid login shell.
fish, version 4.0.2
/usr/bin/fish
On Ubuntu 26.04 the same two commands install the archive build. It was on the 4.2 line when we checked. If the version you got is new enough for you, skip the next section and go straight to switching the shell, because the OBS repository below is only needed when you want the newest release on Debian. For the newest release on Ubuntu, the fish project points to its own PPA instead, which we did not test:
sudo add-apt-repository ppa:fish-shell/release-4
sudo apt update
sudo apt install fish
Check the result with apt-cache policy fish afterwards, because the PPA build should now be the installed version, and if it isn’t, the archive package is still winning.
Install the latest fish from the OBS repository
The fish project publishes Debian builds on the openSUSE Build Service, and the shells:fish:release:4 channel follows the 4.x releases. Older guides, including the previous version of this one, added the plain shells:fish path with a key in /etc/apt/trusted.gpg.d. Avoid that layout, because a key in that directory is trusted for every repository on the system, while a Signed-By keyring limits it to this one source. The key step needs curl and gpg, which minimal Debian images may not have, so install them first.
sudo apt install curl gpg
Then download the key and convert it into its own keyring with gpg --dearmor.
sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://download.opensuse.org/repositories/shells:/fish:/release:/4/Debian_13/Release.key | gpg --dearmor | sudo tee /etc/apt/keyrings/shells_fish.gpg >/dev/null
Next, create a deb822 source file for the repository. That format keeps the URL, the suite and the keyring together in one small block, so the file is also easy to remove cleanly later.
sudo vim /etc/apt/sources.list.d/shells-fish.sources
Add these four lines. The Debian_13 part of the URL is the release, so a Debian 12 box would need its own path, which we did not test.
Types: deb
URIs: https://download.opensuse.org/repositories/shells:/fish:/release:/4/Debian_13/
Suites: /
Signed-By: /etc/apt/keyrings/shells_fish.gpg
Refresh the index and ask apt which fish it would install now. The policy output is the quickest way to see which repository won.
sudo apt update
apt-cache policy fish | head -8
Both sources have priority 500, so apt picks the higher version. The candidate should come from download.opensuse.org, while the starred line is whatever you installed from Debian earlier. Nothing pins the OBS repository, so a future Debian package with a higher version would win the same way.
fish:
Installed: 4.0.2-1
Candidate: 4.9.3-1
Version table:
4.9.3-1 500
500 https://download.opensuse.org/repositories/shells:/fish:/release:/4/Debian_13 Packages
*** 4.0.2-1 500
500 mirror+file:/etc/apt/mirrors/debian.list trixie/main amd64 Packages
If the candidate is still the Debian version, apt update didn’t use the new source, so read its output for an error on the opensuse.org line before going further. Once the OBS build is the candidate, install it. apt upgrades the package in place, so there is no need to remove the Debian build first.
sudo apt install fish
On our box apt unpacked the OBS build over the Debian package, and fish --version confirmed the upgrade:

One side effect shows up later: the OBS build replaced fish but left Debian’s fish-common from the 4.0 line installed next to it. That did not break anything in our testing. It is still why the uninstall step at the end purges both packages, because removing fish alone leaves the older fish-common behind.
Install fish on Rocky Linux, Fedora and Arch
Rocky Linux 10 doesn’t ship fish in BaseOS or AppStream, so a plain dnf install fish finds nothing. It comes from EPEL, which had the 4.2 line. If you have not set EPEL up before, our guide to enabling EPEL on Rocky Linux and AlmaLinux covers that step.
sudo dnf install epel-release
sudo dnf install fish
Fedora has fish in its main repository, so no extra repository is needed, and the install in a Fedora 43 container came out on the 4.2 line. A lot of people land here from a Fedora search. The fish and Oh My Fish guide for Fedora walks through that distro end to end.
sudo dnf install fish
Arch packaged the same release as the OBS repository, so it is the one distro in our checks where the stock package is already current.
sudo pacman -S fish
On Arch, use sudo pacman -Syu fish instead if your package database is out of date, because Arch does not support partial upgrades. We checked these three in containers, so we did not verify that each one adds fish to /etc/shells. That check is the first thing the next section does, and it matters more than the version number. Without the entry, chsh warns about an invalid shell, exactly as it did in our lockout test.
Set fish as your default shell
Run these checks before chsh, because a wrong shell path does not fail at the command, it fails at your next login. By then the session you made the change from may be closed, so the checks have to come first. Do them in this order:
- Get the real path with
command -v fish - Confirm that path is listed in
/etc/shells - Keep a second root or sudo session open until a fresh login works
The first two checks take one line each, and the third is simply a terminal you leave alone until the end.
command -v fish
grep fish /etc/shells
On our Debian box both lines came back identical:
/usr/bin/fish
/usr/bin/fish
Both should print the same path, /usr/bin/fish on Debian. If grep prints nothing, add that path to /etc/shells before going further, because a shell missing from that file is exactly what chsh warned about in the lockout below. Run this from bash, and only when the grep came back empty (it is not needed on Debian):
command -v fish | sudo tee -a /etc/shells
With the path in both places, the account change itself is safe to make.
Now change the login shell for your account and read back what was stored in the user database. We use sudo here because a plain chsh asks for your password. Cloud image accounts like ours have none, so without sudo the change fails with a PAM error.
sudo chsh -s /usr/bin/fish "$USER"
getent passwd "$USER" | cut -d: -f7
The last field of the passwd entry is the login shell, and it should now read:
/usr/bin/fish
Do not close your current session yet. Open a new SSH connection and check the shell from inside it, since only a fresh login proves that sshd accepts the new shell. If it fails, fix it from the session you kept open.
echo $SHELL; echo $FISH_VERSION
Our new login came up in fish, which proves the change took effect for real logins and not only for the passwd file:
/usr/bin/fish
4.9.3
Once that second login works, you can close the old session. The change applies to new logins only, so terminals that were already open stay in bash until you reconnect.
chsh: Warning: /usr/local/bin/fish does not exist
This is the lockout, and we hit it on purpose. Pointing chsh at /usr/local/bin/fish, which is the default location for a fish build from source but not where Debian puts it, printed two warnings and still returned success:
chsh: Warning: /usr/local/bin/fish is an invalid shell
chsh: Warning: /usr/local/bin/fish does not exist
rc=0
The next SSH login failed with Permission denied (publickey), which sends you looking at keys when the key is fine. The real reason is only in the SSH server’s journal. sshd refuses any user whose login shell is missing, so it treats the account as an invalid user, as the second line shows:
Oct 20 11:53:27 deb01 sshd-session[8455]: User josphat not allowed because shell /usr/local/bin/fish does not exist
Oct 20 11:53:27 deb01 sshd-session[8455]: Connection closed by invalid user josphat 10.0.1.1 port 16943 [preauth]
The fix is to set a shell that exists from a session that still works, which is why the second session matters. From that root or sudo session, or from the VM console if you have nothing else, put the account back on a valid path (we fixed ours through the hypervisor’s guest agent).
sudo chsh -s /usr/bin/fish josphat
Replace josphat with the locked-out user, or use /bin/bash if you’d rather sort fish out later. The login works again straight away, because sshd checks the shell on every connection.
Fix bash commands that fail in fish
Fish runs a lot of everyday bash syntax unchanged, but not the control structures or the special variables. The errors do not always say “this is bash syntax”, so the message alone may not tell you what to change. This is what we typed into fish 4.9 and what came back.
| Bash habit | Result in fish | Fish equivalent |
|---|---|---|
echo $(date +%Y) | Works | echo (date +%Y) also works |
true && echo ok | Works | Same |
FOO=bar env | Works | Same |
export GREETING=hi | Works | set -gx GREETING hi |
$$ and $? | Error | $fish_pid and $status |
if [[ ... ]]; then ... fi | Error | Block closed with end |
for ...; do ...; done | Error | for i in 1 2 3; echo $i; end |
| Here-documents | Error | Run the snippet with bash -c |
source ~/.bashrc | Error | Move settings into config.fish |
echo !! | Prints !! | No history expansion |
The !! row is the sneaky one, because nothing fails: fish just prints the two characters, so sudo !! won’t repeat your last command. The rows marked Error each have a message you can search for, and the fixes are below.
fish: $$ is not the pid. In fish, please use $fish_pid.
Fish uses named variables where bash uses punctuation. Our first command after the switch was ps -p $$ -o comm=, and fish refused to run the line at all and pointed at the $$, so nothing before it on that line ran either. Use $fish_pid instead, and in scripts you share with bash users, keep the bash version inside a bash -c call.
fish: $? is not the exit status. In fish, please use $status.
Same rule, different variable. echo rc=$? fails, and echo rc=$status prints the exit code of the last command. This one bites in one-liners copied from bash documentation, where $? usually sits at the end, so the error points at the last character of the line.
fish: Missing end to balance this if statement
Fish has no then, fi, do or done. Every block closes with end, so a bash if or for line reads to fish as a block that never finishes, and you get “Missing end to balance this if statement” or “Missing end to balance this for loop”. The caret under the error points at the keyword that opened the block, not at the part you need to change. The screenshot below recreates the error lines from our lab log in one window:

The fish loop drops do and done and closes with end:
for i in 1 2 3; echo $i; end
It prints the three values, one per line. For simple loops that is the whole change: drop do and done, and close with end.
1
2
3
When you don’t want to translate a snippet, run it in bash from inside fish. Escape the $ inside the double quotes, because fish would otherwise expand $i itself before bash ever sees it.
bash -c "for i in 1 2 3; do echo \$i; done"
That printed 1, 2 and 3 as well. It’s the escape hatch we’d use for install scripts and long one-liners from vendor docs, since rewriting them for fish buys you nothing. Scripts that start with #!/bin/bash are not affected at all, because they run under bash whatever your login shell is.
fish: Expected a string, but found a redirection
Fish has no here-documents, so cat <<EOF stops with this message and the caret points at the redirection. Feed the text another way, for example with printf piped into the command, or run the whole block with bash -c.
‘case’ builtin not inside of switch block
This one appears when you source ~/.bashrc from fish, out of habit or from an old setup note. Fish tried to parse the file as fish code, hit the case $- in test near the top of Debian’s default bashrc, and gave up with source: Error while reading file '/home/josphat/.bashrc'. Bash startup files can’t be shared with fish, so move the settings you need, such as PATH entries, variables and aliases, into fish’s own config as shown in the next section.
Configure fish: PATH, abbreviations and functions
Fish keeps its user configuration in ~/.config/fish, and it created the directory on first run with four entries:
config.fishfor startup commandsconf.d/for snippets that plugins drop infunctions/for one file per saved functioncompletions/for your own completions
Most of what you’d put in a bashrc doesn’t need config.fish at all, because fish stores PATH entries and universal variables for you. To add a directory to your PATH permanently, use fish_add_path rather than editing PATH by hand. The setting survives logouts without any file edits, as the check below shows.
mkdir -p ~/go/bin
fish_add_path ~/go/bin
set -U | grep fish_user_paths
The mkdir is not optional, because fish_add_path ignores directories that do not exist and adds nothing. Since fish 4.0 it also prints a line describing the change when you run it at the prompt, which our non-interactive lab run did not show. The directory lands in the universal fish_user_paths variable, which every fish session reads, and it was in $PATH straight away.
fish_user_paths /home/josphat/go/bin
Universal variables are stored in ~/.config/fish/fish_variables. A test set -U demo_color blue was visible in a second fish process and appeared in that file as SETUVAR demo_color:blue, and set -e -U demo_color removed it again.
Abbreviations are where older guides go wrong. abbr --add gs git status works in the session you type it in, but a new fish process listed no abbreviations at all, because abbreviations aren’t universal in current fish. Put them in config.fish instead, which fish reads at the start of every interactive session.
vim ~/.config/fish/config.fish
On first run fish already writes an 87-byte config.fish holding an empty if status is-interactive ... end block, so put your lines inside that block rather than adding a second one. The check means scripts that start fish don’t pay for them. We set EDITOR in the same block. The -x flag exports it, so programs you launch from fish see it too.
if status is-interactive
abbr --add gs git status
abbr --add k kubectl
set -gx EDITOR vim
end
Start a fresh interactive fish to prove the file is read. The -i flag matters here, because the abbreviations sit inside the interactive check and a plain fish -c would skip them.
fish -i -c "abbr --list; echo EDITOR=\$EDITOR"
Both abbreviations and the variable came back from the file:
gs
k
EDITOR=vim
Aliases work the other way round. In fish, alias defines a function, and funcsave writes it to functions/, where fish loads it on demand in every new shell. Without funcsave, an alias typed at the prompt is gone when you log out, so save the ones you want to keep.
alias ll "ls -lh"
funcsave ll
cat ~/.config/fish/functions/ll.fish
The saved file is a normal fish function that passes your arguments through:
funcsave: wrote /home/josphat/.config/fish/functions/ll.fish
function ll --wraps='ls -lh' --description 'alias ll ls -lh'
ls -lh $argv
end
A new fish session had ll available without anything in config.fish. That’s the pattern we would follow for anything longer than an abbreviation, because one file per function keeps config.fish short and fish only reads each file when the command is first used.
Change the prompt and generate completions
Fish ships with a set of prompts you can switch between from the command line, so you do not need a framework just to change how the prompt looks. The chosen prompt is saved as a function, so it can be edited or deleted like any other. List them.
fish_config prompt list
Our install listed thirteen built-in prompts:
acidhub
arrow
astronaut
default
disco
informative
informative_vcs
minimalist
nim
pythonista
scales
simple
terlar
Saving one asks for confirmation before it writes functions/fish_prompt.fish. In a script, or anywhere stdin isn’t a terminal, it prints “Not overwriting” and changes nothing, so pipe the answer in when you automate it. Our first save ran without a terminal, printed only that line, and left the default prompt in place.
echo y | fish_config prompt save informative_vcs
With the confirmation supplied, fish saved the prompt as a function:
Overwriting
funcsave: wrote /home/josphat/.config/fish/functions/fish_prompt.fish
Colour themes work the same way through fish_config theme list, which starts with ayu-mirage, ayu and the base16 sets. Running fish_config with no arguments opens the web configuration page in a browser, which we did not test on this headless server. If you want one prompt across bash, zsh and fish, Starship is the better fit than any of these.
Fish can also build completions for commands that don’t ship any by parsing your installed man pages. It only knows the man pages present when it runs, so re-run it after you install new tools. On our VM it finished in about five seconds.
fish_update_completions
ls ~/.cache/fish/generated_completions | wc -l
On our Debian box it parsed 1973 man pages and wrote 1423 completion files (the output below joins the first line of that run with the count from a later ls, since our first count looked in the old path). Note the path in the first line: current fish writes them under ~/.cache/fish, not ~/.local/share/fish as older docs say:
Parsing man pages and writing completions to /home/josphat/.cache/fish/generated_completions
1423
Your count will differ, because it depends on how many man pages the installed packages ship, and a minimal server image carries far fewer than a desktop.
Install Oh My Fish
Oh My Fish (OMF) is a package manager for fish themes and plugins. You do not need it for anything above, but it’s the quickest way to try themes such as bobthefish. The installer clones from GitHub, so git and curl must be present first.
sudo apt install git curl
Download the installer to a file instead of piping it straight into fish, so you can read it before it runs. Then run it from fish. The two flags skip the installer’s questions, which you can leave off when you run it by hand. The OMF project also publishes an install.sha256 checksum next to the installer for verifying the download, a step we did not run.
curl -fsSL https://raw.githubusercontent.com/oh-my-fish/oh-my-fish/master/bin/install -o /tmp/omf-install
fish /tmp/omf-install --noninteractive --yes
The installer writes its bootstrap into conf.d, which is how OMF loads in every new shell:
Writing bootstrap to /home/josphat/.config/fish/conf.d/omf.fish...
Setting up Oh My Fish configuration...
Updating https://github.com/oh-my-fish/packages-main master... Done!
Installing package default
✔ default successfully installed.
Installation successful!
Check the version, then install a theme. Themes come from the packages-main index that the installer just refreshed.
omf version
omf install bobthefish
OMF reported itself as version 8 and installed the theme cleanly:
Oh My Fish version 8
Updating https://github.com/oh-my-fish/packages-main master... Done!
Installing package bobthefish
✔ bobthefish successfully installed.
Our Oh My Fish configuration guide goes through themes and plugins in detail. Fisher is the other plugin manager people use with fish, but we did not test it here, so we can’t tell you how it compares on this box. If all you wanted was a nicer prompt, the built-in prompts above do that without OMF.
Switch back to bash and uninstall fish
Going back is two steps: change the login shell first, then remove the package, never the other way round, or you’re back in the lockout above. A plain chsh from a cloud image account failed on our box, because the account has no password to give:
Password: chsh: PAM: Authentication failure
Use sudo for the change, and read back the stored shell to confirm it.
sudo chsh -s /bin/bash "$USER"
getent passwd "$USER" | cut -d: -f7
The login shell should now read /bin/bash. Before you purge anything, remove OMF if you installed it, and run omf destroy from a real terminal session, since without a terminal it failed on /dev/tty and left OMF in place. Then log out and back in so your session is bash, and purge both packages along with the OBS source and key if you added them.
sudo apt purge fish fish-common
sudo rm -f /etc/apt/sources.list.d/shells-fish.sources /etc/apt/keyrings/shells_fish.gpg
grep fish /etc/shells || echo no-fish-in-etc-shells
The purge also took the /etc/shells entry with it, so nothing else needs cleaning in system files:
Removing fish (4.9.3-1) ...
Purging configuration files for fish (4.9.3-1) ...
Purging configuration files for fish-common (4.0.2-1) ...
no-fish-in-etc-shells
Your own files stay behind after the purge, because apt does not touch home directories. The three fish directories were still there on our box, and OMF leaves its own two behind if you installed it:
~/.config/fishwith config.fish, functions and saved prompts~/.local/share/fishwith command history~/.cache/fishwith the generated completions~/.local/share/omfand~/.config/omfif OMF was installed
Keep them if you might come back, since a reinstall picks up your functions, abbreviations and history where you left them. Otherwise delete them. Nothing outside your home directory refers to them, so removing them cannot affect bash or other users.
rm -rf ~/.config/fish ~/.local/share/fish ~/.cache/fish ~/.local/share/omf ~/.config/omf
On Rocky Linux, Fedora and Arch the package manager’s remove command does the same job, but we only ran the removal on Debian. Whichever distro you’re on, the rule from the lockout section holds in both directions: the shell in the passwd file has to exist before you log out.