3 min read

Fish Shell PATH Performance: Manual vs fish_add_path

Fish Shell PATH Performance: Manual vs fish_add_path

Your shell startup is slow. And fish_add_path might be the reason.

You opened your terminal. Typed a command. And waited. Maybe it was just 200ms. Maybe less. But something felt off.

So you did what any sane person would do. You benchmarked it.

Spoiler: that convenient fish_add_path you're calling in your config.fish? It's 12x slower than a manual loop. And the Fish docs never told you why.

The Question Nobody Asks

Fish's documentation recommends fish_add_path for PATH management. Sounds reasonable. Clean API, handles deduplication, the whole package.

But is it actually fast? Or are you paying a hidden tax every time you open a terminal?

Let's find out.

How Fish Builds Your PATH

Fish assembles $PATH in four steps:

1. System config     /usr/share/fish/config.fish
2. Universal vars    ~/.config/fish/fish_variables ($fish_user_paths)
3. Your config.fish  Whatever you put there
4. conf.d snippets   Auto-loaded configs

The important bit is step 2. Universal variables are persistent across sessions. They live on disk. And when you call fish_add_path, it writes to that file.

Every. Single. Time.

Three Ways to Set Your PATH

The Manual Loop

set -l paths_to_add \
    $HOME/.local/bin \
    $HOME/.bin \
    $HOME/go/bin

for new_path in $paths_to_add
    if not contains $new_path $PATH
        set PATH $new_path $PATH
    end
end

Runs every startup. Checks for duplicates. Pure in-memory operations. No disk I/O.

fish_add_path in config.fish (Don't Do This)

fish_add_path --path --move ~/.local/bin ~/.bin ~/go/bin

Writes to disk each time. Parses arguments. Normalizes paths. Manages universal variables. All of that, every single startup.

fish_add_path Run Once (The Right Way)

# Run this ONCE in your terminal, not in config.fish
fish_add_path ~/.local/bin ~/.bin ~/go/bin

Saves paths to $fish_user_paths permanently. Startup cost after that: basically zero. This is what the Fish team actually intended.

The Benchmark

Environment: macOS 14.6, Apple Silicon, Fish latest stable.

I ran both approaches 1000 times.

Manual loop benchmark

cat > /tmp/test_manual.fish << 'EOF'
set -l paths_to_add ~/.local/bin ~/.bin ~/go/bin
for i in (seq 1 1000)
    set -l temp_path $PATH
    for new_path in $paths_to_add
        if not contains $new_path $temp_path
            set temp_path $new_path $temp_path
        end
    end
end
EOF

fish -c "time source /tmp/test_manual.fish"

fish_add_path benchmark

cat > /tmp/test_fish_add_path.fish << 'EOF'
for i in (seq 1 1000)
    fish_add_path --path --move ~/.local/bin ~/.bin ~/go/bin 2>/dev/null
end
EOF

fish -c "time source /tmp/test_fish_add_path.fish"

The Results

Approach Total (1000x) Per Call Speed
Manual loop 50.15 ms 0.05 ms 12x faster
fish_add_path 606.08 ms 0.6 ms Baseline

System time tells the real story: 203 ms for fish_add_path vs 2 ms for the manual loop. That's a 100x difference in system overhead. The culprit? Disk writes.

Why fish_add_path Is Slower

Here's what happens under the hood every time you call fish_add_path:

fish_add_path called
  |-- argparse (parse flags)
  |-- realpath (normalize each path)
  |-- set -U (write to disk)
  |     \-- rewrites ~/.config/fish/fish_variables
  \-- deduplication logic

Compare that to the manual loop:

manual loop
  |-- contains (in-memory string check)
  \-- set PATH (in-memory variable update)

One talks to the filesystem. The other stays in memory. That's the whole story.

The "Run Once" Paradigm

Real talk: fish_add_path was never meant to live in your config.fish. It's a one-time setup command.

Run it once:

fish_add_path ~/.local/bin ~/.bin ~/go/bin

Verify it stuck:

echo $fish_user_paths
# /Users/you/.local/bin /Users/you/.bin /Users/you/go/bin

From now on, every new shell reads $fish_user_paths from disk once at startup and prepends it to PATH in memory. Zero overhead.

When to Use What

Situation Approach Startup Cost
Paths that rarely change fish_add_path once ~0 ms
Dynamic/conditional paths Manual loop in config 0.05 ms
PATH in version control Manual loop in config 0.05 ms

A Clean config.fish

If you need dynamic PATH management, here's what it should look like:

status is-interactive || exit

set -l paths_to_add \
    $HOME/.local/bin \
    $HOME/.bin

if test -d $HOME/go/bin
    set paths_to_add $paths_to_add $HOME/go/bin
    set -gx GOPATH $HOME/go
end

for new_path in $paths_to_add
    if not contains $new_path $PATH
        set PATH $new_path $PATH
    end
end

0.05 ms overhead for 3 directories. You won't feel it.

What This Is Really About

  • fish_add_path in config.fish is 12x slower because of disk I/O on universal variables
  • Manual loops are fast, predictable, and in-memory only
  • fish_add_path run once interactively is the intended usage, with zero startup cost
  • Pick the approach that matches your setup. Static paths? Run once. Dynamic paths? Manual loop

Your terminal should start fast. Now it will.

References


Benchmarks performed on macOS 14.6 (Apple Silicon). Your mileage may vary. Measure on your own setup.