Understanding $PATH: how the shell actually finds your commands
Demystify the PATH environment variable and 'command not found': how the shell searches, why order matters, and how to manage it safely.
You type python and get command not found. Or worse: you install a new version of a tool, run it, and the old one responds. Both problems trace back to one variable: $PATH. Understanding it — really understanding it, not just cargo-culting export PATH=... into a dotfile — makes you noticeably faster at the command line and saves hours of confused debugging.
This post covers how PATH works, how to inspect it, how to modify it, the shell-startup-file mess that trips everyone up, and a security rule you must not skip.
What PATH actually is
$PATH is an environment variable that holds a colon-separated list of directory paths. When you type a bare command name — python, git, ls — your shell does not guess. It walks through every directory in $PATH, left to right, and looks for an executable file with that name. The first match wins. If no directory contains that name, you get command not found.
Print yours right now:
echo $PATH
# /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
Each colon marks a boundary. The shell checks /usr/local/bin first, then /usr/bin, then /bin, and so on. Later directories are only consulted if earlier ones do not contain a match.
This ordered-search behavior is not a quirk — it is the feature. It is the precise mechanism that lets you override a system tool without touching system files.
Diagram: how the shell scans PATH for python
Tools for inspecting PATH
Knowing which binary you are actually running is the first debugging move. Four tools help, and they are not interchangeable.
which — searches PATH and prints the path of the first match. Simple, widely available:
which python
# /usr/local/bin/python
type — a shell built-in that knows about everything: external files on PATH, shell aliases, shell functions, and built-in commands. Prefer this when you are not sure what which will miss:
type ls
# ls is aliased to `ls --color=auto'
type cd
# cd is a shell builtin
type python
# python is /usr/local/bin/python
command -v — POSIX-portable alternative to which. Useful in scripts that should run on any POSIX shell:
command -v git
# /usr/bin/git
whereis — searches a fixed set of standard locations (not just PATH) and also locates man pages and source files. Useful for finding what is installed system-wide, less useful for diagnosing which version a running shell actually uses.
The practical rule: use type when debugging an alias or function shadowing an external command; use command -v in portable scripts; use which for quick interactive checks.
See the environment and PATH reference for a deeper comparison of these tools.
Why which python sometimes lies
which only searches PATH. If you have defined a shell function named python, which python finds the binary — but your shell calls the function. type python catches that case. Always use type when something unexpected is happening.
How to modify PATH
The canonical pattern is to prepend your directory so it takes priority:
export PATH="$HOME/bin:$PATH"
Appending ($PATH:$HOME/bin) works when you want your directory to be a fallback, consulted only when nothing else matches.
You can confirm the change immediately:
echo $PATH
which my-tool
Changes made at the command line last only for the current shell session. To make them permanent you need to put the export line in the right startup file.
The startup-file mess: login vs non-login, interactive vs non-interactive
This is the part that trips everyone up. Shells load different files depending on how they are invoked.
Login shells — started when you first authenticate (SSH login, macOS terminal with “Run command as a login shell” checked, bash -l). They source:
- Bash:
/etc/profile, then the first of~/.bash_profile,~/.bash_login, or~/.profilethat exists - Zsh:
/etc/zprofile, then~/.zprofile, then~/.zshrcif also interactive
Non-login interactive shells — a new terminal tab opened inside a running GUI session. They source:
- Bash:
~/.bashrc - Zsh:
~/.zshrc
Non-interactive shells — scripts run with bash script.sh. Neither .bashrc nor .bash_profile is sourced automatically.
The common mistake: you put export PATH=... in ~/.bashrc but log in over SSH and wonder why the change is absent. Or you put it in ~/.bash_profile and wonder why a new terminal tab does not see it.
Practical recommendations:
- Zsh (default on macOS): put PATH changes in
~/.zprofile(login) and/or~/.zshrc(interactive). Most people put everything in~/.zshrcand source~/.zprofilefrom it. - Bash on Linux: put PATH changes in
~/.bashrcand source~/.bashrcfrom~/.bash_profilewith the standard guard ([[ -f ~/.bashrc ]] && source ~/.bashrc).
See files and directories on the CLI for how to navigate your home directory dotfiles quickly.
More CLI patterns live in the CLI section.
Diagram: why virtualenv works
Activating a Python virtual environment prepends the environment’s bin directory to PATH. The system Python is still there — it just loses the race.
The same mechanism explains nvm use 20, conda activate myenv, and asdf reshim. They all modify PATH. They do not patch system binaries. That is why they are safe to use alongside each other.
Hashing and command caching
Bash maintains an internal hash table of command-to-path lookups to avoid repeating the PATH search on every invocation. If you install a new version of a tool after the shell has cached the old path, the old path wins until you clear the cache:
hash -r # clear the entire hash table
hash -d python # clear only python
Zsh does not cache this way by default, so hash -r matters most in Bash. You can inspect the table with hash alone.
Absolute and relative paths bypass PATH entirely
PATH is consulted only when you type a bare command name. If you specify any path at all, the shell uses it directly:
/usr/bin/python --version # absolute path, no PATH search
./my-script.sh # relative path, no PATH search
This is why scripts must be called with ./script.sh rather than script.sh unless their directory is on PATH. It is also how you can test a specific binary without modifying PATH.
Built-ins vs external commands
Not every command is a file on disk. Shell built-ins (cd, echo, export, alias, source) are part of the shell itself. They execute without any PATH search and cannot be overridden by a file on disk with the same name. type distinguishes them:
type export
# export is a shell builtin
External commands are separate executables found via PATH. ls, grep, git, and python are external. Built-ins take priority over external commands; you cannot shadow cd with a script named cd.
Security: never put . early in PATH
The same risk applies to any world-writable directory appearing early in PATH. Regularly audit your PATH with echo $PATH and remove anything you do not recognize or control.
Quick reference
echo $PATH # print current PATH
type python # what the shell actually calls
which python # first binary match on PATH
command -v python # POSIX-portable which
hash -r # clear bash command cache after install
export PATH="$HOME/bin:$PATH" # prepend; put in ~/.zshrc or ~/.bashrc
export PATH="$PATH:$HOME/bin" # append (fallback)
For a broader introduction to environment variables, see the environment and PATH guide and the glossary.
Frequently asked questions
Why does sudo python use a different Python than plain python?
sudo runs with a restricted environment for security. By default it resets PATH to a safe system default and does not inherit your user PATH. Pass -E to preserve your environment (sudo -E python) or use the full path: sudo /home/user/.venv/bin/python.
I added an export PATH=... line to my dotfile but the change only takes effect after I open a new terminal. How do I apply it immediately?
Source the file in your current session: source ~/.zshrc or . ~/.bashrc. Sourcing runs the file in the current shell’s context rather than a subprocess, so the exports take effect immediately.
which python shows the right path, but running python still launches the wrong one. What is going on?
You almost certainly have a shell alias or function that shadows the binary. Run type python instead. If it says something like python is aliased to python2, you have found the culprit. Unalias it or rename the alias.
If I have two tools with the same name installed in different directories, can I keep both? Yes. They coexist peacefully; whichever directory appears earlier in PATH is the one used for bare invocations. You can always reach either one explicitly by its full absolute path, regardless of PATH order.