So I make a variant of this blog post every 2 to 4 years now, it's kind of become a tradition. The reason is actually fairly simple: shell is by far the language I use the most, but we'll get into that. As a result of using it that damn much, and in the kinds of contexts that I use it in, I have very peculiar preferences. In this early 2026 edition of this post, I'll talk about where I've been, where I'm at, and what I would recommend to people that don't literally live in the terminal emulator.
First, the short version: I'm back on zsh because I've been doing more server
stuff again and do want a consistent setup. None of them are sensitive to the
usual zsh issues, and I do appreciate having a lot of the funny features like
=cmd and co. Currently, I'm fine recommending fish to people that aren't going
to be doing anything too funny, and am probably likely to recommend bash
otherwise, even though that's unlikely to ever be my personal choice.
Historical Context #
First off, let's quickly discuss what my history with shell is. The short version is simple: I've been using some variant of a shell command language my entire life. I've been using a UNIX-like system from basically the moment I had a choice in the matter.
My first job was in C++, but that didn't last long, as I landed squarely in system administration, where I could instutitionalize my insane tendencies, doing things like replacing thousands lines of perl with dynamic HTTP/1.1 generation in bash with its tcp socket support.
This is to say that I've been using shell and shell derivatives every day of my life for as long as I remember, both at work and at home. This, combined with my other background (writing, philosophy) means that my fundamental thought process is text oriented, where command languages (and shell in particular) use plain text as the lingua franca.
Typical complaints about shell and the general std{in,out,err} ecosystem talk about the irregularity of the interface. We'll talk about attempts to "solve" this later on, but to me this is a non-problem. The entire point is that tools that are "good citizens" in shell-land are useful both interactively and in scripting.
If you're a programmer, I want you to imagine this: an interface so simple, every programming language on earth has built-in support for it, that provides all your users an automatic embedded IDE with highly UX-optimized low-latency interactions and adjustment cycle, and all you gotta do to make it all work well together is follow some simple rules. And if the users want more? They can trivially just wire plumb around it.
This is what shell is, and the only thing that can even try to compete is lisp (I have previously argued that shell is in the lisp family, which wasn't a 100% serious argument due to the lack of defmacro, but I do feel like sh and tcl as command languages are closer to shell than things like C, which should make my opinions on tcsh not very surprising when we get to that).
Where Am I Today? #
Before I talk about how I feel about every shell and the more interesting details about how I use shell, let's talk a little bit about the recent (last couple of years) developments in my life and how that has shaped my choices.
Currently, I am mainlining zsh again (which hasn't been the case in a solid 6-8 years). I gave a solid shot to mostly using it (and several predecessors) in emacs, which I use about as much as vi nowadays (latter being more common in ssh or for smaller edits), but in the end my brain is more line-oriented than buffer-oriented (though I fully recognize buffer-orientation as being fully legitimate and good also).
My previous serious setup was with fish, because after the big move I did for a little bit I didn't feel like doing as much SSHy things and so on. I'm very much back in that mood now though.
My biggest complaint about fish though has been a lot simpler: I have so many years of shell syntax muscle memory. As long as I'm doing simple things, fish is great. If I'm writing a script, fish is fine. But between wanting to have more easily available stuff in ssh, the muscle memory and thought process already pre-optimized for more POSIX-y sh, and a few other things, I cracked and just went.
What do I think about the various shells out there? #
This is mostly personal opinion and vibes, for obvious reasons. I think it's important to have this section before I start giving out recommendations, since universal recommendations don't exist. Seeing what and how I think about these things helps you make (more) informed decisions.
Besides that, we have to talk (briefly) about what "a shell" even is. Here's the
definition I'm running with: a shell is a bareword (meaning that the parse/lex
balance is very much on the parse side) command language (meaning all
fundamental action comes down to commands, which are a thin layer on top of
external executables' main function, or builtins/functions that act
similarly). Shells effectively handle their own internal state (such as pwd and
environment variables), allow you to run said commands, manipulating their
initial states (like FDs) to interact with the system (primarily via the
filesystem).
A big part of the utility is a dynamic binding system (as opposed to lexical binding), since it allows modifying the interpreter's global state from functions in the absence of more "true" macros (irrespective of whether you can implement them on top of eval).
This means that shells strongly depend on the environment they run in (i.e. what external (not builtin or functions) commands are available) as to what they're capable of doing, and on the quality of said environment as to how ergonomic it is.
There is a standard that specifies a minimum set of both of these called POSIX. When I talk about POSIX sh, I mean the features prescribed by it, and the command behaviors described by it. There is an important distinction to be made between strict POSIX and POSIX-compatible: the latter is a superset. The vast majority of implementations of every component can do more than what POSIX describes. It is the general view that strict POSIX compliance means disabling that extra functionality (in large part because it allows targetting as many systems as possible), while POSIX-compatible merely means that anything that POSIX describes is available, even if there are more options.
With that said, this means that shells are differentiated on the following things:
- What does the shell itself support? What kinds of additional process substitution patterns, variable substitution patterns, additional types of scoping, special (non-POSIX) syntax, arrays, namerefs...
- What things are available as builtins.
- How exactly does it wrap around
main? - Interactive features, like completions, how the prompts work, hooks, etc.
These are the terms by which I'm going to talk about these. There is no partcular order to the following listing.
Korn Shell (ksh) #
By this I mean ksh93u+m, oksh, pdksh, mksh, and so on. Realistically this is more of a family of shell than anything else, and it's the oldest one still going (unless you consider bash to be a direct descendant of the bourne shell, which isn't unreasonable but not how I see things).
What's more, it's legitimately a very advanced shell! It has things like... nested mixed-type arrays. Check this shit out (ksh93u+m):
1typeset -a foo
2foo[0]=1
3typeset -A foo[1]
4foo[1][a]=bar
5foo[1][b]=baz
6foo[1][c d]=space
7foo[2]=2
8for i in "${!foo[@]}"; do
9 printf '%q -> %q\n' "$i" "${foo[$i]}"
10done # 0 -> 1, 1 -> '', 2 -> 2
11for i in "${!foo[1][@]}"; do
12 printf '%q -> %q\n' "$i" "${foo[1][$i]}"
13done # a -> a, b -> b, 'c d' -> space
It also has arbitrary hooks on variable changes. Look at this!
1foo=1
2function foo.get { nameref foo=${.sh.name}; [ "$foo" = 5 ] && .sh.value=6 }
3echo $foo # 1
4foo=5
5echo $foo # 6
6while [ $foo -gt 4 ]; do foo=$(( foo - 1 )); echo $foo; done # infinite 6
As a language, ksh is seriously incredible relative to everything else out there. Not bad for something originally written in the early 80s. Unfortunately, it has a couple of issues.
For one, the fragmentation problem is pretty real. Notice how I had to specify that these examples are in ksh93u+m? Well, that's not quite how things work out in other ksh derivatives like mksh. In fact, each ksh derivative is special in its own way (ksh93 for example is so named because it went through a full rewrite in 1993), and all of them share an additional issue...
Interactively, they're not really it. For example, you probably want tab completion for things like options, or maybe process IDs, or maybe other stuff. Well, with ksh, you get filenames. You probably want keybindings. Well, a bunch of ksh variants let you have a function that you can run on a keypress to edit what the shell will ultimately "see". While the actual command language is seriously great, using this shell day to day (if you live in it) can get annoying (I would know, I've done it!).
Bourne Again Shell (bash) #
In a lot of ways, bash feels like a downgrade to ksh. Is it actually? Well you do lose native nested arrays! A lot of bash's features are equivalent to ksh ones or are less. Bash-specific/native features have a tendency to be marked, like with BASH_REMATCH. On the overall I don't have anything against it per se, but I've just never really vibed with it much.
The biggest power bash has is how widespread it is. The vast majority of systems will have bash as the default shell, and a lot of them will have it be as the default provider of /bin/sh as well. This has significant practical consequences.
Let's say that you're writing a script, and what's in POSIX sh isn't quite cutting it. What you're lacking aren't specific versions of commands (you can always document those as dependencies), but shell features. What do you do?
Well, you can use a "real" language, but you still have the issue of "well now they have to install it". You could close your eyes and pretend the issue isn't there "everyone has python/perl/php/whatever", but you can do that with anything. Alternatively, you could use shell extensions. You could pick ksh, for example, but now you're saying "ok you gotta have oksh specifically", or "a ksh compatible shell" and then deal with people that are using something that isn't quite the same.
There's an easy resolution here: bash runs basically everywhere, and is the most
common, and has most of what you might want. The alternative resolution is to
use ash since virtually every shell is still just a superset of ash. This has an
additonal effect in that if you just say "fuck it" and put a #!/bin/bash
everywhere, you can justify getting the bashisms into your muscle memory on the
same standing as POSIX sh.
Like I said, that's not how it wound up going for me, but I do honestly get it. It does make me wonder what would have happened if bash wasn't what "won out" (essentially by default). But hey, if you're gonna be stuck with one shell and one shell only, this isn't that bad a choice on the overall.
Almquist Shell (ash, dash) #
What if we had basically none of the features of either bash or ksh? Ash does have a couple of extensions but they're fairly minor. Well, you might say minor, while I might say "just enough" - it has pipefail! In exchange, it's small. This is why, besides bash, this is the most common shell. Possibly more common depending on how you count (see: busybox running in every initrd and more).
When I write scripts, I typically target POSIX sh and XCU. Sometimes, I'll want
an extension, and I'll try and get it done with ash. If I can, I keep the
#!/bin/sh and just have a comment about it, because basically every shell is
just a superset of ash, even if it's not strictly compliant.
Similar thing also happens with XCU, actually; where if it's an extension, but it's in busybox, half the time I shrug, add a comment, and move on. This is very bsd-phobic of me, I know (though I'm pretty sure most busybox extensions are in various bsd utils as well...).
Yet Another Shell (yash) #
Yash is cool stuff. You can really tell that it was made to be POSIX sh first and foremost, with other extensions added on top. It makes sense that most of the other shells are not this, since they either don't care about POSIX (like fish), or predate it.
The extensions yash has feel homegrown; they're not quite like what other shells
offer, for the most part. Combined with excellent documentation, it's a really
interesting experience! Ever thought . might take flags? Well it does here!
Since it's "POSIX first, extensions later" it also means that it's good for testing compatibility. After all, how will you know it'll work with another compliant shell if extensions are silently allowed? I don't really do this often (see how I feel about ash), but it's good to have something for that!
On the overall, yash is a really fun entry. I like having it around, and on
non-small systems (read: I can justify having more than just busybox on there),
my /bin/sh is typically yash.
Run Commands (rc, es) #
I think rc/es are cool, but there's kind of a problem here. See, I have dozens of thousands of hours of POSIX sh muscle memory, and that's not going away anytime soon. So while they're cool, and have funny things like ksh in some respects, while being simpler, it's just too late for me. At the same time, they also lack interactive usability features just like ksh, so it's hard to really recommend them per se.
On the other hand, I strongly recommend trying at least one rc-family shell for a little bit, as it really shows you a lot of things quite quickly.
Fish #
Fish is really interesting because it really narrows down to the core things that make a shell good - being latency-optimized and features that work well for both interactive and scripting usage.
The most "controversial" part of fish remains the fact that it isn't compatible with POSIX sh syntax, but honestly, it doesn't really matter. The fundamental logic is quite similar (arrays are more complicated than that but still), and the ecosystem is unchanged.
At the same time, the project itself is quite alive, and improving. They recently rewrote it in rust, and while I'm not a big fan of the language, it's clearly worked out great for them! There are more active contributors than ever, and some new features have been enabled by it. One of these is a hermetic distribution method.
So basically, most shells, once they offer enough interactive usability (so fish, zsh, somewhat bash...) tend to depend on the runtime-availability of a bunch of files. This is generally fine, you just install it with your package manager, and it ensures those are there. It also means that distributing it without a package manager becomes a horrible pain.
Now, on this subject, there has been a solution to this of sorts for a while now. The stuff that's to be distributed by default with the program should just be embedded into the binary, and then the binary can perform lookups into itself as a fallback at runtime.
Up until recently, this has been a horrible pain to do in C. Basically you would
do some runtime transformation of the input files into byte arrays and then
manually arrange them such that you can do programmatic lookups and honestly the
vast majority of people in general would never bother with this. Now C is
improving on this side of things with the #embed preprocessor directive, but a
lot of the orchestration remains a pain.
At the same time, the Go programming language added a much more convenient way to embed entire trees into the binary, all the way to being able to emulate (via an interface) a filesystem, letting the code that looks the file up on the filesystem also look up the embedded data. This was a good idea, and now you can do similar stuff in Rust. One of the first things that happened once the fish rust rewrite happened was movement in this direction, and it works!
This is kind of a convoluted example, but it's one I actually care about, so that's that! Similarly, more and more POSIX sh syntax is compatible.
In the last couple of years I've test-driven fish quite a lot on a variety of devices, and in the end I have moved off, but it's mostly for unrelated reasons (for example, the way-too-much-time with POSIX sh syntax and brainworms is far too much for me to change at this point). I'm perfectly comfortable recommending it, especially to people that aren't locked into other stuff yet.
Z Shell (zsh) #
I decided to talk about zsh right after fish for a couple of reasons. First, at the time of writing, I'm back on zsh. Secondly, it feels like a really apt comparison. Where fish gets you surprised by how much is doable with great latency characteristics, zsh makes you feel like it's a game show (POSIX compatibility, nice interactive features, latency, pick two).
Let's start with the elephant in the room: the project isn't fully dead, but it certainly isn't going. The last release was almost 4 years ago, longer ago than for ksh93. The mailing list isn't dead, but there's certainly not that much going on. This isn't because there's nothing to do, there just isn't that much interest. The codebase hasn't aged that well (nor that poorly fwiw), and most of the mindshare is in other shells right now.
Combine this with fairly poor latency behavior relative to most other shells (the new stuff excluded, we'll get to that), and it might become surprising as to why I'm back on it. So let's talk about what's nice about zsh instead!
The way that you should think about zsh is "what if ksh had a bunch of
interactive features". Indeed, this is how it was made to begin with, quite a
while ago. It might not have true namerefs, but it has ! indirection. It might
not have nested arrays, but while those are cool I don't use them that often in
practice. Instead it has things like =.
For example, a (very) common task in shell is to check if a particular command is available. The correct way to do this in POSIX sh looks something like this:
1hascmd() {
2 # local is a common extension and is explicitly mentioned as undefined behavior
3 # (to be ignored if unimplemented) under POSIX
4 local cmd=$(command -v "$1")
5 [ -n "$cmd" ] && [ -x "$cmd" ]
6}
Usually, you'll want to be able to switch on "which one" is available. You can implement it something like so:
1whichcmd() {
2 local cmd cmdp
3 for cmd; do
4 cmdp=$(command -v "$cmd")
5 [ -z "$cmdp" ] || ! [ -x "$cmdp" ] && continue
6 echo "$cmd"
7 return 0
8 done
9 return 1
10}
The = option in zsh is essentially equivalent to command -v, but it also works
well with [[ ]] by erroring out on being unable to find the command. As a
result, here are both of the above implemented in zsh.
1hascmd() { [[ -x =$1 ]]; }
2whichcmd() {
3 local cmd
4 for cmd; do
5 [[ -x =$cmd ]] || continue
6 echo "$cmd"
7 return 0
8 done
9 return 1
10}
This isn't super important in scripts, you can just implement hascmd, who
cares? In interactive usage though, it's very useful! And hey, getting the
absolute path to a binary in general is useful, and is similarly a pain
elsewhere. Zle is nice (even if has a few funny side-effects), pathdirs is
useful, etc.
At the same time, in terms of disadvantages, I'm not that affected. My overall setup is not very complex, so while the latency is noticably different, on most of my systems it might as well be instant. I don't care that much about new releases, even if there are things that could be better. And while it's a shame about the binary embedding stuff, because the core syntax is still POSIX sh, I can just drop in ksh93u+m or busybox ash or anything else on smaller systems.
So in short, I'm willing to take the tradeoff, at least right now. Get the nice UX things I really enjoy, lose some latency (this will probably be what makes me swap out eventually), and ride it out for a while as I get used to having fewer things again. Should you use zsh? Maybe! If you're struggling to decide, it's difficult to give a definitive reason to use it over anything else (precisely because if it's for you, you already know you want it) though.
C Shell (csh, tcsh) #
I still don't understand/like csh or tcsh. You lose POSIX syntax and gain essentially nothing of interest. Being more C-like is not something I care about. I mostly feel this way about other shells that try to imitate other languages (a common example is xonsh): I want a shell, if I wanted C I'd just write C. On top of that, the codebase is... not great, last time I looked.
Powershell (pwsh), Nushell (nu), Elvish #
I really don't like this new family of shells. Like, on one hand, it's interesting, I guess? Thing is, I like shell and the ecosystem precisely because it's "just text". I can directly introspect things in the middle of a pipeline by adding in a text editor. I can think in pipes and parallel execution. POSIX sh syntax and the general approach isn't perfect, but I do like it! I don't think trying to smush everything into addressable data structures that become much more awkward to ad-hoc manipulate is an improvement. You guys know you can just use any other programming language, right?
Now maybe this is undeserved criticism. For example, powershell is trying to be a more traditional programming language, as it just lets you directly interact with dotnet constructs. Another way to look at these is as bringing a more "serious" language into a shell-ish-dsl-ish form that's still useful ad-hoc. So let's consider them under this lens.
Why are they so slow? Every time I've tried using these, it felt like typing on butter. With no configuration, none of my code even running yet, everything felt slow. Nushell would even keep blinking text in and out. It's "not supposed to happen", but I've yet to see it not happen. At the same time, ad-hoc usage is not that great actually, pwsh has awkward syntax for a lot of things, elvish's modules cannot change caller state (good for a programming language, not so good for a shell)... As I already was fairly unconvinced, I'm just not interested in these!
So what would I recommend? #
I get if you skipped that last section. It's long, so let's compile some base recommendations real quick.
- If you're new to shell, or mostly just use it on your desktop or whatever, you'll probably be best served by fish on a day-to-day basis, though you should still learn POSIX sh syntax.
- Whether 1 doesn't apply to you, or you're following it and learning the POSIX syntax, you should probably just use bash for that.
- If you want to dig deeper and learn some cool stuff, you should be trying out the plan9-derived shells (rc/es) and ksh (specifically ksh93u+m).
None of this is "how you use shell" #
Yeah we're now getting to this. For some reason I felt like writing all of the damn above first.
First off, I essentially live in my shell. I tried to do the whole living in emacs thing, and for some things I do do it, but the more common thing for me is to run emacs using an e or E (tui, gui aliases respectively) command.
For aliases, I tend to have two "kinds" of aliases: aliases that configure a
program and those that provide a program. To elaborate on this example, eza
might be providing ls, while alias ip='ip -c auto' configures ip to
automatically use colors when relevant. The former I tend to create depending on
what's available on the system, while the latter can be unconditional (you go
from an error because it doesn't exist to an error because it doesn't exist).
For functions, I think I've found a decent balance of reusable, useful / total, and small.
My general expectation of any computer I work on is that I should be able to do essentially anything I want to achieve from the terminal emulator, and my preference for "good citizens" has only gotten stronger.
I make a lot of use of substitution, ${var:=default}, ${var:-fallback},
${var:+--if-present $var} and so on.
My annoyance at "shellcheck" and its ilk (e.g. blind set -euo pipefail) has
only gotten stronger (I agree that -o pipefail is a good default, but -eu
cause more problems than they're worth in most cases IME).
When I'm trying to solve a problem, my first reflex is to try and solve it in shell using standard (and less standard) tools I've got before reaching for a "serious" programming language. I manage >90% of the time.
Which speaking of tools, let's go over some of the ones I use quite often, because when else would I do this than in a blog about shell?
- bat: a syntax-highlighting cat replacement that also calls a pager, adds
borders etc. A very important feature is that if stdout is not connected to a
pty, it just does what regular cat does. Yes, yes,
cat -vis considered harmful, the biggest reason to do this rather than have a highlighting pager directly is precisely because I can then just turn it into a pipeline iteratively. - bfs: breath-first find-compatible implementation, I literally alias it in. Makes things run way faster simply because it's quite unlikely for what I'm looking for to be 50 layers in. Yes, I know about locate, but honestly for the most part indexing just isn't that interesting for me. With bfs, I just find what I'm looking for almost instantly.
- bsdtar: I think I wrote about this last time I wrote a blog like this, but bsdtar (and libarchive in general) is simply the best tar implementation out there. I install it everywhere I go, have a static version around, and alias it straight off. I cannot think of a reason to use GNU tar over this.
- jaq: jaq is a reimplementation of jq with some improvements. It's quite nice!
It's not 100% compatible, and I've hit some curious bugs (no time to look into
them right now, + it's rust), but for the most part it runs faster and more
consistently. The manual's also quite excellent! I directly alias it if it's
available since I can always do a
command jqotherwise. - eza: nowadays
ezais mylsimplementation of choice. It even listens toLS_COLORSif it's set (though I'm very happy with the defaults). Also comes with atreeimplementation. I keep wanting to trylrinstead, but unlike some other tools from that collection (likenq, which we'll talk about later), it just doesn't quite work with my brain. - ugrep: this is a funny one, I don't know if I really recommend
it. Realistically I use
rgjust as much, but this one I alias togrep. No idea if I'll be keeping it, but it's been in there for a while now and most of the time I forget it's even non-GNU grep (I typically remember once I'm on a system with a different grep implementation, but I never remember what it is that made me remember, oops). - xh:
httpieif it didn't suck / gone corpo. - chafa: really well written visuals in the terminal.
- skim (sk): kind of like fzf, which I also use. Not very much though, you might be surprised. Sometimes it's just a nice to have though, kind of like a dmenu in cli.
There's really not that much here, huh? Indeed, for the most part, I just do the thing I guess. And indeed I don't bother mentioning everything that is straight up standard. On that note, I guess that's gonna be it for me! It is 2am and I need to sleep :D