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 .zprofile and interactive behavior in .zshrc. Keep .zshenv silent and minimal. Do not move interactive version-manager hooks into .zprofile or source the whole profile from .zshrc; inspect the tool’s generated code and follow its current Zsh instructions.

Zsh evaluates two independent flags

Zsh startup files selected by login and interactive modes

A shell may be:

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:

  1. $ZDOTDIR/.zshenv for every Zsh invocation
  2. $ZDOTDIR/.zprofile if the shell is a login shell
  3. $ZDOTDIR/.zshrc if the shell is interactive
  4. $ZDOTDIR/.zlogin if the shell is a login shell
  5. $ZDOTDIR/.zlogout when 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.

Zsh startup sequence for four shell modes

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

FileSelection ruleGood candidatesAvoid
.zshenvEvery ZshRare variables required by every Zsh processoutput, aliases, prompts, network calls, slow commands
.zprofileLogin shellslogin-session path setup and exported defaultsinteractive hooks, completions, key bindings
.zshrcInteractive shellsprompt, completion, aliases, shell options, interactive tool hooksoutput or mutations meant for scripts
.zloginLogin shells, after .zshrcrare post-interactive login actionsconfiguration that must precede .zshrc
.zlogoutLogin-shell exitsmall cleanup or terminal reset actionsimportant 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:

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.

References