skip to content

Bash Compatibility and Migration

Where zsh quietly differs from bash — no word splitting by default, 1-based arrays, stricter globbing — and how emulation modes paper over it. Interviewers ask because macOS made zsh the default and every team hit this migration.

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

questions

5

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

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

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

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

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