skip to content

zsh

Z shell as a daily driver: startup file order, the completion system, globbing, prompt and plugin frameworks, and where it diverges from bash. Interviewers ask because it is the macOS default, so every mixed team runs into its differences.

part ofCommand-line shellsoverview, primer and where to startread it →
on this pageshow

explore

questions

17

On a machine whose shell is zsh, `pip install requests[security]` fails instantly with `zsh: no matches found: requests[security]` and pip never runs, while the identical line works in bash. Why does zsh reject it, and how do you fix it?

level: juniorimportance: must knowfreq 66%

answer

  1. the shell errored, not the program
  2. brackets are pattern characters
  3. zsh option that is on by default
  4. bash's shopt -s failglob equivalent

basics

~20 s

zsh reads the brackets as a filename pattern and, under its default NOMATCH option, refuses to run a command whose pattern matches nothing. bash instead passes an unmatched pattern through literally. Quote the argument: pip install 'requests[security]'.

solid answer

~40 s

The `zsh:` prefix tells you the shell aborted the line, not pip. `[` and `]` are glob characters, so zsh tries to expand `requests[security]` against the current directory — it means "requests followed by one character from the set s,e,c,u,r,i,t,y" — and nothing matches. zsh's `NOMATCH` option is on by default, and it makes an unmatched pattern a hard error that cancels the whole command; bash's POSIX default is to pass the word through unchanged, which is why the same line works there. The fix is to stop the pattern from being a pattern: quote it (`'requests[security]'`), backslash-escape the brackets, or prefix the command with zsh's `noglob` modifier. You can also `unsetopt NOMATCH` to get bash's behaviour globally, but you lose the typo protection that makes NOMATCH worth having.

code

bash · 12 lines
bash
# zsh, default options
setopt | grep -i nomatch        # nomatch is on
ls *.nosuchextension            # zsh: no matches found: *.nosuchextension

# make zsh behave like bash for unmatched patterns
unsetopt NOMATCH
ls *.nosuchextension            # ls: *.nosuchextension: No such file or directory

# the right fix for a non-filename argument
setopt NOMATCH
noglob pip install requests[security]
pip install 'requests[security]'

go deeper

for a junior

Recognise that a message prefixed with zsh: came from the shell, so the program never ran, and reach for quotes around any argument that contains *, ?, or brackets.

for a middle

Explain that unmatched patterns have three possible behaviours — literal passthrough, removal, or error — and name zsh's NOMATCH and bash's failglob/nullglob as the switches between them.

for a senior

Show where this bites in real tooling: remote scp paths, URLs with query strings, refspecs, and CI scripts that behave differently under /bin/sh, and argue for quoting at the call site rather than flipping a global option.

for a principal

Frame it as a defaults tradeoff — loud failure versus silent literal text — and take a position on which default a team's shared shell configuration should carry, given that the silent option is the one that produces genuinely wrong results.

## The error comes from the shell, not from the program Before `pip` exists as a process, zsh parses the command line and performs filename generation (globbing) on every word that contains a pattern character. `requests[security]` contains `[` and `]`, so zsh treats it as a bracket expression: the literal text `requests`, followed by exactly one character drawn from the set `s e c u r i t y`. It looks for files in the current directory named `requestss`, `requeste`, `requestc`, and so on. In a normal project directory nothing matches. The `zsh: ` prefix on the message is the giveaway. The shell never built an argument list, never forked, and never executed pip. Any theory that blames pip, the package name, or the network is disproved by that prefix alone. ## NOMATCH is the only real difference from bash Both shells glob. What differs is what happens when a pattern matches nothing, and there are only three possible behaviours: - **Pass the word through literally.** This is POSIX, and it is bash's default. `pip` receives the eight-character string `requests[security]` and does the right thing with it. - **Remove the word.** zsh calls this `NULL_GLOB`; bash calls it `shopt -s nullglob`. - **Fail the command.** zsh calls this `NOMATCH` and has it **on by default**; bash calls it `shopt -s failglob` and has it off. So zsh is not doing anything exotic — it simply defaults to the third option. Related knobs: `unsetopt NOMATCH` restores bash-style passthrough, and `CSH_NULL_GLOB` errors only when *no* pattern on the line matched anything. ## The fixes, best first ```zsh pip install 'requests[security]' # quote — always correct pip install requests\[security\] # escape — same effect noglob pip install requests[security] # zsh precommand modifier: skip globbing for this command alias pip='noglob pip' # if you hit it constantly unsetopt NOMATCH # global bash-like behaviour; last resort ``` Quoting is right because the argument was never meant to be a filename pattern in the first place; you are telling the shell the truth about your intent. `noglob` is a genuine zsh feature — a precommand modifier that disables filename generation for that one command — and it is convenient for tools whose arguments routinely look like globs. ## Where else this bites a bash refugee - **Remote paths.** `scp server:/var/log/*.gz .` — the pattern is meant for the *remote* shell, but your local zsh expands it first and aborts. Quote it: `scp 'server:/var/log/*.gz' .` - **URLs with query strings.** `?` is a pattern character, so `curl http://api.example.com/v1?limit=10` can fail. Quote the URL. - **Refspecs and branch patterns.** `git branch -d 'feature/*'`, `git log --grep='fix[0-9]'`. - **Extended globbing.** If a configuration enables `EXTENDED_GLOB`, `^` and `#` become pattern characters too, and `git show HEAD^` starts failing. That option is off in a bare zsh but many shipped configurations turn it on. In every case the rule is the same: if an argument is a pattern *for something other than your local filesystem*, quote it. ## Why the default is defensible The silent-passthrough behaviour bash inherits from POSIX has a nasty failure mode. Consider: ```zsh for f in *.log; do gzip "$f"; done ``` In a directory with no `.log` files, bash runs the loop body exactly once with `f` set to the literal string `*.log`, and `gzip` fails with a confusing "no such file" for a file whose name contains an asterisk. Scripts that then feed `$f` into `rm`, `mv`, or a `find -name` argument produce genuinely surprising results. zsh stops the command instead, which is noisy but never silently wrong. The same protection catches ordinary typos: `ls *.tct` tells you nothing matched rather than printing an error from `ls` about a file literally named `*.tct`. That is the trade the interviewer usually wants you to articulate: zsh chose loud failure over silent literal text, and once you know that, the fix is always "quote what was never a filename".

  • How would you make bash reproduce zsh's behaviour here, and why would anyone want that?
    `shopt -s failglob` makes an unmatched pattern an error that cancels the command, exactly like zsh's default `NOMATCH`. People enable it in interactive shells to catch typos, and some prefer `shopt -s nullglob` in scripts so that `for f in *.log` iterates zero times instead of once over the literal string — the classic bug that silent passthrough causes.
  • You quote the glob for scp and it still does not expand on the remote side. What is going on?
    Quoting stops your local zsh from expanding it, but expansion then depends on the remote command being run through a shell. scp does invoke a remote shell, so a quoted pattern normally expands there. If the remote path is odd, watch for double-expansion: the string is parsed once locally and once remotely, so characters like spaces may need escaping twice.
  • Does NOMATCH fire for a word that contains no pattern characters?
    No. Filename generation only applies to words containing pattern characters such as `*`, `?`, `[`, or — with EXTENDED_GLOB — `^` and `#`. A plain word like `requests` is passed to the command untouched, matching file or not, in both shells.

saying these in an interview costs you the question

  • Blaming pip or the package index for a shell error
  • Thinking zsh does not support glob patterns at all
  • Claiming bash never errors on unmatched globs, even with failglob
  • Recommending unsetopt NOMATCH as the first fix instead of quoting
  • Believing the brackets are Python or pip syntax the shell should know about

context

open as a page

A CLI you installed dropped a zsh completion function file named `_mytool` into a directory on disk, but in your zsh session pressing Tab after `mytool ` still only completes filenames. How does zsh's completion system find completion functions, and what has to be in place before that file takes effect?

level: juniorimportance: must knowfreq 62%

basics

~20 s

zsh looks for completion functions in the directories listed in its fpath array, and only uses them once the completion system has been initialised by running autoload -Uz compinit followed by compinit. The directory must be on fpath before compinit runs.

open as a page

In zsh, `files="a.txt b.txt"; touch $files` creates one file literally named `a.txt b.txt`, while bash creates two files. Why do the shells differ, and what are the correct ways to get the two-file behaviour in zsh?

level: middleimportance: must knowfreq 60%

basics

~20 s

zsh does not split unquoted parameter expansions into words on whitespace the way bash does, so $files stays a single argument. Use a real array — files=(a.txt b.txt) — or force splitting for one expansion with ${=files}.

open as a page

zsh's completion system is configured with `zstyle` on context strings such as `':completion:*:*:git:*'`. What is a zstyle context, how does zsh decide which of several matching zstyle lines applies, and what would you set to get case-insensitive matching and an arrow-key menu?

level: middleimportance: must knowfreq 55%

basics

~20 s

A zstyle context is a colon-separated string describing the exact situation being completed, and zstyle lines are patterns matched against it, with the most specific matching pattern winning. Case-insensitive matching comes from the matcher-list style; the arrow-key menu comes from the menu style set to select.

open as a page

Name zsh's user startup files in the order they are read, and say which of them are read by a login shell, by an interactive non-login shell, and by a shell that is only running a single command.

level: middleimportance: must knowfreq 70%

basics

~10 s

zsh always reads ~/.zshenv; ~/.zprofile and ~/.zlogin are read only by login shells, and ~/.zshrc only by interactive ones. The order is .zshenv, .zprofile, .zshrc, .zlogin, with .zlogout on logout of a login shell.

open as a page

A deploy job runs `ssh build@host 'mytool --version'` and fails with `zsh: command not found: mytool`, yet logging into that same account interactively and typing the command works. The PATH entry is exported from ~/.zshrc. Why does the remote command miss it, and where does the export actually belong?

level: seniorimportance: must knowfreq 48%

basics

~20 s

sshd runs a remote command in a non-interactive, non-login zsh, which reads only ~/.zshenv and never ~/.zshrc. Move the PATH export to ~/.zshenv, and keep that file silent and fast because every zsh process runs it.

open as a page

In zsh, what is the `setopt` builtin for, how does it relate to `unsetopt`, and how would you find out which shell options are currently enabled?

level: juniorimportance: should knowfreq 38%

basics

~20 s

setopt turns a named zsh behaviour on and unsetopt turns it off; setopt NO_NAME means the same as unsetopt NAME. Typed with no arguments, setopt lists the options currently on and unsetopt lists those currently off.

open as a page

A snippet is moved from bash to zsh: `a=(x y z); echo ${a[1]}; echo ${#a}`. What does each line print under each shell, and why do they differ?

level: middleimportance: should knowfreq 44%

basics

~10 s

zsh prints x then 3: its arrays are 1-indexed and ${#a} counts elements. bash prints y then 1: its arrays are 0-indexed and ${#a} is the length of element zero, not the element count.

open as a page

Your team ships an internal CLI called `deploytool` that takes a few flags and then a subcommand. In zsh, how would you write a completion function for it, and how do you make zsh use that function for that command?

level: middleimportance: should knowfreq 34%

basics

~20 s

Write a function in a file named _deploytool whose first line is the #compdef tag line, describe the flags and arguments with the _arguments helper, offer subcommands with _describe, and place the file in a directory on fpath so compinit registers it. compdef binds it manually when you cannot.

open as a page

Every new zsh session on a machine prints `zsh compinit: insecure directories, run compaudit for list` and then asks whether to continue. What is compinit checking, why does it care, and how should you resolve it?

level: middleimportance: should knowfreq 46%

basics

~20 s

compinit refuses to load completion functions from directories or files that are writable by group or others, or not owned by root or by you, because those functions are shell code that runs as you. Run compaudit to list the offenders and tighten their ownership and permissions.

open as a page

You ship a zsh plugin whose functions misbehave for users who have set options such as SH_WORD_SPLIT or KSH_ARRAYS in their own configuration. What does adding `emulate -L zsh` at the top of each function do, and how is it different from `emulate sh`?

level: seniorimportance: should knowfreq 32%

basics

~20 s

emulate -L zsh resets options to zsh's native defaults for the duration of the enclosing function, so a user's unusual setopts cannot change how your code behaves. emulate sh instead switches the shell into sh-compatible mode, which is what you want for running Bourne-style code.

open as a page

In zsh, pressing Tab after a command that must query a slow source — a package index or a remote inventory — freezes the prompt for a second or two each time. How would you find out where the time goes, and what does zsh's completion system offer to fix it?

level: seniorimportance: should knowfreq 26%

basics

~20 s

Time the completion by running the underlying query by hand, then check whether the completion function supports zsh's completion cache. Turning on the use-cache style, with cache-path pointing at a writable directory, makes such functions store and re-read candidate lists instead of re-querying on every Tab.

open as a page

Opening a new terminal tab takes roughly two seconds before the zsh prompt appears. How would you find out what part of the startup is slow, and what typically turns out to be responsible?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Measure first: time zsh -i -c exit against a zsh -f baseline to confirm the cost is your own configuration, then load the zsh/zprof module at the top of ~/.zshrc and call zprof at the bottom to see which functions consume the time.

open as a page

Your team wants one standard zsh setup across everyone's laptops and the CI images. How would you weigh oh-my-zsh against a plugin manager such as zinit or antidote against a hand-written ~/.zshrc, and what would you standardise regardless of which you pick?

level: principalimportance: should knowfreq 26%

basics

~20 s

Choose by who owns ~/.zshrc and when code loads: oh-my-zsh takes the file over and sources a large bundle eagerly, plugin managers leave you as the author while adding version-pinned dependencies, and a hand-written file is fastest and fully auditable. Standardise the environment contract, not the taste layer.

open as a page

You put `PROMPT="%~ [$(date +%H:%M)] %# "` in your ~/.zshrc and the clock in the zsh prompt is frozen at the time the shell started. Explain what happened, and what has to change for the prompt to re-evaluate that substitution before every command line.

level: middleimportance: nice to knowfreq 30%

basics

~20 s

The double quotes ran the command substitution once, when ~/.zshrc was sourced, so the value was baked into the string. Assign the prompt single-quoted and enable the PROMPT_SUBST option, or use the native %D{%H:%M} prompt escape.

open as a page

Your team's shared .bashrc is being ported to .zshrc after everyone's login shell changed to zsh. Beyond word splitting and array indexing, which bash-specific constructs in that file will simply not work under zsh, and what replaces each?

level: seniorimportance: nice to knowfreq 28%

basics

~10 s

shopt, HISTCONTROL, PROMPT_COMMAND, PS1 backslash escapes, complete/compgen completion scripts and $BASH_SOURCE are all bash-only. zsh replaces them with setopt, HIST_* options plus SAVEHIST, the precmd function, percent-style prompt escapes, bashcompinit and $0.

open as a page

You own a CLI that everyone on your team uses daily, and you are deciding how its zsh completion should be produced and delivered. How would you choose between a hand-written completion function, one generated from the CLI's own argument parser, and `compdef _gnu_generic <cmd>` — and what do you owe users beyond the initial install?

level: principalimportance: nice to knowfreq 18%

basics

~20 s

Choose by how much the completion must know and who will maintain it: generated completions stay in sync with the parser, hand-written ones give the best experience but drift, and _gnu_generic costs nothing but only completes option names. Delivery and staleness matter as much as the choice.

open as a page