Zsh itself is a speedy shell, but it’s all too easy to blindly add stuff to its startup scripts and prompt that drastically slow it down. I’ve been using Zsh since around 2002 (narrator: that’s over 20 years ago, which is making me feel really old!), and my Zsh config has accumulated a lot of cruft. A few years back, there was a very noticeable delay when opening a new terminal tab where I’d stare at a blank screen for a bit. And worse, typing commands felt very sluggish even when the commands executed quickly.

Once I started digging into it, I found some great optimizations to make it fast, without losing any functionality. In fact, by the time I was done, I had a much better prompt than I previously had, yet it was orders of magnitude faster.

If you don’t want to read the whole post, the single best thing you can do is to use Powerlevel10k. And the next best thing is to avoid using eval $(some other command), if possible. But read on for the details.

Measuring Performance

Before going into the changes I made, I need to talk about taking quantitative measurements. As with all optimizations, taking measurements is the first step. How do you measure the speed, and how do you know if you’ve improved anything? Fortunately, this is very easy with zsh-bench. Once you clone the git repository, you run the zsh-bench command, wait a bit, and it’ll print out some numbers. Here’s what mine first looked like:


There’s a lot here, but there are two sets of important numbers. The first set is the shell startup time:


first_prompt_lag_ms is the time until you see a prompt. Basically, how long are you staring at a blank screen. This is almost a half a second! first_command_lag_ms is how long before you can actually type a command. In this case, they are pretty much the same.

The second set of important numbers is:


command_lag_ms is probably the most important number, which represents the time between one command completing and getting a prompt to start typing another command. You see this delay for every command you type, but also in the simplest case, when you just hit Return and wait until you get another prompt. 328ms here is very noticeable. input_lag_ms is the time in between each keystroke.

Both first_prompt_lag_ms and command_lag_ms were noticeably slow for me. Hundreds of milliseconds is easily perceptible. The zsh-bench README has numbers for “indistinguishable from zero”. For first_prompt_lag_ms, it is 50ms, and for command_lag_ms, it is 10ms. Both of my numbers were an order of magnitude above this at 446ms and 328ms, respectively

After making some changes, I was able to drastically improve these numbers:



These are both very close to the “indistinguishable from zero” goals. And these numbers were on an Intel iMac Pro. On my shiny new M3 Max MacBook Pro, they clock in about twice as fast:



Both numbers are now well under the “indistinguishable from zero” goals. Let’s dig into how I made this improvement.


With a way to measure “what is slow?” and to measure if I was making things better, I needed a way to find out what part of my Zsh config was slow. It’s grown to hundreds of lines spread over many files, so it’s not possible to just intuit what is slow. Here are a number of resources I used:

I found Kevin Burke’s page most helpful, and it boils down to adding a bit of code at the start of your ~/.zshenv, as that is the first file in your home directory to run, for interactive shells:

# Profiling via:
# https://kev.inburke.com/kevin/profiling-zsh-startup-time/
: "${PROFILE_STARTUP:=false}"
: "${PROFILE_ALL:=false}"
# Run this to get a profile trace and exit: time zsh -i -c echo
# Or: time PROFILE_STARTUP=true /bin/zsh -i --login -c echo
if [[ "$PROFILE_STARTUP" == true || "$PROFILE_ALL" == true ]]; then
    # http://zsh.sourceforge.net/Doc/Release/Prompt-Expansion.html
    PS4=$'%D{%H:%M:%S.%.} %N:%i> '
    #zmodload zsh/datetime
    #PS4='+$EPOCHREALTIME %N:%i> '
    exec 3>&2 2>/tmp/zsh_profile.$$
    setopt xtrace prompt_subst
# "unsetopt xtrace" is at the end of ~/.zshrc

This produces a file in named /tmp/zsh_profile.<pid> which contains every command run, along with a timestamp, down to the millisecond. From this, you can infer which commands are slow. Here’s an example:

00:24:09.378 redacted:7> computer_name=
00:24:09.379 redacted:7> /usr/sbin/scutil --get ComputerName
00:24:09.378 redacted:7> computer_name=guts
00:24:09.385 redacted:29> redacted

It’s a bit convoluted, but you can see this scutil command takes 7ms to run: 09.385 - 09.378. While this is not a lot, this is basically death by a thousand cuts, and you have to identify all these cases where you bleed out ~7ms or more.

Making it Faster

Command lag was my priority, and the biggest slowdown, by far, was my Git status prompt. I was using the git-prompt.sh file found in the Git repository’s contrib/ directory. This runs multiple git commands and it turns out this gets really slow for large repositories.

I did a lot of searching for how to make my Git prompt faster, and eventually I found gitstatus. This project is truly amazing. The author, Roman, is obsessed with performance. He also wrote zsh-bench. gitstatus uses a combination of tricks like async prompt updates and avoiding executing a separate executable by using a deamon to drastically speed up git status.

The easiest way to use gitstatus is to use Powerlevel10k, also written by Roman. I liked how my prompt was setup, and was a little reluctant to switch, but I’m glad I did. I won’t go into the details on my Powerlevel10k setup, but suffice to say, the speedup was impressive, and the benefits of Powerlevel10k don’t stop with Git status. Performance is the headlining feature of Powerlevel10k, and it shows.

It also turns out executing commands, any command, adds noticeable delay. There’s fixed overhead in spawning a process, so you want to avoid this, where possible.

I’m a big fan of direnv, but the way it works requires running a command as a pre-command hook, so it directly affects command lag. direnv adds about 5ms of command lag on my M3 Max MacBook Pro, but for me, it’s worth it. The total command lag of 8ms is still imperceptible. I wish there was a faster alternative which used the same tricks as gitstatus, but I don’t know of one.

While I am able to live with the direnv command lag, executing commands is more of an issue for shell startup time. For example, Homebrew says you should add this to your shell startup:

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

All this does is set some environment variables:

> brew shellenv
export HOMEBREW_PREFIX="/opt/homebrew";
export HOMEBREW_CELLAR="/opt/homebrew/Cellar";
export HOMEBREW_REPOSITORY="/opt/homebrew";
export PATH="/opt/homebrew/bin:/opt/homebrew/sbin${PATH+:$PATH}";
export MANPATH="/opt/homebrew/share/man${MANPATH+:$MANPATH}:";
export INFOPATH="/opt/homebrew/share/info:${INFOPATH:-}";

However, this executes the command brew, just to set these environment variables. brew is written in Ruby, so this also has to startup the full Ruby runtime environment. The values of the environment variables almost never change. It’s much faster to just paste the output of brew shellenv directly into your .zshrc.

Similarly, rbenv recommends you eval the output of rbenv init - zsh:

echo 'eval "$(~/.rbenv/bin/rbenv init - zsh)"' >> ~/.zshrc

Again, this has to run an executable, and again, it is much faster to just paste the output into your .zshrc.

Another way to speed up startup is to run some stuff asynchronously and defer it out of the startup “hot path”. zsh-defer (also written by Roman) is one such way to do this. But Powerlevel10k has an Instant Prompt feature which builds upon this. By avoiding running commands and using Instant Prompt, my startup time is now quite fast.


Hopefully this gives you some tips to speed up your Zsh config. Bringing startup time and especially command lag down from hundreds of milliseconds will make your shell feel much faster. Also, use Powerlevel10k. Between its Git prompt, Instant Prompt, and deferred startup features, it’s incredible.