Bash : so long, and thanks for all the fish !

Lessons learned migrating from bash to fish

March 31, 2019

I recently took the plunge and decided to move to fish, a friendly modern shell.


I'm heavily invested in bash. I wrote countless scripts, for my personal use, and for various jobs or projects, so I didn't think it would be an easy thing to move away from it. But I tried anyway, because if you always stay in your comfort zone, you never learn anything, and stop growing. And I didn't regret it. I've moved to fish (3.0.2 at the time of this writing) on all my machines, including Termux on my mobile phone.

The trigger was probably, in retrospect, the fact that the bash development process makes AOSP feel like a model of open source development, see for example this git commit.

Fish is in written in C++ (but it looks mostly like modern C), has a github, CI, pull requests, and more than one contributor. It's 14 years old (vs 30 for bash or 29 for zsh), so it's pretty young compared to other shells. But the project is still very mature.

Differences from bash

One important aspect, is that fish is designed around a simpler language, and is not POSIX-compatible. It makes the command lines and the scripts much easier to read. This also means that you shouldn't uninstall bash just yet, it might be useful to run those legacy scripts :-)

Although you can write scripts pretty easily, fish is mostly designed around the command line use. It works very well by default, and requires very little configuration. For example, while there's no full-featured linter like shellcheck (which you should really use with bash), fish has live-command line syntax check: if it knows it won't be able to run your command because of a syntax error or non-existing command, it will highlight it in red, allowing you to fix it before even running.

A core fish feature is the auto-suggestions: they mostly replace and remove the need to use reverse history search (Ctrl+R); they are enabled by default and take some time to get used to, but make you very productive in the end. You can still search in history by typing part of a previous command, and then pressing UP. This is useful since auto-suggestions work only search history (and completions, file paths) the beginning of a command.

While there's an fzf integration, I didn't really feel the need to use it.

Since the language is different, you cannot simply add environment to a command like this: VAR=x cmd, you need to use env to run the command: env VAR=x cmd. It's a bit longer to type. You also need to be careful if you have such command in your config files, for example I had to change this vim fugitive configuration:

let g:fugitive_git_executable="LANG=C LC_ALL=C git"

into this:

let g:fugitive_git_executable="env LANG=C LC_ALL=C git" (that's because fugitive parses git output in english).

This construct is portable to other shells as well, so that's fine.

Another big difference is command substitution: $(cmd) becomes (cmd) (and `cmd` isn't supported at all, but you shouldn't be using it anyway).

Pitfalls and limitations

When attempting to port some bash functions over to fish, I noticed other missing features:

  • there is no short &> or |& combined stderr/stdout redirection (issue)
  • there is no parameter expansion of variables (out of scope)
  • Process substitution only works for input, not output, with psub. For example diff <(sort file1) <sort file2) in bash, becomes diff (sort file1 | psub) (sort file2 | psub) in fish. (issue for output)
  • there is no fc to edit the last command in your $EDITOR, but you can do that with UP, then Alt+E to edit the current line.
  • there is no history substitution (!! for example), use arrow keys, like you'd do in bash.

Good surprises

Pasting in fish works as it always should have: it does not execute commands, and wraps multiple lines properly. This invalidates pastejacking attacks for example.

History is managed transparently by fish: the ~/.local/share/fish/fish_history text file is using an internal fish format. It means searching through it is more efficient when history goes bigger. And fish supports merging the history from other fish sessions with a single history --merge command ! This means I'll never have to run exec bash in all my open sessions to do a history sync again !

Fish also imports bash history automatically on first run, but it might take a while if you have a big history (a few minutes for 50k lines on one of the machines).

Python3 venvs work as they should if you include

Getting started

Just try fish in browser; it follows the tutorial, and explains all the basic features !

This article →