Fish Shell PATH Performance: Manual vs fish_add_path
Your shell startup is slow. And
fish_add_pathmight 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_pathin config.fish is 12x slower because of disk I/O on universal variables- Manual loops are fast, predictable, and in-memory only
fish_add_pathrun 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.