Understanding the shell startup sequence is crucial for effective dotfile management. This document explains how Zsh (and other shells) load configuration files, when each file is sourced, and how to optimize your setup for performance and functionality.
Unix shells have evolved complex initialization sequences to handle different use cases: login shells, interactive shells, non-interactive shells, and shell scripts. Each type requires different configurations, leading to multiple initialization files with specific purposes. This complexity often confuses developers, resulting in misconfigured environments or performance issues.
The shell startup sequence evolved from the original Bourne shell’s simple
.profile
file to today’s multi-file systems. This evolution addressed
several needs:
Before diving into the startup sequence, understanding shell types is essential:
graph TD
A[Shell Invocation] --> B{Login Shell?}
B -->|Yes -l| C{Interactive?}
B -->|No| D{Interactive?}
C -->|Yes| E[Login + Interactive<br/>Terminal login, SSH]
C -->|No| F[Login + Non-interactive<br/>Rare edge case]
D -->|Yes| G[Non-login + Interactive<br/>New terminal window]
D -->|No| H[Non-login + Non-interactive<br/>Script execution]
style E fill:#90EE90
style G fill:#87CEEB
style H fill:#FFB6C1
The complete Zsh startup sequence:
graph TD
A[Zsh Starts] --> B["/etc/zshenv"]
B --> C["~/.zshenv"]
C --> D{Login Shell?}
D -->|Yes| E["/etc/zprofile"]
E --> F["~/.zprofile"]
F --> G{Interactive?}
D -->|No| G
G -->|Yes| H["/etc/zshrc"]
H --> I["~/.zshrc"]
I --> J{Login Shell?}
G -->|No| N[Ready]
J -->|Yes| K["/etc/zlogin"]
K --> L["~/.zlogin"]
L --> M[Ready]
J -->|No| M
M --> N
style C fill:#FFE5B4
style I fill:#90EE90
Each configuration file serves a specific purpose:
File | When Loaded | Purpose | Example Content |
---|---|---|---|
~/.zshenv | Always | Environment variables | PATH, EDITOR, LANG |
~/.zprofile | Login shells | Login-time setup | Large initializations |
~/.zshrc | Interactive shells | Aliases, prompts, functions | Most configurations |
~/.zlogin | After zshrc (login) | Post-setup tasks | Message of the day |
~/.zlogout | Login shell exit | Cleanup tasks | Clear screen, temp cleanup |
The multi-file system evolved to handle different scenarios efficiently:
Most modern setups concentrate configuration in fewer files:
# ~/.zshenv - Minimal, just critical environment
export ZDOTDIR="$HOME/.config/zsh"
export PATH="$HOME/bin:$PATH"
# ~/.zshrc - Everything else
# Aliases, functions, completions, prompt, etc.
Detect if running in a login shell:
# In .zshrc
if [[ -o login ]]; then
echo "Welcome, $USER!"
# Login-specific tasks
fi
Only load interactive features when needed:
# In .zshrc
if [[ $- == *i* ]]; then
# Interactive-only configurations
alias ll='ls -la'
bindkey '^R' history-incremental-search-backward
fi
Debug slow startup:
# Add to start of .zshrc
zmodload zsh/zprof
# Add to end of .zshrc
zprof # Shows time spent in each function
Load configurations based on availability:
# Load tool if available
if command -v starship >/dev/null 2>&1; then
eval "$(starship init zsh)"
fi
# Source file if exists
[[ -f ~/.aliases ]] && source ~/.aliases
graph TD
A[Bash Starts] --> B{Login Shell?}
B -->|Yes| C["/etc/profile"]
C --> D["~/.bash_profile"<br/>OR ~/.bash_login<br/>OR ~/.profile"]
D --> E{Interactive?}
B -->|No| F{Interactive?}
E -->|Yes| G["~/.bashrc"]
F -->|Yes| G
E -->|No| H[Ready]
F -->|No| H
G --> H
Key differences from Zsh:
zshenv
(always-loaded file)~/.bashrc
not automatically loaded for login shells.bashrc
from .bash_profile
Fish uses a more straightforward model:
~/.config/fish/config.fish # Always for interactive
~/.config/fish/functions/ # Autoloaded functions
~/.config/fish/conf.d/ # Autoloaded configs
Defer expensive operations:
# Instead of immediate execution
eval "$(rbenv init -)"
# Use lazy loading
rbenv() {
eval "$(command rbenv init -)"
rbenv "$@"
}
Skip unnecessary loads:
# Only load work config at work
if [[ "$(hostname)" == *"work"* ]]; then
source ~/.config/zsh/work.zsh
fi
Compile scripts for faster loading:
# Compile .zshrc if newer than compiled version
if [[ ~/.zshrc -nt ~/.zshrc.zwc ]]; then
zcompile ~/.zshrc
fi
Measure startup time:
# Time shell startup
time zsh -i -c exit
# Detailed profiling
zsh -xvl 2>&1 | ts -i "%.s" > startup.log
Problem: Commands not found in scripts
Cause: PATH only set in .zshrc
Solution: Move PATH to .zshenv
Problem: Aliases not working in vim
Cause: Vim spawns non-interactive shell
Solution: Use functions or scripts instead
Problem: Slow terminal startup
Cause: Heavy operations in .zshrc
Solution: Profile and optimize, use lazy loading
Problem: Different behavior SSH vs local
Cause: SSH creates login shell
Solution: Ensure consistent configs across files
zsh -x # Print commands as executed
[[ -o login ]] && echo "Login shell"
[[ -o interactive ]] && echo "Interactive shell"
# Add to each file
echo "Loading $(basename $0)"
.zshenv
: Minimal, only essential environment.zprofile
: Rarely used in modern setups.zshrc
: Main configuration file.zlogin
: Avoid, use .zprofile
if needed.zshrc
:
for config in ~/.config/zsh/*.zsh; do
source "$config"
done
# Bad: Visible in process list
export API_KEY="secret"
# Better: Read from file
export API_KEY=$(cat ~/.secrets/api_key)
# Check ownership before sourcing
if [[ -O "$config_file" ]]; then
source "$config_file"
fi
# Prepend, don't replace
export PATH="$HOME/bin:$PATH"
# Not: export PATH="$HOME/bin"
Organize configs outside HOME:
# In ~/.zshenv
export ZDOTDIR="$HOME/.config/zsh"
# Now Zsh looks for:
# $ZDOTDIR/.zshrc instead of ~/.zshrc
For compatibility:
# Emulate sh for POSIX scripts
emulate sh -c 'source script.sh'
# Emulate bash for bash scripts
emulate bash -c 'source script.bash'
For multi-user systems:
/etc/zshenv # Global environment
/etc/zprofile # Global login setup
/etc/zshrc # Global interactive
/etc/zlogin # Global login finish
/etc/zlogout # Global logout
Understanding shell startup sequences enables:
The key is putting the right configuration in the right file:
.zshenv
.zshrc
.zprofile
or .zlogin