Skip to content

Shell Initialisation & Loading Order

This page explains which shell config files run when, and why. Understanding this is essential for knowing where to put environment variables, aliases, and tool initialisations — and for debugging "command not found" issues.

For how this repo uses these concepts, see the Repository Mapping and Shell Modules pages.

Shell Types

Shells have two independent properties — login vs non-login and interactive vs non-interactive — that combine:

Combination When Example
Login + Interactive macOS terminal windows, SSH ssh user@host, opening Terminal.app
Non-login + Interactive Linux terminal windows Opening GNOME Terminal (login already happened at GDM)
Non-login + Non-interactive Script execution ./script.sh, bash -c "..."

The key distinction: macOS terminals default to login + interactive (every new window), while Linux terminals default to non-login + interactive (login happened at GDM/SDDM). This affects which config files are read.


Zsh Loading Order

This repo uses zsh as the primary shell. The loading order determines where to put things:

File Scope What goes here
.zshenv Always (every invocation, including scripts) $PATH, Homebrew, environment variables. Use a load guard to avoid redundant work in subshells.
.zprofile Login only Re-runs brew shellenv to survive path_helper reordering (see macOS). Other login-shell setup lives here.
.zshrc Interactive only Aliases, prompts, completion, keybindings. This repo uses a modular .zshrc.d/ pattern instead of editing .zshrc directly.
.zlogin Login only Final setup after initialisation. Not used by this repo.

Each user file has a system-wide counterpart (/etc/zshenv, /etc/zprofile, /etc/zshrc, /etc/zlogin) that runs before the user file.

Loading sequences:

  • Login Shell (macOS default):

    • /etc/zshenv~/.zshenv/etc/zprofile~/.zprofile/etc/zshrc~/.zshrc/etc/zlogin~/.zlogin
  • Interactive Non-Login Shell (Linux terminal default):

    • /etc/zshenv~/.zshenv/etc/zshrc~/.zshrc
  • Script (Non-Interactive):

    • /etc/zshenv~/.zshenv

Load Guard Pattern

Because ~/.zshenv runs for every zsh invocation (including subshells and scripts), it should use a guard variable to avoid redundant work:

# Prevent double loading in subshells that inherit the environment
if [[ -n "$__ZSHENV_LOADED" ]]; then
  return
fi
export __ZSHENV_LOADED=1

The return statement is safe here because ~/.zshenv is always sourced (never executed directly), so return exits the sourced file without terminating the shell.


Bash Loading Order

Included for reference — this repo uses zsh, but understanding bash loading is useful for debugging scripts and login shells.

File Scope Purpose
.bash_profile Login only Bash reads only the first file it finds:
~/.bash_profile, ~/.bash_login, then ~/.profile.
.bashrc Interactive only Aliases, prompts, shell options. Not read by login shells
unless explicitly sourced from .bash_profile.
.profile Login only Generic POSIX login config. Only read if .bash_profile and
.bash_login don't exist.

Loading sequences:

  • Login Shell (macOS default):

    • /etc/profile → first of ~/.bash_profile / ~/.bash_login / ~/.profile
  • Interactive Non-Login Shell (Linux terminal default):

    • /etc/bash.bashrc~/.bashrc
  • Script (Non-Interactive): $BASH_ENV (if set, sources the file it points to; otherwise nothing)

Note: Bash login shells do not read ~/.bashrc by default. Most users source ~/.bashrc inside their ~/.bash_profile to ensure consistency.


macOS vs. Linux

macOS

  • macOS terminals (Terminal.app, iTerm2, Ghostty) launch every new window as a login shell.
  • This means ~/.zprofile and ~/.zshrc are both read for every window.
  • path_helper: macOS runs /usr/libexec/path_helper from /etc/zprofile (since Catalina, when zsh became the default shell). It reads /etc/paths and /etc/paths.d/*, builds a PATH with system paths first, then appends any existing PATH entries. The effect: Homebrew bins that ~/.zshenv placed at the front of $PATH get pushed behind /usr/bin, /bin, etc.
  • How the dotfiles handle this: ~/.zshenv sets up Homebrew via brew shellenv (essential for non-login shells and scripts, which never see /etc/zprofile). For login shells, ~/.zprofile re-runs brew shellenv — it fires after /etc/zprofile, so its prepend wins over path_helper's reorder. The duplication is intentional: each copy serves a different shell type. See the comment block at the top of home/dot_zprofile.tmpl for the canonical explanation alongside the implementation.

Linux

  • Desktop environments (GNOME, KDE) treat the session login as the login shell. Terminal windows start non-login + interactive shells.
  • This means ~/.zprofile is skipped for terminal sessions, and many display managers source ~/.profile but not ~/.zprofile. This is why environment variables belong in ~/.zshenv.

Repository Mapping

How the shell concepts above map to actual files in this repo:

Concept Repo File Notes
~/.zshenv dot_zshenv.tmpl $PATH, Homebrew init (for non-login shells / scripts),
environment variables, load guard
~/.zprofile dot_zprofile.tmpl Re-runs Homebrew init to survive path_helper's reorder
on login shells. See macOS.
~/.zshrc modules dot_zshrc.d/*.zsh Modular configs sourced via loop
Bootstrap .chezmoiscripts/run_once_before_01-setup-zshrc-sourcing.sh.tmpl Appends .zshrc.d sourcing block
to ~/.zshrc
External plugins .chezmoiexternals/externals.toml.tmpl Oh My Zsh, zsh plugins, Nerd Fonts