Bash's extended glob operators `!(pat)`, `@(pat)`, `+(pat)`, `*(pat)` and `?(pat)` are off by default. What does each of them match, how do you turn them on, and why can the single line `shopt -s extglob; echo !(*.txt)` still fail with a syntax error?
answer
- five operators over a pipe-separated list
- exactly one, one or more, none of these
- off by default, shopt -s enables it
- the line is parsed before it is run
- syntax error, not a no-match error
basics
~20 sWith shopt -s extglob, bash adds five operators over a pipe-separated pattern list: ?(p) zero or one, *(p) zero or more, +(p) one or more, @(p) exactly one, and !(p) anything that does not match. Enabling it on the same line fails because bash parses the whole line before running any of it.
solid answer
~50 sExtended globs give ordinary patterns the quantifiers and alternation they otherwise lack. After `shopt -s extglob`, each operator takes a `|`-separated list: `?(list)` matches zero or one occurrence, `*(list)` zero or more, `+(list)` one or more, `@(list)` exactly one of the alternatives, and `!(list)` matches anything the list does **not** match. So `!(*.txt)` is every name that is not a .txt file, `@(*.log|*.err)` is either kind of log, and `report+([0-9]).csv` is report followed by at least one digit. The one-liner trap is a parsing issue rather than a runtime one: bash reads and parses an entire line — and an entire function or compound command — before executing any part of it, so with extglob still off at parse time the `!(` is a syntax error even though the `shopt` earlier on the line would have enabled it. Put `shopt -s extglob` on its own line near the top of the script.
code
bash · 8 linesshopt -s extglob
cd "$(mktemp -d)"
touch a.txt b.log c.md report1.csv report22.csv
echo !(*.txt) # everything that is not a .txt file
echo @(*.log|*.md) # exactly one of the two extensions
echo report+([0-9]).csv # report, one or more digits, .csv
echo !(*.csv|*.md) # composed exclusion of two patternsgo deeper
Recognise the shapes and know they need shopt -s extglob first. Being able to say that !(*.txt) means "everything except .txt files" is enough at this level.
Explain all five operators precisely, including that the list is pipe-separated and that @ means exactly one alternative, and describe the parse-before-execute reason the one-liner fails.
Show judgment about shell state and portability: enabling extglob at the top of a script you own, never flipping it inside a sourced library without restoring it, and knowing it rules out a /bin/sh shebang.
Own the standard: decide whether the team's scripts may depend on bash-only pattern syntax at all, and make that choice consistent with the interpreters in your base images rather than deciding it file by file.
## What the operators are A plain glob has no alternation and no repetition. Extended globs add both, in five forms, each wrapping a `|`-separated list of sub-patterns: ```bash shopt -s extglob ?(pattern-list) # zero or one occurrence *(pattern-list) # zero or more occurrences +(pattern-list) # one or more occurrences @(pattern-list) # exactly one of the alternatives !(pattern-list) # anything that does NOT match the list ``` The operator character sits immediately before the parenthesis with no space. Sub-patterns may themselves contain ordinary glob metacharacters and may nest. ```bash touch a.txt b.log c.md report1.csv report22.csv echo !(*.txt) # b.log c.md report1.csv report22.csv echo @(*.log|*.md) # b.log c.md echo report+([0-9]).csv # report1.csv report22.csv echo *.@(jpg|jpeg|png) # any of three image extensions ``` `@(a|b)` is the alternation people miss most often: without extglob there is no way to say "either of these two spellings" in a single pattern, and the usual workaround is brace expansion — but braces are pure text generation and produce words whether or not the files exist, which is a different thing entirely. `!(...)` is the operator with the most surprising semantics. It means "this component matches anything **except** what the list matches", and it composes: `!(*.txt|*.md)` excludes both. Because it is still pathname expansion, it also still obeys the leading-dot rule, so `!(*.txt)` will not sweep in `.hidden` unless `dotglob` is set. Watch the interaction with a suffix: `!(*.txt)*` is not "not a .txt file", because the trailing `*` can absorb the extension. ## Why the one-liner fails ```bash $ shopt -s extglob; echo !(*.txt) bash: syntax error near unexpected token `(' ``` Bash reads input a **whole line at a time** and parses it into commands before executing any of them. At parse time extglob is still off, so `!(` is not recognised as the start of an extended pattern and the `(` is an unexpected token. The `shopt` on the left of the semicolon has not run yet — it cannot, because parsing failed first. The same trap has a bigger version. A function body is parsed as one unit when the function is **defined**, so this does not work either: ```bash setup() { shopt -s extglob; } use() { echo !(*.txt); } # parsed now, while extglob is still off ``` And the same applies to any compound command — an `if` block, a `while` loop, a `{ ...; }` group — that both enables the option and uses the syntax. The reliable habit is to put `shopt -s extglob` on its own line at the top of the script, before anything that uses the syntax is read. A useful diagnostic consequence: the failure is a **syntax error**, not a no-match error. If a colleague reports `syntax error near unexpected token '('` from a script that works on their machine, the first thing to check is whether extglob is enabled where the script is being parsed. ## Practical uses and alternatives The strongest everyday use is deleting or copying everything except something: ```bash shopt -s extglob rm -rf -- !(keep-me|also-keep) # remove everything else in this directory cp -- !(*.tmp) staging/ ``` That is far cleaner than the loop-plus-condition alternative, and it is one of the reasons people knowingly choose bash over POSIX sh. But note the cost: extended globs are **bashisms**. They do not exist in dash, so a `#!/bin/sh` script cannot use them; portability tradeoffs of that kind are their own topic. When a script must stay POSIX, the honest alternatives are `find` with `! -name` or a loop that skips names it does not want. Extended globs are also the pattern syntax used by bash's other pattern contexts, so once extglob is on, the operators become available wherever bash matches patterns rather than only in pathname expansion. ## Enabling it without wrecking the caller `shopt` changes shell state that persists for the rest of the script, and for the caller too if your file is sourced. If a library needs extglob only briefly, save and restore: ```bash prev=$(shopt -p extglob) # prints "shopt -u extglob" or "shopt -s extglob" shopt -s extglob # ... use the patterns ... eval "$prev" ``` Because of the parse-time rule this only works when the code using the pattern is read after the `shopt -s` has executed — in practice, in a separate sourced file or an `eval`'d string. For a normal standalone script, one `shopt -s extglob` near the shebang is simpler and entirely adequate.
- How is `@(a|b)` different from the brace expansion `{a,b}`?Brace expansion is pure text generation and happens earlier: `{a,b}.txt` always produces the two words a.txt and b.txt whether or not those files exist. `@(a|b).txt` is pathname expansion, so it produces only names that are actually on disk and leaves the pattern literal — or errors, or vanishes — when nothing matches.
- You need `!(*.tmp)` behaviour in a `#!/bin/sh` script. What do you use instead?Extended globs are a bashism and dash has no equivalent, so either match positively with several patterns, filter inside a loop that skips the names you do not want, or delegate to `find` with `! -name '*.tmp'` and consume its output safely. Alternatively change the shebang to bash and accept the dependency.
- Why does `rm -rf !(keep)` still leave hidden files behind?Extended globs are still pathname expansion, so they obey the leading-dot rule: a pattern that does not itself start with a dot never matches a name that does. Enable dotglob as well if hidden entries must be included, and remember that a clean approach is often removing the directory and recreating it.
saying these in an interview costs you the question
- Assuming extglob is on by default in bash
- Writing `! (pat)` with a space before the parenthesis
- Thinking the syntax error means the pattern matched nothing
- Believing `!(*.txt)` also excludes hidden files
- Calling extended globs POSIX-portable