How to create a custom oh-my-zsh theme
An oh-my-zsh theme is one file of shell variables. This page explains every part of it —
where the file goes, how PROMPT is assembled, how colours and git status get
in, and which mistakes quietly break alignment — so you can write one by hand or
build it visually and understand what comes out.
On this page
Where oh-my-zsh themes live
A theme is a file named <name>.zsh-theme. oh-my-zsh ships its own in
~/.oh-my-zsh/themes/, but that directory is overwritten on every upgrade. Your
own themes belong in the custom directory, which is never touched:
# 1. save the theme
mkdir -p ~/.oh-my-zsh/custom/themes
$EDITOR ~/.oh-my-zsh/custom/themes/mytheme.zsh-theme
# 2. point zsh at it
sed -i 's/^ZSH_THEME=.*/ZSH_THEME="mytheme"/' ~/.zshrc
# 3. reload the shell
exec zsh
oh-my-zsh looks in $ZSH_CUSTOM/themes first and falls back to its own
directory, so a custom theme named after a bundled one wins. exec zsh replaces
the running shell, which is the only reliable way to re-read a theme — sourcing
~/.zshrc again leaves hooks registered twice.
PROMPT, RPROMPT and prompt_subst
zsh draws up to four prompts. A theme almost always sets two of them:
| Variable | What it is |
|---|---|
| PROMPT | The main prompt, at the left, where you type. PS1 is the same variable under its POSIX name. |
| RPROMPT | Drawn flush with the right edge of the terminal. zsh erases it when you accept a line, so it never ends up in copied text. |
| PROMPT2 | Continuation lines — an unclosed quote or a trailing \. |
| PROMPT4 | Trace output under set -x. |
Both are re-expanded before every prompt, so %~ follows you around the file
system without any work on your part. What is not re-expanded by default is command
substitution: $(git_prompt_info) inside a prompt string is only run if
prompt_subst is on. Every theme that shows git state starts with it:
setopt prompt_subst
PROMPT='%F{green}%n@%m%f %F{blue}%~%f $(git_prompt_info)%(!.#.$) '
RPROMPT='%F{240}%*%f'
Single quotes matter. With double quotes the command substitution runs once, when the theme is read, and your branch name freezes at whatever it was then.
Build one in the editor
Hand-writing the escapes is fine for a one-line prompt and miserable for anything else, which is what the visual oh-my-zsh theme editor is for. It renders the prompt the way zsh does — same escapes, same truncation, same colour lookup — and the file it gives you produces exactly what you saw.
Lay out the prompt
Drag segments — user, host, directory, git branch, exit status, clock — onto a prompt line. Add a second line for a two-line prompt, or drop segments into RPROMPT to pin them to the right edge.
Style each segment
Select one or more segments and set foreground, background, bold, underline, and separate colours for when you are root or connected over SSH.
Check it under real shell states
The scenario strip renders the same prompt with a dirty repository, a failed command, a deep path, no repository, a root shell and an active virtualenv, so an unreadable state shows up before you install the theme.
Copy the generated theme
The export panel holds a complete .zsh-theme file: prompt_subst, helper functions, ZSH_THEME_GIT_PROMPT_* variables and the PROMPT/RPROMPT assignments. Copy it or download it.
Install and activate it
Save the file as ~/.oh-my-zsh/custom/themes/<name>.zsh-theme, set ZSH_THEME="<name>" in ~/.zshrc, and run exec zsh. The prompt in your terminal matches the preview character for character.
Colours
Modern zsh has one colour escape, and it takes three kinds of value:
| Escape | Meaning |
|---|---|
| %F{red} … %f | One of the eight names, i.e. palette slots 0–7. What they look like is decided by your terminal's colour scheme, not by zsh. |
| %F{166} … %f | An xterm-256 index. 0–15 follow the terminal palette; 16–255 are the fixed cube and greyscale ramp, identical everywhere. |
| %F{#ff8700} … %f | A 24-bit hex colour, shown exactly as written wherever truecolor is supported. |
| %K{…} … %k | The same three forms, for the background. |
| %B … %b | Bold. %U %u underline, %S %s reverse video. |
Older themes use $fg[red] and $reset_color from the
colors function. They still work — oh-my-zsh loads colors for you —
but they expand to raw terminal escapes, which zsh cannot measure. That is a real problem:
see what breaks a prompt.
%F{green} can land on a different
green — or, in a light scheme, on something unreadable. The editor previews a theme against
ten common schemes for this reason.
Git status
oh-my-zsh's git library gives themes a small set of functions. The two you will use are
git_prompt_info, which prints the branch wrapped in your prefix and suffix, and
git_prompt_status, which prints one marker per kind of change:
ZSH_THEME_GIT_PROMPT_PREFIX="%F{242}on %F{magenta}"
ZSH_THEME_GIT_PROMPT_SUFFIX="%f"
ZSH_THEME_GIT_PROMPT_DIRTY="%F{yellow} ✗"
ZSH_THEME_GIT_PROMPT_CLEAN="%F{green} ✔"
PROMPT='%F{blue}%~%f $(git_prompt_info) '
The marker variables — ZSH_THEME_GIT_PROMPT_ADDED,
…_MODIFIED, …_UNTRACKED, …_RENAMED,
…_DELETED, …_UNMERGED, …_STASHED,
…_AHEAD, …_BEHIND — are listed with their defaults in the
cheat sheet. Every one of them is just a string, so the
colour goes inside the string.
parse_git_dirty runs git status on every prompt, which is slow in
a large repository. git config --add oh-my-zsh.hide-dirty 1 inside that one
repository turns the check off without changing your theme.
Root, SSH and exit codes
zsh has a ternary escape, %(x.true.false), where x is a test
letter and the two branches are separated by whatever character follows the condition:
%(!.%F{red}#%f.%F{green}$%f) # root gets a red #, anyone else a green $
%(?..%F{red}%? ) # exit code, but only when the last command failed
%(1j.%F{yellow}&%j .) # a job count, only when there are background jobs
There is no test letter for “am I over SSH”, because that is an environment variable rather
than shell state. The standard trick is a precmd hook that sets a slot in
psvar, which does have a test letter:
autoload -Uz add-zsh-hook
zsh_theme_ssh_psvar() { [[ -n $SSH_TTY$SSH_CONNECTION ]] && psvar[1]=1 || psvar[1]=; }
add-zsh-hook precmd zsh_theme_ssh_psvar
PROMPT='%(1V.%F{red}%M%f.%F{green}%m%f) %~ ' # fully qualified and red over SSH
${SSH_TTY:+…} in a prompt. zsh ends a
${…} at the first unescaped }, so
${SSH_TTY:+%F{red}} swallows the } of the colour escape and prints
a stray brace. The psvar form above has no such trap.
Two-line prompts
A second line costs nothing but a newline inside the string, and it buys back the width that long paths and branch names eat:
PROMPT='%F{242}╭─%f %B%F{green}%n@%m%f%b %F{blue}%~%f $(git_prompt_info)
%F{242}╰─%f%(?.%F{green}.%F{red})➜%f '
Two things to know. RPROMPT is drawn on the first line of the prompt,
not next to the cursor, so a right prompt on a two-line theme sits up and to the right. And
a prompt that ends with a newline makes clear and Ctrl+L behave oddly in some
terminals; ending with a space is safer.
What breaks a prompt
Unmeasured escapes push the line
zsh counts the printing width of your prompt to know where the cursor is. Raw terminal
escapes have no printing width, so anything zsh cannot recognise must be wrapped in
%{…%}, which means “this occupies zero columns”. %F and
%B are already understood and must not be wrapped. Get this wrong and
the symptom is unmistakable: long command lines wrap in the wrong place, and editing an
earlier word smears the prompt.
Glyphs that your font does not have
Powerline separators and Nerd Font icons live in the Private Use Area. Without a patched
font they render as a box, and their width is a guess, so alignment goes with them. Use
%G (or the editor's width hint) to tell zsh that a sequence counts as one
column, and keep an ASCII variant for consoles and SSH sessions.
A virtualenv prefix in front of everything
If your prompt suddenly starts with (env-name) ahead of your own first segment,
that is not your theme: it is the virtualenv's activate script doing
PS1="(env-name) $PS1". Set VIRTUAL_ENV_DISABLE_PROMPT=1 — themes
exported from the editor do it for you — and print $VIRTUAL_ENV in a segment of
your own. Conda ignores that variable; use
conda config --set changeps1 false.
Colours that vanish inside tmux or over SSH
Truecolor needs the terminal to advertise it; inside tmux it needs
set -ga terminal-overrides ",*256col*:Tc". When in doubt, build the theme in
256-colour mode, which every terminal emulator written this century supports.
Frequently asked questions
Where do oh-my-zsh themes live?
In ~/.oh-my-zsh/custom/themes/. Save the generated file there as <name>.zsh-theme, set ZSH_THEME="<name>" in ~/.zshrc, then run exec zsh. The custom directory survives oh-my-zsh upgrades; the bundled themes directory does not.
Do I need a Nerd Font for a custom zsh prompt?
Only if you use powerline separators or glyph segments. Everything else is plain text. The editor warns you when a prompt contains a glyph outside the basic set, and an ASCII-only switch rewrites those segments so the prompt stays readable over SSH or in a console without a patched font.
Why does my prompt show (venv) in front of my theme?
That prefix is not your theme. A Python virtualenv's activate script prepends "(name) " to PS1 unless VIRTUAL_ENV_DISABLE_PROMPT is set, so it lands ahead of your whole prompt. Themes exported here set that variable and read $VIRTUAL_ENV themselves, which puts the environment name back in its own segment. Conda needs conda config --set changeps1 false instead.
Why do the colours look different in my terminal?
Colour indexes 0-15 are defined by the terminal palette, not by zsh, so index 2 is whatever green your scheme picked. Indexes 16-255 are fixed across terminals, and truecolor hex values are exact wherever 24-bit colour is supported. Switch the preview between palettes to see how far a theme drifts.
What is the difference between PROMPT and RPROMPT?
PROMPT (also called PS1) is drawn at the left, where you type. RPROMPT is drawn flush to the right edge of the terminal and is erased by default when you accept a line, which makes it the right place for timestamps, exit codes and other context you do not want copied along with a command.
Can I edit the oh-my-zsh theme I already use?
Yes. Paste any .zsh-theme file into the import dialog. Local variables, $UID and SSH branches, ZSH_THEME_GIT_PROMPT_* settings and the legacy $fg[...] arrays are all understood, and anything the parser cannot classify is kept verbatim so an import never silently changes how your prompt looks.
Does a generated theme work in plain zsh, without oh-my-zsh?
The prompt escapes do, and so do the helper functions the generator writes for you. The git segments call oh-my-zsh helpers such as git_prompt_info, so outside oh-my-zsh you either source its git library or drop the git segments.
Next
Keep the zsh prompt escape codes cheat sheet open while you work, or skip the escapes entirely and assemble the prompt in the editor — it writes the file, checks it against the shell states that usually break a theme, and tells you what each segment costs.