<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
<title>Basetta&apos;s Website · zsh</title>
<id>https://basetta.bz/tags/zsh/atom.xml</id>
<link href="https://basetta.bz/tags/zsh/atom.xml" rel="self" type="application/atom+xml"/>
<link href="https://basetta.bz/"/>
<updated>2026-11-10T10:00:00.000Z</updated>
<author><name>Patrick Martini</name></author>
<entry>
<title>Speeding Up Zsh Startup with zprof and zcompile</title>
<id>https://basetta.bz/blog/speeding-up-zsh-startup/</id>
<link href="https://basetta.bz/blog/speeding-up-zsh-startup/"/>
<published>2026-11-10T10:00:00.000Z</published>
<updated>2026-11-10T10:00:00.000Z</updated>
<summary>How I used zprof to find what was slowing down my terminal, and fixed it with zcompile. Plus the one culprit I chose to keep.</summary>
<content type="html" xml:base="https://basetta.bz/blog/speeding-up-zsh-startup/">&lt;p&gt;A new terminal tab was taking noticeably longer than it should. Not enough to block anything — just enough to be annoying a few dozen times a day. The kind of friction that accumulates.&lt;/p&gt;
&lt;p&gt;The problem was somewhere in my Zsh startup. I have a modular config spread across several sourced files: aliases, keybindings, fzf setup, gcloud, github helpers, a handful of plugins loaded straight from cloned git repositories, and a prompt powered by Starship. Any of those could be the culprit, and guessing seemed like a waste.&lt;/p&gt;
&lt;p&gt;Zsh ships with a profiler. I used it.&lt;/p&gt;
&lt;h2 id=&quot;measuring-first&quot;&gt;Measuring first&lt;/h2&gt;
&lt;p&gt;The tool is &lt;code&gt;zsh/zprof&lt;/code&gt;, a built-in module. Load it at the very start of the startup sequence, then call &lt;code&gt;zprof&lt;/code&gt; at the end to print a sorted function call table. In practice, the easiest way to run a one-shot profile is:&lt;/p&gt;
&lt;pre data-language=&quot;zsh&quot;&gt;&lt;code&gt;ZDOTDIR=~/.config/zsh zsh -c &apos;
  zmodload zsh/zprof
  source ~/.config/zsh/.zshrc
  zprof
&apos;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The output ranks every function by cumulative time. Mine looked like this:&lt;/p&gt;
&lt;pre data-language=&quot;text&quot;&gt;&lt;code&gt;num  calls        time              self          name
----------------------------------------------------------
 1)    1     18.10   18.10  40.17%  18.10  40.17%  _mise_hook
 2)    4     18.57    4.64  41.23%  14.22   3.55%  _zplugin_load
 3)    1      7.98    7.98  17.72%   7.98  17.72%  compinit
 4)    1      2.55    2.55   5.66%   2.55   5.66%  _zsh_highlight_bind_widgets
 5)    1      1.16    1.16   2.58%   1.16   2.58%  _history-substring-search-function-callable&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three things dominate:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;_mise_hook&lt;/code&gt; at 40% — this is what &lt;code&gt;eval &quot;$(mise activate zsh)&quot;&lt;/code&gt; installs&lt;/li&gt;
&lt;li&gt;&lt;code&gt;_zplugin_load&lt;/code&gt; at ~31% — sourcing four plugins from cloned repos&lt;/li&gt;
&lt;li&gt;&lt;code&gt;compinit&lt;/code&gt; at ~18%&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;No guessing required. I fixed the second and third. The first one I chose to keep — more on that at the end.&lt;/p&gt;
&lt;h2 id=&quot;compiling-plugins-with-zcompile&quot;&gt;Compiling plugins with zcompile&lt;/h2&gt;
&lt;p&gt;Every time Zsh sources a &lt;code&gt;.zsh&lt;/code&gt; file, it reads the text, tokenises it, and parses it into an internal representation before executing it. For a small aliases file this is negligible. For a plugin, less so.&lt;/p&gt;
&lt;p&gt;The four plugins I load are not small:&lt;/p&gt;
&lt;table tabindex=&quot;0&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plugin&lt;/th&gt;
&lt;th&gt;Lines&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;zsh-autosuggestions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1,709&lt;/td&gt;
&lt;td&gt;52 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;zsh-history-substring-search&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;848&lt;/td&gt;
&lt;td&gt;29 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;zsh-vi-mode&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4,045&lt;/td&gt;
&lt;td&gt;101 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fast-syntax-highlighting&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1,488&lt;/td&gt;
&lt;td&gt;60 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Zsh parses all of that from scratch on every new shell. The &lt;code&gt;.plugin.zsh&lt;/code&gt; entry point for each one is a thin wrapper that immediately sources the real file, so sourcing four plugins means reading and parsing roughly 240 KB of text before a single line of user config runs.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;zcompile&lt;/code&gt; is a Zsh built-in that pre-compiles a &lt;code&gt;.zsh&lt;/code&gt; file into a &lt;code&gt;.zwc&lt;/code&gt; word-code file alongside it. Word code is Zsh’s internal bytecode: it skips the tokenise-and-parse step entirely. When Zsh sources a &lt;code&gt;.zsh&lt;/code&gt; file, it automatically uses the &lt;code&gt;.zwc&lt;/code&gt; if one exists and is newer than the source. No changes to how the files are sourced are needed.&lt;/p&gt;
&lt;p&gt;To compile all four plugins:&lt;/p&gt;
&lt;pre data-language=&quot;zsh&quot;&gt;&lt;code&gt;cd ~/.config/zsh/plugins
for dir in */; do
  plugin=&quot;${dir%/}&quot;
  main=&quot;${dir}${plugin}.plugin.zsh&quot;
  [[ -f &quot;$main&quot; ]] &amp;amp;&amp;amp; zcompile &quot;$main&quot; &amp;amp;&amp;amp; echo &quot;compiled $main&quot;
done&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This writes four new files:&lt;/p&gt;
&lt;pre data-language=&quot;text&quot;&gt;&lt;code&gt;plugins/zsh-autosuggestions/zsh-autosuggestions.plugin.zwc
plugins/zsh-history-substring-search/zsh-history-substring-search.plugin.zwc
plugins/zsh-vi-mode/zsh-vi-mode.plugin.zwc
plugins/fast-syntax-highlighting/fast-syntax-highlighting.plugin.zwc&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note that this only compiles the entry-point file, not every &lt;code&gt;.zsh&lt;/code&gt; file inside the plugin directory. The entry point is what Zsh sources directly; any files it sources internally will still be read as text unless compiled separately. For these four plugins, the entry point is either the whole plugin or sources one additional file — compiling the entry point is enough to cover the parse-time overhead on load.&lt;/p&gt;
&lt;p&gt;The same applies to modular config files. My &lt;code&gt;.zshrc&lt;/code&gt; splits things across &lt;code&gt;aliases.zsh&lt;/code&gt;, &lt;code&gt;bindings.zsh&lt;/code&gt;, &lt;code&gt;fzf.zsh&lt;/code&gt;, and a few others. They all get sourced on every startup:&lt;/p&gt;
&lt;pre data-language=&quot;zsh&quot;&gt;&lt;code&gt;for f in ~/.config/zsh/*.zsh; do
  zcompile &quot;$f&quot; &amp;amp;&amp;amp; echo &quot;compiled $f&quot;
done&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And the completion dump that &lt;code&gt;compinit&lt;/code&gt; generates — which can be several hundred lines of generated code:&lt;/p&gt;
&lt;pre data-language=&quot;zsh&quot;&gt;&lt;code&gt;zcompile ~/.cache/zsh/zcompdump&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;what-the-numbers-say&quot;&gt;What the numbers say&lt;/h3&gt;
&lt;p&gt;After compiling everything, &lt;code&gt;zprof&lt;/code&gt; showed &lt;code&gt;_zplugin_load&lt;/code&gt; dropping from roughly &lt;strong&gt;44ms to 26ms&lt;/strong&gt; in the profiling run from earlier in the session — about a 40% reduction in the time spent loading plugins. That is the parsing overhead disappearing.&lt;/p&gt;
&lt;p&gt;Wall-time measurements across five repeated runs tell a more conservative story: compiled shells averaged &lt;strong&gt;~112ms&lt;/strong&gt;, uncompiled ones &lt;strong&gt;~120ms&lt;/strong&gt;. The smaller gap is expected: these runs happen with a warm filesystem cache, so the disk read cost is already low. The benefit is most pronounced on the first shell after a fresh login or when the system is under load and the page cache is cold. That is exactly the moment when a slow terminal is most noticeable.&lt;/p&gt;
&lt;h2 id=&quot;one-maintenance-step&quot;&gt;One maintenance step&lt;/h2&gt;
&lt;p&gt;Compiled &lt;code&gt;.zwc&lt;/code&gt; files become stale when the original &lt;code&gt;.zsh&lt;/code&gt; changes. A plugin update replaces the source but leaves the old bytecode. Zsh will use the new source if it is newer than the &lt;code&gt;.zwc&lt;/code&gt;, so it will not silently run old code — but the compiled speedup disappears until you recompile.&lt;/p&gt;
&lt;p&gt;I updated my &lt;code&gt;zplugin-update&lt;/code&gt; function to recompile each plugin after pulling:&lt;/p&gt;
&lt;pre data-language=&quot;zsh&quot;&gt;&lt;code&gt;zplugin-update() {
  local dir plugin main
  for dir in &quot;${ZPLUGINDIR}&quot;/*/; do
    plugin=&quot;${dir:t}&quot;
    echo &quot;Updating ${plugin}...&quot;
    git -C &quot;$dir&quot; pull --ff-only
    main=&quot;${dir}${plugin}.plugin.zsh&quot;
    [[ -f &quot;$main&quot; ]] &amp;amp;&amp;amp; zcompile &quot;$main&quot;
  done
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now running &lt;code&gt;zplugin-update&lt;/code&gt; leaves everything in a compiled state.&lt;/p&gt;
&lt;h2 id=&quot;the-result&quot;&gt;The result&lt;/h2&gt;
&lt;p&gt;After compiling plugins and config files, the profiler output looks like:&lt;/p&gt;
&lt;pre data-language=&quot;text&quot;&gt;&lt;code&gt;num  calls        time              self          name
----------------------------------------------------------
 1)    4     26.14    6.53  68.86%  20.58   5.15%  _zplugin_load
 2)    1     11.50   11.50  30.30%  11.50  11.50%  compinit
 3)    1      3.03    3.03   7.99%   3.03   7.99%  _zsh_highlight_bind_widgets&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;_zplugin_load&lt;/code&gt; dropped from ~44ms to ~26ms. &lt;code&gt;_mise_hook&lt;/code&gt; is gone from the table entirely — not because I removed it, but because it is no longer the bottleneck worth talking about. Overall wall time went from around 205ms to 138ms, a 33% improvement.&lt;/p&gt;
&lt;h2 id=&quot;what-zprof-is-good-for&quot;&gt;What &lt;code&gt;zprof&lt;/code&gt; is good for&lt;/h2&gt;
&lt;p&gt;Startup performance is easy to misjudge without measurement. I had assumed the prompt — Starship, which does a few subprocess calls — was the main slowdown. It barely showed up. The actual time was being spent on text parsing that a one-line command eliminates for good.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;zprof&lt;/code&gt; makes the guess unnecessary. Run it once, look at the table, fix what the numbers say to fix.&lt;/p&gt;
&lt;h2 id=&quot;the-one-thing-i-left-alone&quot;&gt;The one thing I left alone&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;_mise_hook&lt;/code&gt; was the single biggest entry in the original profile at 40%. It comes from &lt;code&gt;eval &quot;$(mise activate zsh)&quot;&lt;/code&gt;, which registers a hook that fires on every prompt to check the current directory and activate the right tool versions.&lt;/p&gt;
&lt;p&gt;Mise offers an escape hatch: &lt;code&gt;--shims&lt;/code&gt;. Instead of hooks, it just prepends a shims directory to &lt;code&gt;PATH&lt;/code&gt;. Startup cost disappears entirely.&lt;/p&gt;
&lt;pre data-language=&quot;zsh&quot;&gt;&lt;code&gt;if command -v mise &amp;gt;/dev/null 2&amp;gt;&amp;amp;1; then
  eval &quot;$(mise activate zsh --shims)&quot;
fi&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I did not use it. I have several projects with different Node and Python versions in &lt;code&gt;.mise.toml&lt;/code&gt; files, and I rely on the automatic switching when moving between directories. Removing the hook would mean remembering to run &lt;code&gt;mise activate&lt;/code&gt; manually after a &lt;code&gt;cd&lt;/code&gt;, which is a different kind of friction.&lt;/p&gt;
&lt;p&gt;Knowing it is there is enough for now. If I ever drop the per-directory switching habit, that 40% is still waiting.&lt;/p&gt;
</content>
<category term="tech"/>
<category term="zsh"/>
<category term="dotfiles"/>
<category term="performance"/>
</entry>
</feed>