Skip to main content

Stop Turning .zshrc Into a Junk Drawer

· 6 min read
Pere Pages
Software Engineer
A cluttered drawer full of tangled cables and odds and ends

Your .zshrc started as ten tidy lines and has quietly become a 400-line dumping ground of half-remembered aliases, dead exports, and copy-pasted snippets you're afraid to delete. Here's how to clean it up and keep it that way.

The junk drawer

If you use zsh, sooner or later your ~/.zshrc becomes a mess.

At first it starts innocently:

alias ll="ls -la"

Then you add Git aliases. Then Homebrew setup. Then some Node stuff. Then a few shell functions. Then a random PATH fix you copied from Stack Overflow. Then something for Python, something for Docker, something for completions, something you no longer understand but are afraid to delete.

Six months later, opening .zshrc feels like opening a drawer full of cables.

Know which file loads when

Before splitting anything, it helps to know that zsh doesn't read one config file — it reads a few, each at a different moment. Think of them as doors your shell passes through, from outermost to innermost:

FileLoaded when…What belongs here
~/.zshenvAlways — every zsh, even scriptsAlmost nothing — keep it empty
~/.zprofileYou start a login shell (macOS Terminal)Environment: PATH, brew, EDITOR
~/.zshrcYou open an interactive terminalComfort: aliases, prompt, plugins

For most people that shakes out to:

  • ~/.zshenv → empty
  • ~/.zprofile → Homebrew + PATH + EDITOR
  • ~/.zshrc → aliases + prompt + plugins

Keep ~/.zshenv almost empty — most "why is my PATH weird?" problems come from cramming things in there.

Split your .zshrc into modules

.zshrc is the door that grows fastest, so it's the one worth splitting. A cleaner approach is to break your shell config into smaller files.

The idea is simple:

~/.zprofile
~/.zshrc
~/.config/zsh/
aliases.zsh
functions.zsh
exports.zsh
path.zsh
options.zsh

Why ~/.config/zsh/? That's the XDG Base Directory convention — the standard ~/.config folder where modern tools keep their settings, so your home directory doesn't collect a dozen loose dotfiles. One caveat: zsh still reads its startup files (.zshenv, .zprofile, .zshrc) from your home directory — that path is fixed unless you set ZDOTDIR. The module files, though, are yours: nothing loads them automatically, so you decide where they live and source them by path. ~/.config/zsh/ is just a tidy home for them.

~/.zprofile is for login-shell environment setup. For example, Homebrew on macOS usually belongs there:

eval "$(/opt/homebrew/bin/brew shellenv)"

That is not really an alias. It is not a function. It is not a prompt customization. It is environment setup, so .zprofile is the right place.

Then ~/.zshrc becomes the entrypoint for your interactive shell. Instead of containing everything directly, it just loads the smaller files:

ZSH_CONFIG="$HOME/.config/zsh"

source "$ZSH_CONFIG/options.zsh"
source "$ZSH_CONFIG/path.zsh"
source "$ZSH_CONFIG/exports.zsh"
source "$ZSH_CONFIG/aliases.zsh"
source "$ZSH_CONFIG/functions.zsh"

Or, slightly safer:

ZSH_CONFIG="$HOME/.config/zsh"

for file in options path exports aliases functions; do
[ -f "$ZSH_CONFIG/$file.zsh" ] && source "$ZSH_CONFIG/$file.zsh"
done

Visually, the two entrypoints load different things:

Now each file has a clear job.

aliases.zsh is for simple shortcuts:

alias ll="ls -la"
alias gs="git status"
alias gp="git pull"
alias c="clear"

Aliases should stay dumb. If you need arguments, conditions, or multiple commands, use a function instead.

functions.zsh is for small bits of reusable logic:

mkcd() {
mkdir -p "$1" && cd "$1"
}

serve() {
python3 -m http.server "${1:-8000}"
}

exports.zsh is for environment variables:

export EDITOR="nvim"
export VISUAL="nvim"
export LANG="en_US.UTF-8"

path.zsh is for manual PATH changes:

export PATH="$HOME/.local/bin:$PATH"
export PATH="$HOME/bin:$PATH"

Strictly speaking, PATH and environment variables are login-shell concerns, so purists put them straight in .zprofile (see the table above). Sourcing exports.zsh and path.zsh from .zshrc keeps every module in one place — the tradeoff is they re-run on each interactive shell. Either works; just don't define the same thing in both.

And options.zsh is for zsh behavior:

setopt AUTO_CD
setopt HIST_IGNORE_DUPS
setopt SHARE_HISTORY

HISTFILE="$HOME/.zsh_history"
HISTSIZE=10000
SAVEHIST=10000

Why it helps

The benefit is not that this is “more advanced”. It is the opposite: it makes the setup boring and understandable.

When I want to add a shortcut, I know where it goes.

When I want to change shell behavior, I know where it goes.

When something breaks, I do not have to scan a giant .zshrc full of unrelated things.

The mental model is:

File / moduleResponsibility
.zshenvAlways loaded — keep empty
.zprofileLogin environment
.zshrcInteractive shell entrypoint
aliases.zshSimple shortcuts
functions.zshSmall scripts
exports.zshEnvironment variables
path.zshPATH additions
options.zshzsh behavior

Start small

You do not need to over-engineer this. Start with just:

aliases.zsh
functions.zsh
exports.zsh

That is already enough to stop .zshrc from becoming a dumping ground.

The goal is not to create the perfect shell framework. The goal is to make your terminal config easy to read, easy to change, and easy to delete without fear.