Configuring zsh: .zshrc, Aliases, Functions, Plugins, the Prompt and Startup Time
A good .zshrc is written in a fixed order: history, completion, aliases and functions, plugins, and the prompt last; slowness usually comes not from a framework but from external commands that run on every start. With real measurements we cover when to pick an alias, a function or a script, installing zsh-autosuggestions and zsh-syntax-highlighting by hand, a Starship prompt showing the git branch and the .NET version, and a completion cache that took startup from 206 ms to 21 ms.
Version note: The settings in this post were tested on Ubuntu 24.04 with zsh 5.9 and bash 5.2, in a clean environment with Starship 1.27.0, zsh-autosuggestions 0.7.1, zsh-syntax-highlighting (October 2026 commit), Oh My Zsh (November 2026 commit), .NET SDK 10.0.404 and Angular CLI 22.2.7 installed. Timings were taken in this environment; absolute values depend on your machine, the ratios are what matter.
Guess First
There are two .zshrc files. The first is Oh My Zsh's stock template with five plugins: git docker dotnet npm sudo. The second is a file I wrote by hand without a framework: history settings, compinit, two plugins, the Starship prompt, and the two lines from the previous post:
source <(dotnet completions script bash 2>/dev/null)
source <(SHELL=${commands[zsh]} ng completion script)
Which one starts slower, and by how many times? You have probably heard "Oh My Zsh is heavy" many times; the measurement is in the middle of this post, and it doesn't really back that claim.
The Layout of .zshrc, Section by Section
According to the file table from the first post, two files do the work in zsh: .zprofile is read once at login, .zshrc in every interactive shell. PATH and environment variables go in the first, everything else in the second. The .zprofile I use while working on Bookstore, the online bookstore used as the shared example across this site's series:
# Login shell: PATH and environment variables
typeset -U path
path=($HOME/.dotnet/tools $HOME/.local/bin $path)
export EDITOR=code
export DOTNET_CLI_TELEMETRY_OPTOUT=1
typeset -U path tells zsh to drop duplicate elements from the path array; even if the file is read twice, PATH doesn't grow. In zsh the path array and the PATH string are tied together: change one and the other changes too.
I split .zshrc into sections in a fixed order, because the order really matters: completion functions must be defined after compinit, the syntax highlighting plugin must load last, and the prompt is set up once everything else is ready. The skeleton:
# 1. Options
setopt AUTO_CD INTERACTIVE_COMMENTS
# 2. History (previous post)
# 3. Line editing: bindkey -e
# 4. Completion: compinit, zstyle, tool completions
# 5. Aliases and functions
# 6. Plugins (syntax highlighting last)
# 7. Prompt
AUTO_CD does a cd when you type a directory name on its own (type src and press Enter). INTERACTIVE_COMMENTS stops comments starting with # in a pasted command from causing errors; in interactive zsh this is off by default. The history and completion sections are the same settings as in the previous post. The whole file is in this post's repo snapshot as shell/zsh/.zshrc.
Alias, Function or Script?
Short names for the commands I type most:
alias dt='dotnet test'
alias db='dotnet build'
alias dw='dotnet watch --project src/Bookstore.Api'
alias gs='git status -sb'
alias ngs='ng serve'
dtf() {
dotnet test --filter "FullyQualifiedName~${1:?usage: dtf <TestClassOrMethod>}" "${@:2}"
}
mkcd() {
mkdir -p -- "$1" && cd -- "$1"
}
An alias is plain text substitution: if the first word of the line is dt, the shell replaces it with dotnet test and leaves the rest as is. If you need to put an argument in the middle, branch on a condition or run more than one command, write a function. dtf puts the first argument into the filter and passes the rest to dotnet test with "${@:2}"; ${1:?...} prints a message and stops if no argument is given. mkcd can't be done with an alias because it must change the directory of the shell it runs in. A separate script couldn't do it either: a script runs in a new process, and its cd doesn't affect your shell.
type tells you how the shell interprets a name:
❯ type dt dtf gs
dt is an alias for dotnet test
dtf is a shell function from /home/ada/.zshrc
gs is an alias for git status -sb
❯ dtf IsbnTests --no-build | tail -1
Passed! - Failed: 0, Passed: 4, Skipped: 0, Total: 4, Duration: 6 ms - Bookstore.Api.Tests.dll (net10.0)
❯ dtf
dtf:1: 1: usage: dtf <TestClassOrMethod>
Aliases and functions exist only in that interactive shell. Using the same dt inside a script:
❯ cat /tmp/run-tests.sh
#!/usr/bin/env zsh
dt --no-build
❯ /tmp/run-tests.sh
/tmp/run-tests.sh:2: command not found: dt
The script doesn't read .zshrc, so it doesn't know the alias. My rule: shortcuts that exist only for my fingers are aliases; short jobs that process arguments or change directory are functions; anything that another tool (CI, a git hook, a VS Code task) will also call, or that grows past 15-20 lines, is a separate script in a folder on PATH (~/.local/bin).
Plugin Approaches: Oh My Zsh, a Plugin Manager or by Hand
There are three ways to extend zsh. Oh My Zsh is a framework: its install script brings the ~/.oh-my-zsh folder and a ready .zshrc template, and you write names into the plugins=(...) list. The version I downloaded had 372 plugin folders. Plugin managers such as antidote, zinit or zim fetch plugins from GitHub, load them and manage their order and updates. The third way is by hand: download the plugin into a folder with git clone and source it from .zshrc.
| Approach | Upside | Downside |
|---|---|---|
| Oh My Zsh | Ready aliases and plugins, easy start | Hundreds of aliases you didn't write, the framework's own update cycle |
| Plugin manager | Version pinning, automatic updates | One more tool and its syntax |
| By hand | You know exactly what loads, no extra dependency | You do the updates (git pull) |
I go by hand, because there are two plugins I actually use. zsh-autosuggestions shows a suggestion it finds in history in a dim colour to the right of the cursor; the right arrow accepts it. It is the zsh counterpart of PowerShell's InlineView. zsh-syntax-highlighting colours the command as you type, before you press Enter: a command that doesn't exist shows red, a correctly typed one green. Installation:
$ mkdir -p ~/.local/share/zsh/plugins && cd ~/.local/share/zsh/plugins
$ git clone --depth 1 https://github.com/zsh-users/zsh-autosuggestions
$ git clone --depth 1 https://github.com/zsh-users/zsh-syntax-highlighting
and in .zshrc:
ZPLUGINS=${XDG_DATA_HOME:-$HOME/.local/share}/zsh/plugins
source $ZPLUGINS/zsh-autosuggestions/zsh-autosuggestions.zsh
# syntax highlighting must load last
source $ZPLUGINS/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh
The syntax highlighting README asks for this explicitly: it must be at the end of the file because it can't wrap widgets (functions bound to ZLE) defined after it. On macOS the same two plugins also install with brew install zsh-autosuggestions zsh-syntax-highlighting; then you give the path as $(brew --prefix)/share/.... The autosuggestion in real life: when I typed dtf I, the line looked like this, with IsbnTests --no-build | tail -1 as the dim suggestion:
❯ dtf IsbnTests --no-build | tail -1
The Prompt: Starship, Powerlevel10k and Plugin-Free vcs_info
I want three things in the prompt: which folder I'm in, which branch I'm on, and which .NET SDK or Node version this folder will use. Starship does this with a single binary and one TOML file, and the same config works in bash, zsh, fish and PowerShell. For someone using PowerShell on Windows and zsh in WSL, that means not dealing with two separate prompt themes. Installation: brew install starship on macOS, winget install --id Starship.Starship on Windows, the official install script on Linux; then eval "$(starship init zsh)" at the very end of .zshrc. My ~/.config/starship.toml:
add_newline = false
format = "$directory$git_branch$git_status$dotnet$nodejs$cmd_duration$line_break$character"
[directory]
truncation_length = 3
[git_branch]
format = "[$symbol$branch]($style) "
symbol = "git:"
[dotnet]
format = "[.NET $version]($style) "
version_format = "${raw}"
[nodejs]
format = "[node $version]($style) "
version_format = "${raw}"
[cmd_duration]
min_time = 2000
format = "[took $duration]($style) "
Starship's default symbols need a Nerd Font; because I turned the symbols into text, it also looks right with a normal font. The prompt at the repo root, in the Angular project and after a command that took 5 seconds (with colour codes stripped):
bookstore git:main .NET 10.0.100
❯
bookstore/web/bookstore-web git:main node 24.22.3
❯
bookstore git:main .NET 10.0.100 took 5s
❯
One detail to watch: Starship's dotnet module reads the version from the repo's global.json. global.json says 10.0.100 with rollForward: latestFeature; the SDK actually running is 10.0.404, as dotnet --version says. The version in the prompt is "what the repo asks for", dotnet --version is "what is used". How global.json works is covered in the ASP.NET Core Web API series post on the .NET SDK and global.json.
Powerlevel10k is a very popular and fast zsh theme, but its README says the project has very limited support and no new features are planned. If you're setting up from scratch today, Starship is the safer choice. If you want no extra tools at all, zsh's own vcs_info is enough:
autoload -Uz vcs_info
precmd() { vcs_info }
zstyle ':vcs_info:git:*' formats 'git:%b '
setopt PROMPT_SUBST
PROMPT='%3~ ${vcs_info_msg_0_}%# '
The prompt these five lines produced was ada/bookstore/web git:main %.
Measuring Startup Speed: time, zprof and Caching
If a new terminal tab takes half a second to open, you wait for it dozens of times a day. The simplest way to measure is to open the shell interactively and close it right away, a few times in a row:
$ time (for i in {1..10}; do zsh -i -c exit; done)
real 0m0.232s
user 0m0.171s
sys 0m0.073s
$ time (for i in {1..10}; do HOME=/tmp/m3u zsh -i -c exit; done)
real 0m1.994s
user 0m1.799s
sys 0m0.381s
I measured from bash; zsh's time gives the same information on one line. The home in the second command uses the same .zshrc as the first; the only difference is that it regenerates the completion scripts on every start. With the same method I opened five configurations ten times each. The answer to the guess:
| Configuration | Average startup |
|---|---|
Empty .zshrc | 7 ms |
Oh My Zsh, only the git plugin | 18 ms |
| Oh My Zsh, five plugins | 19 ms |
| Hand-written, completions generated on every start | 206 ms |
| Hand-written, completions from cache | 21 ms |
By this measurement Oh My Zsh itself isn't heavy. What brings the slowness is two lines that run an external program on every start: dotnet completions script spins up the .NET runtime and ng completion script spins up Node. In real life, nvm's init script, lines like eval "$(pyenv init -)" and calling brew --prefix on every start have the same effect.
zsh's own profiler is zprof. With zmodload zsh/zprof on the first line of .zshrc and zprof on the last, it prints how long each function took during startup. The top of the output for the slow configuration:
num calls time self name
-----------------------------------------------------------------------------------
1) 1 9.98 9.98 98.17% 7.16 7.16 70.46% compinit
2) 2 2.82 1.41 27.71% 2.82 1.41 27.71% compaudit
3) 2 0.08 0.04 0.78% 0.08 0.04 0.78% compdef
It shows about 10 ms in total, yet startup takes 200 ms. zprof measures only zsh functions; it never sees the external processes running inside source <(...). So look at the total with time first, and if zprof shows only a small part, look for the cause in external commands: comment out suspicious lines one at a time and measure again.
The fix is to generate the completion script once, write it to a file and refresh it only when the tool is updated:
# Regenerate a completion script only when the tool changes
cache_completion() {
local tool=$1 bin=${commands[$1]}
local file=${XDG_CACHE_HOME:-$HOME/.cache}/zsh/$tool-completion.zsh
[[ -n $bin ]] || return 0
if [[ ! -s $file || $bin -nt $file ]]; then
mkdir -p ${file:h}
"${@:2}" >| $file 2>/dev/null
fi
source $file
}
cache_completion dotnet dotnet completions script bash
cache_completion ng env SHELL=${commands[zsh]} ng completion script
$bin -nt $file checks whether the binary is newer than the cache file; when you install a new SDK or Angular CLI, the script is regenerated on the next start. This check looks at the date of the file on PATH; if completion acts strangely, just delete the ~/.cache/zsh folder and it is recreated on the first start. This change took startup from 206 ms down to 21 ms. If a single tool's init is expensive, another technique is lazy-loading: defining a small function that loads the tool the first time you call it. But in most cases the cache is enough and simpler.
Equivalents for bash Users and a Quick Look at fish
If you want to stay on bash in WSL, the same ideas apply in .bashrc. Alias and function syntax is the same. For the prompt you set up Starship with eval "$(starship init bash)"; without Starship, PS1 and a command substitution are enough for a simple branch display:
if command -v starship >/dev/null; then
eval "$(starship init bash)"
else
PS1='\W $(git branch --show-current 2>/dev/null)\$ '
fi
In bash, the code that runs before every prompt is PROMPT_COMMAND; the history -a from the previous post lived there. After installing Starship I checked: the value of PROMPT_COMMAND became starship_precmd, but Starship stores the old value as STARSHIP_PROMPT_COMMAND="history -a" and keeps running it on every prompt. Another tool with its own init script may not be this careful; if something breaks, check with declare -p PROMPT_COMMAND.
fish is worth knowing too: autosuggestions, syntax highlighting and good completion come without extra plugins. In return it isn't POSIX-compatible; you write set -x X 1 instead of export X=1, and bash lines you find online don't always work. Some teams use fish interactively and bash for scripts; in a .NET + Angular team it breaks the shared language, so I recommend staying on zsh.
In Production
.zshrc is a developer-machine file; it should not move to a server, a container image or a CI runner. If a build step needs a shortcut like dt or gs, it is no longer a personal shortcut but a team tool: it belongs in the repo as a script or a Makefile/task. Another reason for this split is reproducibility: the behaviour in CI and on your machine should come from the same script.
Installing plugins with git clone means running that repo's code in every new shell. Take them from well-known projects, glance at what changed before git pull, and delete plugins you never use. Adding Oh My Zsh plugins you don't use to the list is likewise running code for nothing.
Moving your settings to a new machine, versioning them and keeping them in sync across machines (dotfiles, chezmoi) is the subject of the Productivity and Automation series; the files here are that series' raw material.
Common Pitfalls
The first is replacing core commands with aliases. alias rm='rm -i' gives you a feeling of safety in an interactive shell, but scripts and other machines don't know the alias:
❯ alias rm='rm -i'
❯ cd /tmp && touch a.txt && rm a.txt
rm: remove regular empty file 'a.txt'? n
❯ bash -c 'rm a.txt; ls a.txt'
ls: cannot access 'a.txt': No such file or directory
The safety net you got used to deletes the file one day where it isn't there. If you want confirmation, use a separate name (alias rmi='rm -i'). To skip an alias once, type \rm or command rm.
The second is the same alias behaving differently on macOS and Linux. ls, sed and grep on macOS are the BSD versions, on Linux the GNU ones; options like ls --color=auto or sed -i are not the same on both sides. If you use an alias on both operating systems, split it with [[ $OSTYPE == darwin* ]].
The third is ordering mistakes: completion lines calling compdef before compinit, plugins loaded after syntax highlighting, a setting after the prompt that overwrites PROMPT. The fourth is putting something slow or something that prints into .zshenv; every zsh script reads it too.
A Small Experiment
★☆☆ Split your own .zshrc into the seven sections from the post. Add three aliases for Bookstore and the dtf function; check with type that all of them are recognised correctly.
★★☆ Measure your own startup time with time (for i in {1..10}; do zsh -i -c exit; done). Then turn on zprof and compare the total time with the time zprof shows. If the gap is large, find which external command causes it by commenting out lines.
★★★ Apply cache_completion to one more tool you use (for example gh completion -s zsh or kubectl completion zsh). Add a module to Starship that shows Bookstore's Docker context or the current Kubernetes context, and measure its effect on the prompt with starship timings.
The zsh side is ready. In the next post we move to the main shell of the Windows developer: PowerShell 7's object pipeline, handling the JSON from the Bookstore API as objects, $PROFILE, PSReadLine, posh-git and an equivalents table for people who know bash.