Skip to content
shellmap

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.

    Fish

    Until 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`.

    Fish

    No `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.

    Fish

    No 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.

    Fish

    Fish 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.

    Fish

    Fish: 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.

    Fish

    Config 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

Other comparisons