Zsh Startup Files: ~/.zprofile vs ~/.zshrc on macOS and Linux
If your terminal feels slow, or an environment variable appears in one terminal but not another, the cause is often Zsh’s startup mode. The two files most people reach for are ~/.zprofile and ~/.zshrc, but neither filename alone tells you whether a line belongs there.
Zsh chooses startup files from two independent properties: whether the shell is a login shell and whether it is interactive. Terminal applications on macOS and Linux can choose different combinations, so inspect the running shell instead of assuming the operating system decides for you.
TL;DR. Put login-session initialization in
.zprofileand interactive behavior in.zshrc. Keep.zshenvsilent and minimal. Do not move interactive version-manager hooks into.zprofileor source the whole profile from.zshrc; inspect the tool’s generated code and follow its current Zsh instructions.
Zsh evaluates two independent flags
A shell may be:
- login and interactive, as Apple Terminal normally launches the account’s default login shell
- non-login and interactive, as a nested
zshor many Linux terminal configurations launch it - login and non-interactive, as
zsh -lc 'command' - non-login and non-interactive, as
zsh -c 'command'
Terminal applications, IDEs, remote sessions, multiplexers, and explicit shell flags can choose different modes. Operating system alone does not determine the result.
Ask the running shell instead of guessing:
print -r -- "interactive=$options[interactive] login=$options[login]"
The actual startup order
Zsh reads global files and then the corresponding user file. With the default RCS options, the user-file order is:
$ZDOTDIR/.zshenvfor every Zsh invocation$ZDOTDIR/.zprofileif the shell is a login shell$ZDOTDIR/.zshrcif the shell is interactive$ZDOTDIR/.zloginif the shell is a login shell$ZDOTDIR/.zlogoutwhen a login shell exits normally
If ZDOTDIR is unset, Zsh uses $HOME. System-wide file locations are installation-specific; the common defaults are /etc/zshenv, /etc/zprofile, /etc/zshrc, /etc/zlogin, and /etc/zlogout.
The order is a filter, not a lifecycle guarantee. .zprofile runs once per login-shell process, not once per computer login. If every new terminal tab starts a login shell, every tab reads it.
What belongs in each file
| File | Selection rule | Good candidates | Avoid |
|---|---|---|---|
.zshenv | Every Zsh | Rare variables required by every Zsh process | output, aliases, prompts, network calls, slow commands |
.zprofile | Login shells | login-session path setup and exported defaults | interactive hooks, completions, key bindings |
.zshrc | Interactive shells | prompt, completion, aliases, shell options, interactive tool hooks | output or mutations meant for scripts |
.zlogin | Login shells, after .zshrc | rare post-interactive login actions | configuration that must precede .zshrc |
.zlogout | Login-shell exit | small cleanup or terminal reset actions | important state that must survive crashes or exec |
Keep .zshenv boring
Every non-interactive zsh -c reads .zshenv. An echo, package-manager call, or expensive subprocess there can corrupt command output and slow scripts. Most users need little or nothing in this file.
An exported variable in .zprofile is inherited by children of that login shell. It is not automatically injected into unrelated GUI applications, services, or shells whose parent never read the file.
Use .zprofile for login-only setup
A small profile might establish exported defaults and an idempotent path:
# ~/.zprofile
export EDITOR=nvim
export VISUAL=nvim
# Zsh ties the path array to the PATH scalar.
typeset -U path PATH
path=("$HOME/.local/bin" $path)
export PATH
typeset -U removes duplicate array entries. It makes repeated sourcing safer, though startup files should still avoid unnecessary work.
Use .zshrc for interactive state
# ~/.zshrc
setopt auto_cd hist_ignore_all_dups share_history
autoload -Uz compinit
compinit
alias ll='ls -lah'
bindkey -e
Aliases, completion widgets, key maps, prompts, and directory-change hooks belong to the interactive shell that uses them.
Version managers cross the simple file boundary
“Put version managers in .zprofile because they are slow” is unreliable advice. A version manager may emit several kinds of code:
- a static path or root variable
- a command shim path needed by child processes
- completion definitions
- shell functions that change the current shell
- directory-change hooks for automatic version switching
The interactive parts must exist in every interactive shell, including a nested zsh launched from an editor. Current fnm documentation therefore puts this hook in .zshrc:
eval "$(fnm env --use-on-cd --shell zsh)"
Current pyenv documentation likewise puts full interactive initialization in .zshrc:
export PYENV_ROOT="$HOME/.pyenv"
[[ -d $PYENV_ROOT/bin ]] && path=("$PYENV_ROOT/bin" $path)
eval "$(pyenv init - zsh)"
Pyenv documents a narrower pyenv init --path mode for shim-path setup, but full pyenv init also installs completion and shell functions. Inspect a tool’s current documentation and generated output before splitting it.
If startup is slow, measure it before moving code to a mode where it no longer works.
Do not source the whole profile from .zshrc
A common cross-platform workaround is:
# Avoid this blanket coupling.
source ~/.zprofile
That turns login-only actions into per-interactive-shell actions and can repeat agent startup, keychain access, output, or path mutation.
If both login and non-login interactive shells need a small set of static exports, extract an idempotent fragment and source it deliberately:
# ~/.config/zsh/environment.zsh
typeset -U path PATH
path=("$HOME/.local/bin" $path)
export PATH EDITOR=nvim VISUAL=nvim
# ~/.zprofile
source "$HOME/.config/zsh/environment.zsh"
# ~/.zshrc
if [[ ! -o login ]]; then
source "$HOME/.config/zsh/environment.zsh"
fi
# Interactive-only configuration follows.
This pattern keeps shared data separate from login side effects. Another valid choice is to configure the terminal or session manager consistently instead of supporting both modes.
Diagnose the shell that is slow or missing state
Reproduce each mode directly:
zsh -lic 'print "login interactive"'
zsh -ic 'print "non-login interactive"'
zsh -lc 'print "login non-interactive"'
zsh -c 'print "non-login non-interactive"'
Measure the mode users actually launch:
time zsh -lic exit
time zsh -ic exit
Trace file and line execution when the source is unclear:
PS4='%N:%i> ' zsh -xlic exit
Run the trace in a clean test account or inspect it before sharing: startup commands can expand tokens and other sensitive values.
For a missing executable, inspect both the mode and the path:
print -r -- "interactive=$options[interactive] login=$options[login]"
print -l -- $path
whence -va python node uv
Conclusion
The useful distinction is not “environment versus aliases.” It is login-only state versus interactive state, plus the small amount that truly must affect every Zsh process.
Confirm the shell flags, keep startup files quiet, and place each generated hook where all of its required features exist. That makes the configuration portable across terminal applications without pretending that macOS and Linux each have one fixed launch mode.