7 minute read

Recently I’ve switched from Z Shell (aka Zsh) to Fish, after being a Zsh user since 2008. The experience has been pretty good overall and here I’ll share a few tips for everyone looking to make the change.

I’ll cover both the general setup and some of the tools I’m using on a day to day basis (e.g. rbenv, nvm, etc).

Note: You can read more about my Zsh setup here.

Let’s go!

fisher (plugin manager)

As you’ll see below, occasionally you’ll need to install Fish plugins. I recommend the fisher plugin manager to help with that. Installing it is as easy as running:

curl -sL https://raw.githubusercontent.com/jorgebucaran/fisher/main/functions/fisher.fish | source && fisher install jorgebucaran/fisher

In the following sections we’ll see examples of installing plugins with fisher.

homebrew

Note: You can skip this section, if you’re not a macOS user.

If you install Fish from Homebrew, it won’t work properly without a bit of additional setup. Basically, you need to add the following to your config.fish (at the very beginning):

/opt/homebrew/bin/brew shellenv | source

Without this bit of code you’ll get some weird errors when you run Fish.

rbenv

I do a lot of programming in Ruby, and I’m using rbenv to manage my Ruby installations. rbenv provides built-in integration with Fish, so things are easy here. Just add the following snippet to your config.fish:

rbenv init - fish | source

NVM

These days so many tools are written in JavaScript and TypeScript that’s almost impossible to avoid having to install Node.js. NVM (Node Version Manager) is the most common way to manage multiple Node.js installations.

NVM doesn’t play well with Fish, but there’s an excellent replacement for it called nvm.fish. Just install it with Fisher like this:

fisher install jorgebucaran/nvm.fish

Now you can easily install and enable various Node.js versions:

nvm install lts
nvm use lts

I think nvm.fish doesn’t support all the features of NVM, but it certainly supports the features I care about.

SDKMAN!

I’m quite fond of Clojure and I’m working on a few OSS Clojure tools in my spare time. As I need to test stuff on multiple JDK versions I’m using SDKMAN! to manage them. (it’s very similar to rbenv and NVM)

The situation here is quite similar with the one for NVM - you need to use a version of SDKMAN! designed for Fish:

fisher install reitzig/sdkman-for-fish@v2.1.0

mise

These days you can replace rbenv, NVM and SDKMAN! with mise. I haven’t made the switch yet, but I’m glad to report that mise plays well with Fish. All you need to do is:

echo '~/.local/bin/mise activate fish | source' >> ~/.config/fish/config.fish

OPAM

OPAM is a package manager and SDK manager for OCaml. Fortunately, it plays with Fish pretty well out-of-the-box:

eval (opam env)

Docker

Docker provides completions for the Fish’s native completion system, that you’ll have to setup like this:

mkdir -p ~/.config/fish/completions
docker completion fish > ~/.config/fish/completions/docker.fish

abbreviations

One of the Fish features that I really like are the abbreviations - snippets of code that can be expanded in various places when typing a shell command. Below are a few examples from my personal configuration:

abbr -a -- be 'bundle exec'
abbr -a -- gst 'git status'
abbr -a -- lg lazygit
abbr -a --position anywhere --command git -- co checkout
abbr -a --position anywhere --command git -- st status
abbr -a --position anywhere --command hx --command nvim --command emacs -- fish ~/.config/fish/config.fish
abbr -a -- jpost 'bundle exec jekyll post'
abbr -a -- jserve 'bundle exec jekyll serve'

Notice that some abbreviations are “global” and are specific to a particular command (e.g. co and st will work only in the context of git). Notice also that the same alias can be triggered in the context of multiple commands - e.g. my fish abbreviation will work with hx (Helix), nvim and emacs.

Now if I type be followed by TAB it will be expanded to bundle exec and git co will be expanded to git checkout. Cool stuff!

ENV variables

For various reasons working with environment variables in Fish is quite different from POSIX shells like Bash and Zsh. I’m not going to go into much details here and I’ll just mention a couple of basics:

  • Instead of doing export $SOME_VAR you should do set -gx SOME_VAR=VALUE
  • Lists in Fish (e.g. a list of paths) are space-separated
  • Variables can have different scopes (e.g. local, global, universal)
  • You can unset (erase) a variable with set -e

All of this seemed pretty confusing to me at first, as I’m so used to the POSIX way of doing things, but after a while it started to feel like a solid improvement over Bash/Zsh and their extended family.

Tip: I highly recommend reading Fish for Bash users at this point.

Tweaking the PATH

If you want to tweak PATH I’d suggest using fish_add_path (e.g. fish_add_path $HOME/bin). This works both one-off interactively, and in config.fish — it won’t produce duplicates in the config.fish case.

There are other ways to do this, though. E.g.:

set -gx PATH "$HOME/bin" $PATH
set -gx PATH /path/to/dir1 /path/to/dir2 $PATH

You can also append entries with set -a.

Universal variables

Those get persisted to a file and are available between restarts. You can create/update universal variables like this:

set -ux SOME_VAR=value

The notion of persistent shell variables was definitely novel and confusing for me at first, but now I find it pretty cool. In practice it means you can tweak your shell setup directly from the shell, without having to update the config and reload it.

Tip: You can read about the different types of shell variables in Fish here.

Reloading the configuration

If you want to reload your Fish configuration you can do so like this:

exec fish

That comes in handy after changes to config.fish or when you want to reset some local changes you’ve made.

My complete configuration

I’m still using Starship and zoxide with Fish, as I was doing with Zsh. My entire config.fish is listed below and I hope you’ll agree it’s pretty simple:

/opt/homebrew/bin/brew shellenv | source

fish_add_path $HOME/bin

if status is-interactive
    # zoxide
    zoxide init fish | source

    # Commands to run in interactive sessions can go here
    starship init fish | source

    # rbenv
    rbenv init - fish | source

    # nvm.fish
    set --universal nvm_default_version lts
    set --universal nvm_default_packages yarn np

    # opam
    eval (opam env)
end

abbr -a -- be 'bundle exec'
abbr -a -- gst 'git status'
abbr -a -- lg lazygit
abbr -a --position anywhere --command git -- co checkout
abbr -a --position anywhere --command git -- st status
abbr -a --position anywhere --command hx --command nvim --command emacs -- fish ~/.config/fish/config.fish
abbr -a -- jpost 'bundle exec jekyll post'
abbr -a -- jserve 'bundle exec jekyll serve'

One tool I use less with Fish is fzf, as the default history search is much better than the one in Zsh.

Make Fish your default shell

One thing that wasn’t immediately clear to me when I switched to Fish was how to make it my default shell. In the past I’d simply make Zsh my login shell and be done with it, but that’s not a good idea with Fish, as it’s not POSIX compatible and this might cause some issues.

After some deliberation I’ve decided that it’s probably best to just make it the default shell for my terminal emulator and this has worked pretty well in practice. I’m using Ghostty and I had to add the following to you config:

command = /opt/homebrew/bin/fish

Alternatively you can just trigger Fish from your .bashrc and .zshrc, and this might save you a bit of hassle as that way Fish will inherit the environment from your existing shell setup.

Note: You can find more on the subject here.

Why Fish?

You might be wondering what prompted me to move to Fish, so let me address this here real quick:

  • I didn’t have any particular issues with Zsh, but I did notice that out-of-the-box Fish behaves more or less exactly as I want my shell to behave. In practice this means less configuration, which is not a bad thing.
  • I like playing with new tools and Fish has been on my radar for a while now.
  • Recently Fish 4.0 was released - a full rewrite of the project in Rust, and that caught my attention.

Epilogue

And that’s a wrap. The switch to Fish was extremly fast and easy and I really regret not doing this earlier. Better late than never!

This post barely scratches the surface of what you can do with Fish, but I hope it gives you an idea how to approach the process of moving over from another shell.

If you’re curious about Fish I cannot recommend you highly enough to try it out!