Skip to content
← All notes

// notes

Nix devShell in fish: PATH is silently ignored

devShell packages are not picked up in fish because PATH points inside the mount namespace. How to diagnose the silent fallback and re-exec the shell

Published: 2026-10-083 min read
Nixfishdirenvrootless NixLinux

The jq version is unexpectedly old even though the flake (a Nix environment definition) builds without errors. The build is not the problem: PATH points at paths that do not exist outside the mount namespace (an isolated tree of mount points), so the shell takes system binaries. Below: how to spot it, why it happens, and how to re-exec fish inside the namespace once

Context: rootless Nix, direnv, nix-direnv and fish in a project whose .envrc contains use flake (loading an environment from a flake)

Symptom

There is no error, and that is what misleads. direnv allow finishes, variables from .env are loaded, but the tools come from the system rather than from the devShell (the development environment Nix builds)

$ echo $APP_ENV
development                    # dotenv works
$ command -s jq
/usr/bin/jq                    # system binary, not the devShell one

Meanwhile PATH looks correct: the right entries are present and come first

$ string split ":" $PATH | head -3
/nix/store/<hash>-git-2.55.0/bin
/nix/store/<hash>-jq-1.8.2-bin/bin
/nix/store/<hash>-curl-8.22.0-dev/bin

Diagnosis

First, check where the shell actually takes the binary from

$ command -s jq
/usr/bin/jq

If the command resolves to /usr/bin instead of /nix/store, part of PATH is dead. The second test tells whether you are inside the namespace

$ test -d /nix/store; and echo inside; or echo outside
outside

This is the only reliable marker. Do not trust $NIX_STORE_DIR: it can be set outside the namespace as well

When a command fails without naming the culprit, intercepting the binary helps: Who calls unshare

Cause

Rootless Nix mounts /nix inside a mount namespace, an isolated tree of mount points that is created without root privileges. Outside the namespace the /nix directory does not exist at all

$ ls -d /nix/store/<hash>-jq-1.8.2-bin
ls: cannot access: No such file or directory

$ nix-enter ls -d /nix/store/<hash>-jq-1.8.2-bin
/nix/store/<hash>-jq-1.8.2-bin

The shell does not treat a missing directory as an error: it skips the dead PATH entry and takes the next match. That is why the fallback goes unnoticed

Dynamically linked binaries have a hard-coded interpreter, /nix/store/<glibc>/lib/ld-linux-x86-64.so.2, so rewriting PATH to ~/.nix/store/... does not help

$ ~/.nix/store/<hash>-jq-1.8.2-bin/bin/jq --version
bash: .../jq: cannot execute: required file not found

If direnv fails with an explicit unshare error, that is a separate scenario: nix-direnv: "unshare failed: Operation not permitted"

And why running direnv outside breaks the package's embedded paths is explained in another note: nix-direnv: "minimum required bash version is 4.4"

Fix

Running the flake build inside the namespace is not enough: the shell itself must run there too. Add this to ~/.config/fish/config.fish

if status is-interactive
    and not set -q __NIX_NS_REEXEC
    and test -x "$HOME/.local/bin/nix-enter"
    and test -d "$HOME/.nix/store"
    and not test -d /nix/store
    set -gx __NIX_NS_REEXEC 1
    exec "$HOME/.local/bin/nix-enter" /usr/bin/fish -i -C "set -gx __NIX_NS_REEXEC 1"
end

What happens here. If /nix/store is not mounted yet, the shell re-executes itself once (a re-exec, an explicit restart) through the nix-enter wrapper and passes the __NIX_NS_REEXEC flag. After that fish runs inside the namespace, where the /nix/store paths are valid. The name nix-enter is an example wrapper, yours may be named differently, what matters is the check itself

The $HOME/.nix/store check assumes the typical rootless Nix layout. If your store lives elsewhere, adjust the path in the condition

The four conditions in if cover four different scenarios

  • status is-interactive keeps non-interactive scripts from being re-executed unexpectedly
  • not set -q __NIX_NS_REEXEC prevents an infinite loop
  • test -d "$HOME/.nix/store" checks that Nix is installed at all
  • not test -d /nix/store checks that we are still outside the namespace

After the re-exec the binary comes from the devShell

$ command -s jq
/nix/store/<hash>-jq-1.8.2-bin/bin/jq

Check

Run the three checks below. The normal picture: no dead PATH entries, command -s jq points to /nix/store, and test -d /nix/store prints "inside"

# 1) dead PATH entries
for p in (string split ":" $PATH)
    test -n "$p"; or continue
    test -d $p; or echo "DEAD: $p"
end

# 2) where the binary actually comes from
command -s jq
type -a jq

# 3) are we inside the namespace
test -d /nix/store; and echo inside; or echo outside

If the first loop prints DEAD for /nix/store paths, and command -s jq returns the system binary, the shell is running outside the namespace

Pitfalls

  • The most dangerous failure is the silent one: the tool version may quietly be the system one. If a version looks unexpectedly old, check command -s first
  • For non-interactive runs, in scripts and CI, the re-exec does not fire: that is intentional. There you must start the shell inside the namespace explicitly

Takeaway

If you run rootless Nix, direnv and fish, and flake packages seem invisible, PATH almost certainly points inside the namespace while the shell runs outside. A transparent re-exec fixes it without breaking scripts

Need this solved on your project?