Terminal

How to Set an Environment Variable on Mac Permanently (zsh)

Published Nov 2, 2026
Tested on a real Mac. Ran the temporary/export/child-shell tests and the startup-file order tests with ZDOTDIR pointing at a scratch folder on macOS Tahoe 26.6.2, zsh 5.9; the real ~/.zshrc and ~/.zprofile were not edited and launchctl setenv was not run. Test machine: MacBook Air (M3, 16 GB), macOS Tahoe 26.6.2, zsh 5.9.
On this page
  1. Temporary vs. exported vs. permanent
  2. Which zsh file: .zshenv, .zprofile, .zshrc or .zlogin?
  3. Why PATH belongs in .zprofile, not .zshenv
  4. Add to PATH without duplicates
  5. Check what’s set: printenv and PATH
  6. GUI apps don’t read .zshrc
  7. Common questions

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:

FileRead byPut here
~/.zshenvEvery zsh, even scriptsRarely anything. Variables a script needs no matter how it starts
~/.zprofileLogin shells (each new Terminal window)PATH changes, Homebrew’s shellenv line
~/.zshrcInteractive shellsAliases, prompt, export of everyday variables
~/.zloginLogin shells, after .zshrcAlmost 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 open man page on Tahoe says opened apps “inherit environment variables” from the shell, and open --env NAME=VALUE -a AppName adds one just for that launch. Quit the app first; if it’s already running, open just 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 setenv at 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.