The most surprising thing about Zsh startup files is that the order in which they’re read isn’t always what you’d expect, and the first one to set a variable often wins, regardless of later files.
Let’s see this in action. Imagine you have a simple zshenv and zshrc:
~/.zshenv:
export MY_VAR="set in zshenv"
~/.zshrc:
export MY_VAR="set in zshrc"
If you start an interactive shell (like opening a new terminal window), you’ll see:
% echo $MY_VAR
set in zshrc
But if you run a non-interactive script that sources your Zsh configuration, like this:
test_script.zsh:
#!/bin/zsh
source ~/.zshrc
echo $MY_VAR
And you run it:
% zsh test_script.zsh
set in zshrc
Now, let’s swap them:
~/.zshenv:
export MY_VAR="set in zshenv"
~/.zshrc:
export MY_VAR="set in zshrc"
And run the same test script:
% zsh test_script.zsh
set in zshrc
This is because zshrc is explicitly sourced by the shell for interactive sessions after zshenv. The core mental model here is that Zsh has a hierarchy, but also a "first-writer-wins" rule for environment variables that can override this hierarchy.
Here’s the full picture of Zsh startup files and their typical order:
-
zshenv: This is the first file sourced for all Zsh invocations (interactive, non-interactive, login, non-login). It’s primarily for setting environment variables that should be available everywhere. If you setMY_VAR="global"here, it will be set. -
zprofile: Sourced for login shells. This is where you’d typically put things likePATHmodifications or other settings that should only apply when you first log in. -
zshrc: Sourced for interactive shells. This is the workhorse for your daily terminal use – aliases, functions, prompt customization, etc. Crucially, it’s sourced afterzprofilefor login interactive shells. -
zlogin: Sourced for login shells, afterzshrc. Less commonly used thanzprofile. -
zlogout: Sourced when a login shell exits.
The key distinction is between login and non-login shells, and interactive versus non-interactive shells.
- Login shell: You get this when you log in directly (e.g., via SSH or
logincommand). It sourceszshenv, thenzprofile, thenzshrc(if interactive), thenzlogin(if interactive). - Non-login interactive shell: This is typically a new terminal window you open after you’ve already logged in. It sources
zshenv, thenzshrc. - Non-interactive shell: This is a script run with
zsh script.zshorsh script.zsh(ifscript.zshhas#!/bin/zsh). It sourceszshenv. If the script explicitlysourceszshrc, thenzshrcwill be read.
The "first writer wins" behavior is most prominent with environment variables (export VAR=value). If zshenv exports MY_VAR="global" and zshrc exports MY_VAR="interactive", the zshrc value will overwrite the zshenv value in an interactive shell because zshrc is read later. However, if a script explicitly sources zshenv after zshrc, the zshenv value will win. This is why you need to be careful about explicit source commands in scripts.
The typical use case for zshenv is setting fundamental environment variables like PATH, LANG, or EDITOR that need to be consistent across all shell types. zprofile is for login-specific setup, and zshrc is for interactive session customization.
One thing most people don’t know is how Zsh handles PATH specifically. While export PATH=/new/path:$PATH in zshenv will set the PATH, if you then do export PATH=/another/path:$PATH in zshrc, the zshrc value will prevail for interactive shells. However, if you have a script that only sources zshenv, that PATH will be the only one set. This means you often see PATH modifications duplicated or carefully ordered across zshenv and zshrc to ensure consistency for interactive shells while respecting the minimal environment for non-interactive ones.
The next concept you’ll likely grapple with is how to manage these files effectively, especially when dealing with different environments or projects, leading you to tools like direnv.