Zsh Scripting: Startup Files Complete Guide
Zsh (Z shell) is one of the most powerful and customizable shells available for Unix-like systems. One of its greatest strengths lies in its flexible startup file system, which allows you to configure behavior for different types of shell sessions. Understanding these files is essential for any developer who wants to automate workflows, manage environment variables, define aliases, and keep a clean, portable configuration across machines.
This guide walks through every Zsh startup file, explains when each one is sourced, shows practical examples, and ends with best practices you can adopt immediately.
Why Zsh Startup Files Matter
Every time you open a terminal, SSH into a server, or run a script, Zsh goes through a specific sequence of file reads. Each file has a distinct purpose:
- Environment consistency: Ensure variables like
PATH,EDITOR, andLANGare set correctly everywhere. - Performance: Avoid loading heavy modules in non-interactive contexts where they are wasted.
- Security: Prevent sensitive data from leaking into scripts or subshells.
- Portability: Keep your dotfiles version-controlled so you can replicate your setup anywhere.
- Separation of concerns: Login shells, interactive shells, and non-interactive shells each have different needs.
Misplacing configuration in the wrong file is a common source of bugs — for example, defining an alias in .zshenv means it is loaded even by scripts that never display a prompt, wasting time and potentially breaking automation.
The Zsh Startup File Order
Zsh reads files in a specific order depending on the type of shell being launched. The five user-level files, in order of execution, are:
~/.zshenv— Always sourced, for every shell.~/.zprofile— Sourced only by login shells, after.zshenv.~/.zshrc— Sourced by interactive shells, after.zprofile.~/.zlogin— Sourced by login shells, after.zshrc.~/.zlogout— Sourced by login shells, at exit.
There are also global equivalents in /etc/ that are sourced before the user versions: /etc/zshenv, /etc/zprofile, /etc/zshrc, /etc/zlogin, and /etc/zlogout. These are typically managed by your operating system or distribution.
Understanding Shell Types
To know which files will be loaded, you must understand the three main shell types:
- Login shell: Started after authentication (e.g., SSH, terminal login). Identified by a leading hyphen in
$0or by the-lflag. - Interactive shell: Reads commands from a terminal with a prompt. Detected with
[[ -o interactive ]]. - Non-interactive shell: Runs scripts or commands without a prompt, e.g.,
zsh script.sh.
A shell can be both login and interactive (like opening a terminal in macOS), login but non-interactive (like ssh user@host 'command'), or interactive but not a login shell (like launching zsh from within an existing shell).
File-by-File Breakdown
1. ~/.zshenv — The Universal File
.zshenv is sourced by every Zsh invocation, including scripts, cron jobs, and subshells. Because of this, it must be fast and free of any output. This is the right place for environment variables that every process needs.
Keep it minimal. Avoid aliases, functions, prompt setup, or anything that produces output. Loading a heavy framework here will slow down every script you run.
# ~/.zshenv
# Essential environment variables
export EDITOR="nvim"
export VISUAL="$EDITOR"
export PAGER="less"
export LANG="en_US.UTF-8"
export LC_ALL="en_US.UTF-8"
# Extend PATH with user-local binaries
typeset -U path
path=(
$HOME/.local/bin
$HOME/.cargo/bin
$HOME/.npm-global/bin
$path
)
# Set default umask for all processes
umask 022
The typeset -U path line is a Zsh-specific trick that keeps the path array free of duplicates automatically. Note that path (lowercase) is tied to PATH (uppercase), so modifying the array updates the environment variable.
2. ~/.zprofile — Login Shell Setup
.zprofile is sourced only by login shells, right after .zshenv. It is the Zsh equivalent of Bash's .bash_profile. Use it for tasks that should happen once per session, such as starting a background agent, printing a welcome message, or running commands that depend on a TTY.
# ~/.zprofile
# Start ssh-agent only for login shells
if [[ -z "$SSH_AUTH_SOCK" ]]; then
eval "$(ssh-agent -s)" >/dev/null
ssh-add ~/.ssh/id_ed25519 2>/dev/null
fi
# Print system information on login
echo "Welcome back, $USER!"
echo "System: $(uname -sr)"
echo "Uptime: $(uptime -p)"
echo
# Run keychain for git signing
if command -v keychain >/dev/null 2>&1; then
eval "$(keychain --eval --quiet id_ed25519)"
fi
Because .zprofile runs only once at login, it is ideal for expensive operations that do not need to repeat in every new terminal tab.
3. ~/.zshrc — The Interactive Workhorse
.zshrc is the most commonly edited file. It is sourced by interactive shells after .zprofile. This is where you define aliases, functions, prompt themes, completions, history settings, and load frameworks like Oh My Zsh or Prezto.
# ~/.zshrc
# History settings
HISTFILE=~/.zsh_history
HISTSIZE=10000
SAVEHIST=10000
setopt APPEND_HISTORY
setopt SHARE_HISTORY
setopt HIST_IGNORE_DUPS
setopt HIST_IGNORE_SPACE
setopt HIST_VERIFY
setopt EXTENDED_HISTORY
# Useful options
setopt AUTO_CD
setopt CORRECT
setopt NO_BEEP
setopt INTERACTIVE_COMMENTS
# Aliases
alias ll='ls -lah'
alias gs='git status'
alias gd='git diff'
alias gp='git push'
alias gl='git log --oneline --graph --decorate -20'
alias ..='cd ..'
alias ...='cd ../..'
alias reload='source ~/.zshrc'
# Functions
mkcd() {
mkdir -p "$1" && cd "$1"
}
extract() {
case "$1" in
*.tar.gz|*.tgz) tar xzf "$1" ;;
*.tar.bz2|*.tbz2) tar xjf "$1" ;;
*.zip) unzip "$1" ;;
*.rar) unrar x "$1" ;;
*) echo "Unknown archive format: $1" ;;
esac
}
# Load completion system
autoload -Uz compinit && compinit
zstyle ':completion:*' menu select
zstyle ':completion:*' matcher-list 'm:{a-z}={A-Z}'
# Prompt (simple two-line prompt)
autoload -Uz colors && colors
PROMPT='%F{cyan}%~%f %F{green}%#%f '
RPROMPT='%F{yellow}[%?]%f'
This is also the right place to load third-party plugins. If you use a framework, it typically manages this file for you, but understanding what it does helps you debug issues.
4. ~/.zlogin — Post-Interactive Login Setup
.zlogin is sourced by login shells, but after .zshrc. This makes it useful for tasks that depend on interactive configuration already being loaded. Most users do not need this file, and many setups leave it empty or omit it entirely.
# ~/.zlogin
# Start a tmux session automatically on remote login
if [[ -n "$SSH_CONNECTION" ]] && [[ -z "$TMUX" ]]; then
tmux attach-session -t default || tmux new-session -s default
fi
A common pattern is to use either .zprofile or .zlogin, not both, to avoid confusion. The difference is timing: .zprofile runs before .zshrc, while .zlogin runs after.
5. ~/.zlogout — Login Shell Cleanup
.zlogout is sourced when a login shell exits. Use it to clean up temporary files, kill background processes, or clear the screen.
# ~/.zlogout
# Clear the screen on exit
clear
# Remove temporary files created during the session
rm -f /tmp/$USER-session-* 2>/dev/null
# Stop ssh-agent if we started it
if [[ -n "$SSH_AGENT_PID" ]]; then
eval "$(ssh-agent -k)" 2>/dev/null
fi
Putting It All Together: A Complete Example
Here is a minimal, well-organized set of startup files you can use as a starting point. Together they demonstrate the separation of concerns that makes Zsh configuration maintainable.
.zshenv
# ~/.zshenv — sourced by every shell
export EDITOR="nvim"
export PAGER="less"
export LANG="en_US.UTF-8"
typeset -U path
path=($HOME/.local/bin $HOME/.cargo/bin $path)
.zprofile
# ~/.zprofile — login shells only, before .zshrc
if [[ -z "$SSH_AUTH_SOCK" ]]; then
eval "$(ssh-agent -s)" >/dev/null
fi
.zshrc
# ~/.zshrc — interactive shells only
HISTFILE=~/.zsh_history
HISTSIZE=10000
SAVEHIST=10000
setopt SHARE_HISTORY HIST_IGNORE_DUPS
alias ll='ls -lah'
alias gs='git status'
autoload -Uz compinit && compinit
PROMPT='%F{cyan}%~%f %# '
.zlogin
# ~/.zlogin — login shells, after .zshrc
if [[ -n "$SSH_CONNECTION" ]] && [[ -z "$TMUX" ]]; then
tmux attach || tmux new
fi
.zlogout
# ~/.zlogout — login shell exit
clear
Debugging Startup File Execution
If you are unsure which files are being loaded or in what order, you can trace execution by adding echo statements temporarily, or by running Zsh with verbose flags.
# Trace which files are sourced
zsh -xlic exit 2>&1 | head -50
# Show the path Zsh searches for startup files
echo $fpath
# Check if current shell is interactive
[[ -o interactive ]] && echo "interactive" || echo "non-interactive"
# Check if current shell is a login shell
[[ -o login ]] && echo "login" || echo "not login"
Another useful technique is to add a marker line at the top of each file:
echo "Sourced ~/.zshenv" >&2
This lets you see the exact order in which files load when you start a new shell.
Best Practices
- Keep
.zshenvlean. Only environment variables andPATHsetup. No aliases, no functions, no output. - Use
.zshrcfor interactive features. Aliases, prompts, completions, and plugins belong here. - Avoid duplicating logic. Pick either
.zprofileor.zloginfor login-only setup, not both. - Version control your dotfiles. Store them in a Git repository and symlink them into your home directory. This makes recovery and migration trivial.
- Guard expensive operations. Use
command -vchecks before calling tools that may not be installed on every machine. - Use
typeset -U pathto prevent duplicate entries in yourPATH. - Test on a clean shell. Run
zsh -fto start Zsh without reading any startup files, then source them one at a time to isolate issues. - Keep machine-specific settings separate. Use a
~/.zshrc.localfile sourced at the end of.zshrcand excluded from version control. - Be mindful of macOS. On macOS, every new Terminal.app window starts a login shell by default, so
.zprofileruns more often than you might expect. - Profile startup time. Run
time zsh -i -c exitto measure how long your interactive shell takes to start. If it is slow, trim unnecessary plugin loads.
Example: Machine-Specific Overrides
# At the end of ~/.zshrc
if [[ -f ~/.zshrc.local ]]; then
source ~/.zshrc.local
fi
This pattern lets you keep shared configuration in version control while allowing each machine to override or extend settings locally without conflicts.
Conclusion
Zsh's startup file system may seem complex at first, but it follows a clear and logical design. By placing each piece of configuration in the correct file — environment variables in .zshenv, login-time setup in .zprofile or .zlogin, interactive features in .zshrc, and cleanup in .zlogout — you gain precise control over how your shell behaves in every context. This separation keeps your configuration fast, portable, and maintainable. Once you internalize the file order and the shell types that trigger each one, you can build a dotfiles setup that works reliably across laptops, servers, and containers alike. Start simple, version-control your files, and refine them over time as your workflow evolves.