sh (POSIX) vs Fish
Fish is the only mainstream Unix shell that deliberately *rejects* POSIX. Every other comparison on this site is "POSIX plus extensions" — sh-vs-fish is "POSIX vs a parallel-universe syntax". This is the compare you need before you `chsh -s /usr/bin/fish`.
Summary
`sh` is POSIX 2017 — the lingua franca of Unix scripting. Fish is a deliberate departure: designed by Axel Liljencrantz in 2005 with the explicit goal of being NOT POSIX-compatible, in service of a cleaner, more discoverable interactive experience. Every syntactic choice optimises for "typed at a terminal", not "embedded in a build script".
Concretely, variable assignment is `set NAME value` not `NAME=value`; conditionals are `if test -f file; ...; end` not `if [ -f file ]; then ...; fi`; pipelines work the same as POSIX but `&&`/`||` only appeared in fish 3.4 (Mar 2022) — before that, you wrote `cmd1; and cmd2` and `cmd1; or cmd2`; command substitution is `(cmd)` not `$(cmd)` or backticks; arithmetic is `math 1 + 2`, not `$((1+2))`. Functions are `function foo; ...; end`, exported with `funcsave` to autoload files.
The portability story is simple: bash/sh scripts do NOT run under fish. Fish is for interactive use; for scripting either use `#!/usr/bin/env fish` (and accept a fish dependency) or stay in POSIX. Many fish users keep a `bash` shebang for scripts and reserve fish for the prompt — that's the dominant pattern.
Syntax & semantic differences
Variable assignment
sh (POSIX)`NAME=value` (no space around `=`). Export with `export NAME=value`. POSIX-strict — the spacing rule trips most beginners.
Fish`set NAME value` (space-separated, no `=`). Export with `set -x NAME value`. Locally scope with `set -l`; universal (persisted across sessions) with `set -U`.
`NAME=value` is a syntax error in fish — it sees `NAME=value` as a command and fails with "Unknown command". The fish-tuned advice is "no equals signs in the shell".
Conditionals
sh (POSIX)`if [ -f file ]; then ...; fi` (or `[[ -f file ]]` in bash/zsh). The `then`/`fi` keywords; `[` is actually a command (synonym for `test`).
Fish`if test -f file; ...; end` — fish uses `end` for every block. `[ ]` works too but `test` is preferred for legibility. No `[[ ]]` at all.
The `end` keyword closes every block — if/while/for/function/switch — in fish, instead of fi/done/etc. More consistent than POSIX once you adjust.
AND / OR chaining
sh (POSIX)`cmd1 && cmd2`, `cmd1 || cmd2`, `cmd1 || cmd2 || die`. POSIX since forever.
FishUntil fish 3.4 (March 2022): use `cmd1; and cmd2` and `cmd1; or cmd2`. From 3.4 onwards: `&&` and `||` work too. The `and`/`or` keyword form is still idiomatic.
Old fish tutorials use `and`/`or` exclusively. If you target a recent fish (≥ 3.4), `&&`/`||` is fine and reads closer to POSIX.
Command substitution
sh (POSIX)`$(cmd)` (POSIX) or `` `cmd` `` (older, harder to nest). `result=$(date +%Y)`.
Fish`(cmd)` — no dollar prefix. `set today (date +%Y)`. Captured output is automatically split on newlines into a list, NOT on `$IFS` like POSIX.
The auto-split-on-newline is fish's killer feature for line-by-line processing: `for f in (ls); ...; end` works correctly even with spaces in filenames, where the same bash code requires `IFS=$'\n'` shenanigans.
Arithmetic
sh (POSIX)`$((1+2))` or `expr 1 + 2` (POSIX). Bash also `(( … ))` C-style. Integer only.
Fish`math 1 + 2` — calls an external command, supports floats and a handful of functions (`math "sqrt(2)"`). Slightly slower than bash `((…))` because of the exec, but more capable.
Exit-on-error mode
sh (POSIX)`set -e` (errexit) — every uncaught failure aborts the script. Pair with `set -o pipefail` for pipe failures. Standard idiom: `set -euo pipefail`.
FishNo `set -e` equivalent. Fish exits on certain syntactic errors but not command failures. The closest pattern: `or return` after each critical command (`cmd; or return`). Common community workaround: scripts use bash for anything stricter than "fail loudly on syntax".
Functions
sh (POSIX)`name() { commands; }` POSIX form. Functions and shell scripts coexist seamlessly.
Fish`function name; commands; end`. Save to autoload files: `funcsave myfunc` writes to `~/.config/fish/functions/myfunc.fish`. Autocomplete + descriptions are first-class.
Subshells
sh (POSIX)`( … )` creates a subshell (separate process). Used for scoped `cd` or trapped state.
FishNo subshell syntax — `( … )` is command substitution instead. To scope changes, fish uses `begin; …; end` (no separate process) or explicitly spawns: `fish -c "cmd"`.
Brace expansion
sh (POSIX)Not in POSIX. bash/zsh have `{a,b,c}` and `{1..10}` ranges. dash and busybox ash literal.
FishFish has `{a,b,c}` brace expansion (calls them "lists"). No `{1..10}` range — use `seq 1 10` instead.
Globbing
sh (POSIX)POSIX: `*`, `?`, `[abc]`. No recursive globs. `find` is the standard escape hatch.
FishFish: same basic globs plus recursive `**` (matches directories at any depth). No glob qualifiers like zsh's `*(.)`, but `find` works.
Configuration
sh (POSIX)`~/.profile` (POSIX) for login shells; bash adds `~/.bash_profile`, `~/.bashrc`. Sourced as flat scripts.
FishConfig lives under `~/.config/fish/`: `config.fish` (sourced on startup), `functions/` (one-file-per-function autoload), `completions/` (one-file-per-command). The autoload layout is unique to fish.
Environment variable export
sh (POSIX)`export NAME=value` or `NAME=value cmd` (one-shot). Available to child processes.
Fish`set -x NAME value` (or `-gx` for global). Plus universal variables `set -U` that persist across sessions in a per-user database — not POSIX-compatible at all (no env-var moral equivalent).
Gotchas when porting between them
- **Bash scripts simply will not run under fish** — `bash script.sh` invokes bash regardless of which shell you typed it in, so the issue only arises when somebody changes the shebang to fish, or runs `source script.sh` in a fish session. Either keep `#!/bin/bash` (or `#!/usr/bin/env bash`) shebangs and pipe-up your scripting brain to POSIX, or commit to fish-only scripts.
- **No `set -e` equivalent in fish** is the biggest scripting surprise — a script that should abort on first error silently continues. The `; or return N` idiom after every critical line is the workaround, but it bloats the script. For production scripts, most fish users keep `#!/bin/bash` and reserve fish for the interactive shell.
- **Auto-split-on-newline in command substitution** is FISH'S BEST FEATURE for portability but a trap when porting bash code. `for f in (find . -name "*.txt"); ...` works correctly even with spaces in filenames; the equivalent bash `for f in $(find . -name "*.txt")` BREAKS on spaces unless you write the `IFS=$'\n'; for f in $(find …)` dance. Conversely, bash code copy-pasted into fish may NOT work because the splitting semantics differ.
- **Universal variables (`set -U`) leak across sessions** — change one terminal's `PATH` with `set -Ux PATH …`, and every other fish session sees it (persisted in `~/.config/fish/fish_variables`). Useful for prompt customisation; dangerous for PATH manipulation. To set a variable just for the current shell, use `set` (default local-to-function) or `set -g` (global to current session, not universal).
- **`source` works but reads fish syntax** — a `#!/bin/bash`-style shebang is ignored when you `source` (fish reads it as fish code). If you have a `.env` file with `KEY=value` pairs, fish errors immediately. Workaround: write `.fish_env` files using `set -x KEY value` syntax, OR pipe through a tiny translator like `bass` (a fish plugin that runs bash one-liners and imports variables).
Full references
- sh (POSIX)POSIX-strict shell — the lowest common denominator for portable scripts. No arrays, no `[[ ]]`, no process substitution.
- Fish referenceUser-friendly Unix shell. Distinct syntax: no $((..)), no [[..]], pipes are the same.