PATH & Environment
How the shell finds commands.
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.