skip to content

POSIX & Bourne-family shells

The Bourne-lineage shells — zsh, fish, dash, ksh — and how far each strays from POSIX. Interviewers ask because the gap between the shell you type in and the /bin/sh your script runs under is a recurring source of breakage.

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

explore

questions

30

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 bash, which startup files does a login shell read compared with an interactive non-login shell, and why does almost every ~/.bash_profile contain a line that sources ~/.bashrc?

level: middleimportance: must knowfreq 72%

basics

~10 s

A login bash shell reads /etc/profile and then the first existing of ~/.bash_profile, ~/.bash_login or ~/.profile. An interactive non-login shell reads ~/.bashrc instead. Sourcing ~/.bashrc from ~/.bash_profile makes both entry paths behave identically.

open as a page

A deploy script whose first line is `#!/bin/sh` runs fine on a RHEL host but on Debian fails with `[[: not found` and `Syntax error: "(" unexpected`. What is /bin/sh on each system, and why do those lines fail on one and not the other?

level: middleimportance: must knowfreq 72%

basics

~20 s

On RHEL /bin/sh is a symlink to bash, which still accepts [[ ]] and arrays even when invoked as sh. On Debian and Ubuntu /bin/sh is dash, a small strict POSIX shell that has neither. The script was never portable; RHEL hid the bug.

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

Bash edits the command line through the GNU Readline library. What does pressing Ctrl-R at a bash prompt do, and where do you make a change to bash's keybindings or editing mode permanent?

level: juniorimportance: should knowfreq 46%

basics

~20 s

Ctrl-R starts an incremental reverse search through the current session's command history: keep typing to narrow it, press Ctrl-R again for older matches, Enter to run. Persist keybinding and editing-mode changes in ~/.inputrc, or with bind in ~/.bashrc.

open as a page

fish (the friendly interactive shell) advertises autosuggestions, syntax highlighting and completions as out-of-the-box features. In fish, what is the difference between an autosuggestion and a tab completion, and where does each one get its candidates from?

level: juniorimportance: should knowfreq 30%

basics

~20 s

In fish, an autosuggestion is one greyed-out continuation of the line you are typing, taken mainly from your history and accepted with the right arrow key. Tab completion instead offers the set of candidates for the current token.

open as a page

What does the `#!/bin/sh` line at the top of a script actually cause to happen when the file is executed, and how does choosing `#!/bin/sh`, `#!/bin/bash` or `#!/usr/bin/env bash` change which interpreter ends up running it?

level: juniorimportance: should knowfreq 60%

basics

~20 s

The kernel reads the first line, and if it starts with #! it runs the named interpreter with the script as an argument. /bin/sh means whatever POSIX-ish shell that path holds, /bin/bash demands bash at that exact path, and /usr/bin/env bash finds the first bash on PATH.

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

You keep two bash terminals open all day, and commands typed in the first are missing from history after the second one exits. How does bash write its history file, and which settings stop concurrent sessions from clobbering each other?

level: middleimportance: should knowfreq 52%

basics

~20 s

Each bash session keeps history in memory and writes it out when the shell exits, overwriting HISTFILE by default — so the last shell to exit wins. shopt -s histappend makes sessions append instead, and a history -a in PROMPT_COMMAND shares lines immediately.

open as a page

You change your login shell to fish with `chsh`. Which of your existing shell scripts stop working as a result, and what genuinely does break because fish's syntax is deliberately not POSIX-compatible?

level: middleimportance: should knowfreq 40%

basics

~20 s

Scripts keep working: a script runs under the interpreter in its shebang, not under your login shell. What breaks is anything meant to be pasted into or sourced by your interactive shell, because fish uses set instead of VAR=value, set -x instead of export, and $status instead of $?.

open as a page

Before shipping a script that begins with `#!/bin/sh` to hosts where /bin/sh is dash, how would you check it for bash-only constructs, and what does `dash -n script.sh` fail to catch?

level: middleimportance: should knowfreq 36%

basics

~20 s

Lint it with shellcheck under sh rules, run Debian's checkbashisms over it, and actually execute it under the real interpreter in a throwaway container. A -n parse check only catches syntax errors, so runtime bashisms such as [[ ]], local and echo -e sail straight through.

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

A command works when you log in to a Linux host and type its name, but `ssh host mytool` and the same command from a crontab both fail with "command not found". Explain this in terms of which bash startup files each of those runs actually reads.

level: seniorimportance: should knowfreq 58%

basics

~20 s

The PATH entry lives in a startup file those invocations never read. ssh host mytool runs a non-interactive, non-login shell, and cron runs the command with a minimal environment, so neither ~/.bash_profile nor (in practice) ~/.bashrc applies.

open as a page

Your `#!/bin/sh` scripts run on some hosts where /bin/sh is dash and others where it is BusyBox ash. Both are called POSIX shells, yet a script using `[[ ]]` works on one and fails on the other. What differences remain between two POSIX shells, and how would you decide between fixing the script and pinning an interpreter?

level: seniorimportance: should knowfreq 32%

basics

~20 s

POSIX is a floor, not a ceiling: each shell adds extensions, and BusyBox ash is commonly built with a bash-compatibility option that accepts [[ ]] while dash does not. Decide by audience — strict POSIX for entrypoints on minimal images, an explicit bash dependency for ordinary internal tooling.

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 ship a command-line tool called `deploytool` and want `deploytool <TAB>` to complete its subcommands in bash. Which bash builtins and shell variables implement that, and where does the completion script get installed?

level: middleimportance: nice to knowfreq 30%

basics

~20 s

Bash's complete builtin registers how a command completes. A fixed word list is complete -W "start stop status" deploytool; anything context-sensitive uses complete -F _deploytool deploytool, where the function fills the COMPREPLY array, usually from compgen. Install it in the bash-completion completions directory.

open as a page

Where does the POSIX shell command language actually come from, and how do ksh88/ksh93 and the C shell family (csh and tcsh) relate to it?

level: middleimportance: nice to knowfreq 18%

basics

~20 s

POSIX standardised the Bourne shell as extended by David Korn's ksh88, which is why bash, ksh, dash and ash all share one grammar. The C shell is a separate lineage with incompatible syntax; it contributed interactive features such as job control and history, not the scripting language.

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

A colleague using fish finds that one directory ends up in their PATH twice in every new terminal, and that a variable they deleted from `~/.config/fish/config.fish` is still set in fresh shells. What are fish's universal variables, and how should fish configuration be laid out so this does not happen?

level: seniorimportance: nice to knowfreq 15%

basics

~20 s

A universal variable, set with set -U, is stored on disk in ~/.config/fish/fish_variables and shared by all of that user's fish sessions. Running set -U from config.fish re-applies it on every start, and deleting the line never erases what was already persisted.

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