To set an environment variable on a Mac permanently, add an export line to ~/.zshrc and reload it:
echo 'export MY_VAR="hello"' >> ~/.zshrc
source ~/.zshrc
Every new Terminal window will now have MY_VAR. For PATH changes, use ~/.zprofile instead. I explain why below, with a test that shows ~/.zshenv can put your PATH entries in the wrong order. Everything was tested on macOS Tahoe 26.6.2 with the default shell, zsh 5.9. I used a scratch folder as a fake home so no real config files were touched.
Temporary vs. exported vs. permanent
There are three levels, and they behave very differently. I ran each one and printed what a child process (a new zsh) could see.
1. One command only. Put the variable in front of the command:
MY_VAR=hello zsh -c 'echo "child sees: [$MY_VAR]"'
echo "parent sees: [$MY_VAR]"
child sees: [hello]
parent sees: []
The variable exists only for that one command. Your shell never keeps it.
2. Shell variable, not exported. This is the trap:
MY_VAR=hello
echo "parent: [$MY_VAR]"
zsh -c 'echo "child sees: [$MY_VAR]"'
parent: [hello]
child sees: []
Your shell knows MY_VAR, but programs you start from it (Node, Python, make, a script) don’t. This is the most common reason for “I set it but my app can’t see it.”
3. Exported, for this window. Add export:
export MY_VAR
zsh -c 'echo "after export, child sees: [$MY_VAR]"'
after export, child sees: [hello]
Now every program started from this Terminal window sees it. When you close the window, it’s gone. To remove it sooner, run unset MY_VAR.
4. Permanent. Put the export line in a startup file, which brings us to which file.
Which zsh file: .zshenv, .zprofile, .zshrc or .zlogin?
zsh reads up to four files from your home folder, depending on what kind of shell starts. The zsh man page lists the order. Rather than trust a chart, I created all four files in a scratch folder, had each one print its name, and pointed zsh at the folder with ZDOTDIR. zsh looks for its startup files there instead of in your home folder, so your real config stays untouched:
mkdir -p ~/scratch/zdot && cd ~/scratch/zdot
for f in .zshenv .zprofile .zshrc .zlogin; do echo "echo \" read $f\"" > $f; done
echo 'export FOO_ENV=from-zshenv' >> .zshenv
echo 'export FOO=from-zshrc' >> .zshrc
Then start each kind of shell:
ZDOTDIR=~/scratch/zdot zsh -c 'echo "FOO=[$FOO] FOO_ENV=[$FOO_ENV]"'
ZDOTDIR=~/scratch/zdot zsh -i -c 'echo "FOO=[$FOO] FOO_ENV=[$FOO_ENV]"'
ZDOTDIR=~/scratch/zdot zsh -l -c 'echo "FOO=[$FOO] FOO_ENV=[$FOO_ENV]"'
ZDOTDIR=~/scratch/zdot zsh -il -c 'echo "FOO=[$FOO] FOO_ENV=[$FOO_ENV]"'
Here’s the real output:
== zsh -c (non-interactive, non-login: scripts)
read .zshenv
FOO=[] FOO_ENV=[from-zshenv]
== zsh -i -c (interactive)
read .zshenv
read .zshrc
FOO=[from-zshrc] FOO_ENV=[from-zshenv]
== zsh -l -c (login, non-interactive)
read .zshenv
read .zprofile
read .zlogin
FOO=[] FOO_ENV=[from-zshenv]
== zsh -il -c (login + interactive: what Terminal.app opens)
read .zshenv
read .zprofile
read .zshrc
read .zlogin
FOO=[from-zshrc] FOO_ENV=[from-zshenv]
What this tells you:
| File | Read by | Put here |
|---|---|---|
~/.zshenv | Every zsh, even scripts | Rarely anything. Variables a script needs no matter how it starts |
~/.zprofile | Login shells (each new Terminal window) | PATH changes, Homebrew’s shellenv line |
~/.zshrc | Interactive shells | Aliases, prompt, export of everyday variables |
~/.zlogin | Login shells, after .zshrc | Almost never needed |
Terminal.app opens a login and interactive shell, so it reads all four. That’s why “just put it in .zshrc” works for most people. But a script run as zsh myscript.sh reads only .zshenv. A variable from .zshrc is invisible there, as the FOO=[] lines show.
Why PATH belongs in .zprofile, not .zshenv
This is the tested gotcha. macOS ships /etc/zprofile, which runs Apple’s path_helper in every login shell:
if [ -x /usr/libexec/path_helper ]; then
eval `/usr/libexec/path_helper -s`
fi
zsh reads /etc/zprofile after your ~/.zshenv. So I put /opt/demo/bin at the front of PATH in .zshenv and compared:
non-login: /opt/demo/bin:/usr/bin:/bin:/usr/sbin:/sbin
login: /usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:…:/opt/demo/bin
In the login shell, path_helper rebuilt PATH from /etc/paths and pushed my folder to the end. If that folder holds a newer python or git than /usr/bin, the system one wins. When I moved the same line to .zprofile, which runs after /etc/zprofile, the folder stayed first:
login, set in .zprofile: /opt/demo/bin:/usr/local/bin:/System/Cry...
That’s why Homebrew’s installer tells you to add its eval "$(/opt/homebrew/bin/brew shellenv)" line to ~/.zprofile. If brew isn’t found after installing, see zsh: command not found: brew.
Add to PATH without duplicates
The usual line to add a folder to PATH is:
export PATH="$HOME/bin:$PATH"
Each time you run source ~/.zshrc, it adds the folder again. My test, after starting a shell and sourcing twice:
after 2x source: /opt/demo/bin:/opt/demo/bin:/opt/demo/bin:/usr/bin:/bin:/usr/sbin:/sbin
Many guides say to add typeset -U path to fix this. In my tests with zsh 5.9, that did not remove duplicates when the file assigns to PATH (the capital, colon-separated version):
with typeset -U: /opt/demo/bin:/opt/demo/bin:/usr/bin:/bin:/usr/sbin:/sbin
typeset -U path marks only the lowercase path array. Changes made through the uppercase PATH string kept the duplicates. Two fixes worked:
# Option A: mark both
typeset -U path PATH
export PATH="$HOME/bin:$PATH"
# Option B: edit the array instead
typeset -U path
path=($HOME/bin $path)
with typeset -U path PATH: /opt/demo/bin:/usr/bin:/bin:/usr/sbin:/sbin
Check what’s set: printenv and PATH
printenv NAME prints one variable. Its exit code tells you whether the variable exists in the environment:
printenv MY_VAR; echo "rc=$?"
printenv NOPE; echo "rc=$?"
hello
rc=0
rc=1
printenv shows only exported variables, so it’s a quick way to catch the “set but not exported” trap. echo $MY_VAR would still print a value that isn’t exported.
To read PATH one folder per line:
echo $PATH | tr ':' '\n'
Here’s the default PATH on Tahoe for a login shell with no personal config (first six lines):
/usr/local/bin
/System/Cryptexes/App/usr/bin
/usr/bin
/bin
/usr/sbin
/sbin
On my real account, the same command showed one folder listed three times. That’s the duplicate problem from the section above, in the wild.
GUI apps don’t read .zshrc
Apps you open from the Dock or Finder (VS Code, IntelliJ, a GUI Git client) are started by launchd, not by zsh. They never read ~/.zshrc or ~/.zprofile. You can set a variable for them with:
launchctl setenv MY_VAR hello
I didn’t run this, because it changes the running session. It only affects apps launched after you run it. And it does not survive a restart or a log-out. Practical options:
- Launch the app from Terminal so it inherits your shell’s variables. The
openman page on Tahoe says opened apps “inherit environment variables” from the shell, andopen --env NAME=VALUE -a AppNameadds one just for that launch. Quit the app first; if it’s already running,openjust brings it forward. - Many editors offer a setting to load the login shell’s environment.
- For something that must survive reboots, look at a LaunchAgent that runs
launchctl setenvat login. Test it carefully first.
Common questions
Do I need to restart Terminal after editing .zshrc?
No. Run source ~/.zshrc to apply it in the current window. New windows pick it up automatically. Windows that were already open keep the old values until you source the file there too.
Should I use .bash_profile on a Mac?
Only if you switched your shell to bash. zsh has been the default since macOS Catalina, and zsh doesn’t read .bash_profile or .bashrc. Check yours with echo $SHELL. Mine printed /bin/zsh.
How do I remove a permanent variable?
Delete the export line from the file, then run unset MY_VAR in each open window. Sourcing the file again won’t remove a variable that’s already set.
Is it safe to put API keys in .zshrc?
It works, but the file is plain text, and people often share dotfiles online by accident. Keep secrets in a separate file (for example ~/.secrets, readable only by you) and source it from .zshrc. The same care applies to keys in ~/.ssh. See how to generate an SSH key on Mac.
Why does my variable work in Terminal but not in a script?
A zsh script runs as a non-interactive shell and reads only ~/.zshenv, as the first test above shows. Either set the variable inside the script or move it to ~/.zshenv. Jobs started by cron or launchd don’t go through your Terminal session at all, so set the variables in the job itself.