Oh My Zsh's $ZSH_CUSTOM: The Update-Safe Extension Point Most Users Ignore
Editing files inside Oh My Zsh's core directory breaks on every update. Here is how $ZSH_CUSTOM gives you a supported place for custom plugins, themes, and startup files, plus the startup cost you pay for it.
05 Sept 2026, 13:36 UTC

You added a couple of aliases to a plugin file inside ~/.oh-my-zsh/plugins/, ran omz update, and watched your changes disappear into a git conflict. The useful takeaway is simple: keep your own code in $ZSH_CUSTOM, the directory Oh My Zsh treats as the supported, update-safe extension point.
ZSH_CUSTOM defaults to $ZSH/custom. The core $ZSH tree is git-managed framework code, so omz update\u2014a git pull\u2014can overwrite or conflict with local edits there. $ZSH_CUSTOM is where the framework expects user plugins, themes, and loose .zsh files to live.
Why the core tree is the wrong place
Oh My Zsh ships as a git repository. When you run omz update, it pulls upstream changes. Any file you hand-edited under $ZSH/plugins/ or $ZSH/themes/ is a tracked file. Git will either refuse to pull, produce a conflict, or silently replace your version depending on the change. The supported pattern is to leave the core tree alone and extend behavior from $ZSH_CUSTOM.
What Oh My Zsh sources, and in what order
The main script, oh-my-zsh.sh, follows a predictable sequence. It loads library helpers, sets up the completion fpath (the array of directories Zsh searches for completion functions), loads plugins from both $ZSH/plugins and $ZSH_CUSTOM/plugins, applies the theme, and then sources every regular file matching $ZSH_CUSTOM/**/*.zsh recursively, sorted by path.
Alphabetical path ordering is your friend
Because those custom files are sorted by path, file names control execution order. A file named 10-base.zsh runs before 20-overrides.zsh. This is the supported way to sequence dependent custom logic without wrapping everything in one giant file.
A worked example: one plugin and one override
Suppose you want a small custom plugin called <name> and an alias that overrides a plugin default. Run these in your interactive shell; no elevated permissions are required.
# Confirm the extension point
echo $ZSH_CUSTOM
# Create the plugin skeleton
mkdir -p "$ZSH_CUSTOM/plugins/<name>"
touch "$ZSH_CUSTOM/plugins/<name>/<name>.plugin.zsh"
Add <name> to your plugin array in .zshrc, before the source $ZSH/oh-my-zsh.sh line:
plugins=(git <name>)
If you want to relocate the directory entirely, set ZSH_CUSTOM before sourcing the framework. Setting it afterward has no effect on the current shell.
export ZSH_CUSTOM="$HOME/dotfiles/zsh-custom"
source $ZSH/oh-my-zsh.sh
Now create a late-loading custom file to override an alias. Because custom files load after plugins and the theme, this definition wins.
# File: $ZSH_CUSTOM/99-overrides.zsh
alias gst='git status -sb'
print custom-loaded >&2
To confirm the override actually took effect, open a fresh interactive shell and ask Zsh how the name is defined:
exec zsh -i
# or, without replacing your session:
zsh -i -c "whence -w gst"
whence -w reports whether gst is an alias, a function, or resolved to an external command. If you expected an alias but see a function or command path, something earlier in the load order is shadowing you.
To undo the example, remove the plugin directory and the custom file, then remove <name> from the plugins=(...) array in .zshrc. The framework will simply stop loading them.
The trade-off: every interactive shell pays
Custom .zsh files run in every new interactive shell\u2014new tabs, new windows, and subshells. Expensive logic there is paid repeatedly. Prefer deferring heavy work until first use, for example inside a function or with autoload, rather than running it at source time.
| Location | Update-safe? | Loaded when | Typical use |
|---|---|---|---|
$ZSH/plugins/... | No | Plugin phase | Framework code |
$ZSH_CUSTOM/plugins/... | Yes | Plugin phase | Your reusable plugins |
$ZSH_CUSTOM/**/*.zsh | Yes | After theme | Overrides and helpers |
Limitations to check before you rely on it
- The recursive sourcing picks up regular files only. Symlinked files or unusual layouts may not load as expected.
- Exact glob qualifiers and load order are version-sensitive. Verify against the
oh-my-zsh.shinstalled on your machine rather than a remembered snippet. - Changing
ZSH_CUSTOMafteroh-my-zsh.shis sourced does not affect that shell.
Verify, then trust it
Before assuming your setup works, list exactly what the framework will source:
print -l $ZSH_CUSTOM/**/*.zsh
If you need to prove ordering, trace a throwaway shell and grep for your file next to plugin and theme lines:
zsh -x -i -c exit 2>trace
grep -n '99-overrides\\|<name>' trace
After the next omz update, reopen a shell and re-run whence -w <name> to prove your custom change persisted while core files moved.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.