unix · level 1

PATH & Environment

How the shell finds commands.

150 XP

PATH & Environment

When you type git in a terminal, the shell doesn't search your entire filesystem. It checks a short list of directories — in order — and runs the first git binary it finds. That list is $PATH.

Analogy

$PATH is like the speed-dial list on an old office phone. When you press "9" to call a courier, the phone doesn't search every business in the city — it runs down a short, ordered list of numbers and dials the first one tagged "courier". Whoever is at the top of that list gets the job, even if there's a better courier stored deeper down. Tools like Homebrew and pyenv are letting new couriers pay to be inserted at position one, so they intercept your calls before the original company ever hears the phone ring.

What PATH is

PATH is a shell environment variable whose value is a colon-separated string of directory paths:

/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin

The shell splits it on : and walks each directory left to right. The first directory that contains an executable named git wins. Everything to the right is ignored.

$ which git
/usr/local/bin/git   # Homebrew's git, because /usr/local/bin is first

Why which and command -v can disagree

which is an external program. command -v is a shell built-in. In zsh, they usually agree, but on some systems which honours a different search path or is itself an alias. Prefer command -v in scripts:

command -v git || echo "git not found"

How tools like Homebrew, pyenv, and nvm modify PATH

These tools work by prepending a directory to the front of $PATH. When you install pyenv, it adds ~/.pyenv/shims before everything else:

export PATH="$HOME/.pyenv/shims:$PATH"

Now the shim directory is checked first. The shim intercepts python3, does version-switching logic, and then delegates to the real binary. If you append instead of prepend, the shim never gets a chance.

Shell startup files — which one runs when

The right file to put export PATH=… in depends on the shell and what you're launching:

File When it runs
~/.zshenv Every zsh invocation, including scripts. Export variables here only if truly universal.
~/.zprofile Login shells only (SSH, terminal app open). Use for PATH changes.
~/.zshrc Interactive shells. Aliases, prompts, completions. Never export PATH alone here — it is skipped for non-interactive shells.
~/.profile Login shells for POSIX sh-compatible shells (bash on Linux).

On macOS, opening a new Terminal.app window is a login shell, so ~/.zprofile runs. SSH is also a login shell. Running a script with zsh script.sh is neither login nor interactive — only ~/.zshenv applies.

export vs assignment

MY_VAR=hello          # local to this shell, not inherited by child processes
export MY_VAR=hello   # inherited by every child process spawned from here

Environment variables only flow down the process tree, never up. A child cannot change its parent's environment.

Shadowing a command

Prepending a directory gives you control over which binary runs. You can create a fake git that logs calls:

mkdir -p ~/bin
cat > ~/bin/git <<'EOF'
#!/bin/sh
echo "git called with: $*" >> /tmp/git.log
exec /usr/bin/git "$@"
EOF
chmod +x ~/bin/git
export PATH="$HOME/bin:$PATH"

Now git status hits your wrapper first. This technique is used by version managers, developer tools, and CI systems everywhere.