Bash & the POSIX Shell Module 4 – Exit status, tests and branching
[[ ]]: patterns, regex and BASH_REMATCH
Test strings safely with Bash's [[ ]]: glob patterns with ==, regular expressions with =~, capture groups in BASH_REMATCH, and why sh scripts cannot use it.
What you will learn
- Use double brackets for safe string tests, glob patterns and && or || inside one test
- Match extended regular expressions with =~ and read capture groups from BASH_REMATCH
- Predict how quoting turns a pattern or a regular expression into literal text
Before you start
What you’ll learn · Before you start
What you will learn
- Use double brackets for safe string tests, glob patterns and && or || inside one test
- Match extended regular expressions with =~ and read capture groups from BASH_REMATCH
- Predict how quoting turns a pattern or a regular expression into literal text
Before you start
On this page
[[ $s == report-* ]] is true for report-final.pdf, and [[ $s == "report-*" ]] is false for the same name. In
Bash’s double brackets the right side of == is a pattern, and two quote marks turn it back into plain text. The same
rule decides whether =~ reads a regular expression or a literal string.
You will write safe string tests without the quoting traps of [ ], match glob patterns and extended regular
expressions, take version numbers apart with BASH_REMATCH, and see exactly where a #!/bin/sh script cannot use any
of it.
Double brackets are a Bash keyword, so values are never split
[[ … ]] looks like [ … ] but is not a command: it is a keyword that Bash parses itself. The manual spells out the
consequence: the words between [[ and ]] do not undergo word splitting or filename expansion. An empty or
spaced value therefore stays one value, quoted or not, and &&, || and < can be used inside the test:
from session import run, write
# [[ ]] is a Bash keyword: variables inside it are not split or globbed, the right side of == is a glob pattern unless
# it is quoted, && and || work inside it, and < compares strings.
write("notes.txt", "remember the milk\n")
write("check.sh", "#!/bin/sh\nif [[ -n \"$1\" ]]; then echo set; fi\n")
run("""
x=''; [[ $x == "" ]] && echo "empty, no quotes needed"
name='Asha Rao'; [[ $name == 'Asha Rao' ]] && echo "one word, spaces and all"
s=report-final.pdf
[[ $s == report-* ]] && echo "glob: starts with report-"
[[ $s == "report-*" ]] || echo "quoted: not the literal text report-*"
[[ $s != *[0-9]* ]] && echo "no digit in the name"
[[ -f notes.txt && $s == *.pdf ]] && echo "both tests in one"
[[ 10 < 9 ]] && echo "10 sorts before 9 as text"
[[ 9 < 10 ]] || echo "9 sorts after 10 as text"
(( 10 < 9 )) || echo "as numbers, 10 is not less than 9"
shopt -s nocasematch; [[ REPORT == report ]] && echo "nocasematch ignores case"; shopt -u nocasematch
!dash -c '[[ 1 == 1 ]] && echo yes'
!shellcheck check.sh
""") Output
$ x=''; [[ $x == "" ]] && echo "empty, no quotes needed" empty, no quotes needed $ name='Asha Rao'; [[ $name == 'Asha Rao' ]] && echo "one word, spaces and all" one word, spaces and all $ s=report-final.pdf $ [[ $s == report-* ]] && echo "glob: starts with report-" glob: starts with report- $ [[ $s == "report-*" ]] || echo "quoted: not the literal text report-*" quoted: not the literal text report-* $ [[ $s != *[0-9]* ]] && echo "no digit in the name" no digit in the name $ [[ -f notes.txt && $s == *.pdf ]] && echo "both tests in one" both tests in one $ [[ 10 < 9 ]] && echo "10 sorts before 9 as text" 10 sorts before 9 as text $ [[ 9 < 10 ]] || echo "9 sorts after 10 as text" 9 sorts after 10 as text $ (( 10 < 9 )) || echo "as numbers, 10 is not less than 9" as numbers, 10 is not less than 9 $ shopt -s nocasematch; [[ REPORT == report ]] && echo "nocasematch ignores case"; shopt -u nocasematch nocasematch ignores case $ dash -c '[[ 1 == 1 ]] && echo yes' dash: 1: [[: not found [exit status 127] $ shellcheck check.sh In check.sh line 2: if [[ -n "$1" ]]; then echo set; fi ^-----------^ SC3010 (warning): In POSIX sh, [[ ]] is undefined. For more information: https://www.shellcheck.net/wiki/SC3010 -- In POSIX sh, [[ ]] is undefined. [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 brackets_session.py
With x empty, [[ $x == "" ]] worked without quotes, and [[ $name == 'Asha Rao' ]] compared the whole name, where
the single-bracket lesson had to quote both variables. [[ -f notes.txt && $s == *.pdf ]] combined two tests in one.
The < and > operators compare strings in the locale’s order, not numbers: [[ 10 < 9 ]] was true, because the text
10 sorts before 9, while (( 10 < 9 )) compared the numbers. shopt -s nocasematch made REPORT match report.
The last two lines show the cost. dash answered [[: not found with status 127, and ShellCheck flagged the same test
in a #!/bin/sh script as SC3010, “In POSIX sh, [[ ]] is undefined”. POSIX’s rationale for test records that a
proposal to add the double-bracket command to POSIX.1-2024 was rejected: the existing versions were found error-prone,
and they behaved too differently from one shell to the next.
Comparing numbers with < in double brackets
Wrong: [[ $count < 10 ]], which is false for count=9 because the text 9 sorts after the text 10. Right:
(( count < 10 )) or [[ $count -lt 10 ]], which compare the numbers.
The right side of the comparison is a pattern, unless it is quoted
After == or !=, Bash reads the right side as a A pattern written in the notation of file name globbing, with *, ? and [ ]. After == or != in double brackets, Bash compares it with a string and never looks at files., with *, ? and
[…] as in file names, and matches it against the text on the left, never against the files in the folder.
[[ $s == report-* ]] was true for report-final.pdf. Quoting any part of the pattern makes
that part match literally, so [[ $s == "report-*" ]] asked whether the name is the eight characters report-*, and
it was not. The left side needs no quotes, the right side decides:
| Test | Means | For report-final.pdf |
|---|---|---|
[[ $s == report-* ]] |
starts with report- |
true |
[[ $s == "report-*" ]] |
is exactly report-* |
false |
[[ $s == *.pdf ]] |
ends with .pdf |
true |
[[ $s != *[0-9]* ]] |
contains no digit | true |
Key idea
In double brackets the left side is always one string and the right side is a pattern; quote the right side, or a part of it, exactly when you want literal text.
Checkpoint
Check: Double brackets as a keyword · Glob patterns after ==
A regular expression match saves its groups in an array
=~ compares the left side with a POSIX extended regular expression: The POSIX regex dialect grep -E, sed -E, awk and Bash's [[ =~ ]] read. + ? | ( ) and { } are metacharacters, so a plain + is one or more and (a|ab) is an alternation. More in “Regular expressions for grep, sed and awk”.
The match may succeed anywhere in the string, so ^ and $ anchor it to the whole value. Each parenthesised group is a
capture group: A part of a regular expression in round brackets, such as ([0-9]+), whose matched text is saved; Bash puts the saved texts in BASH_REMATCH[1] onwards., and its text is saved: BASH_REMATCH[0] holds the whole match and BASH_REMATCH[1] onwards the groups. Store the pattern in a
variable and leave the variable unquoted, which keeps backslashes and brackets away from the shell’s own quoting:
from session import files, run
# =~ matches an extended regular expression. Kept in a variable and left unquoted, the pattern is a regex; quoted, it
# is matched as literal text. Capture groups land in BASH_REMATCH.
files("ifsc.sh")
run("""
re='^v([0-9]+)\\.([0-9]+)\\.([0-9]+)$'
[[ v5.3.20 =~ $re ]] && echo "major ${BASH_REMATCH[1]}, minor ${BASH_REMATCH[2]}, patch ${BASH_REMATCH[3]}"
[[ v5.3 =~ $re ]] || echo "v5.3 does not match"
echo "after the failed match: ${#BASH_REMATCH[@]} elements"
[[ v5.3.20 =~ "$re" ]] || echo "quoted: the pattern is literal text"
[[ 'a.b' =~ a.b ]] && [[ 'axb' =~ a.b ]] && echo ". matches any character"
bash ifsc.sh ABCD0123456 ABCD1123456 abcd0123456 ABCD0A1B2C3 ABCD012345
""") Output
$ re='^v([0-9]+)\.([0-9]+)\.([0-9]+)$'
$ [[ v5.3.20 =~ $re ]] && echo "major ${BASH_REMATCH[1]}, minor ${BASH_REMATCH[2]}, patch ${BASH_REMATCH[3]}"
major 5, minor 3, patch 20
$ [[ v5.3 =~ $re ]] || echo "v5.3 does not match"
v5.3 does not match
$ echo "after the failed match: ${#BASH_REMATCH[@]} elements"
after the failed match: 0 elements
$ [[ v5.3.20 =~ "$re" ]] || echo "quoted: the pattern is literal text"
quoted: the pattern is literal text
$ [[ 'a.b' =~ a.b ]] && [[ 'axb' =~ a.b ]] && echo ". matches any character"
. matches any character
$ bash ifsc.sh ABCD0123456 ABCD1123456 abcd0123456 ABCD0A1B2C3 ABCD012345
ABCD0123456 valid shape (bank ABCD, branch 123456)
ABCD1123456 not an IFSC shape
abcd0123456 not an IFSC shape
ABCD0A1B2C3 valid shape (bank ABCD, branch A1B2C3)
ABCD012345 not an IFSC shape
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 regex_session.py
With re='^v([0-9]+)\.([0-9]+)\.([0-9]+)$', [[ v5.3.20 =~ $re ]] matched and printed major 5, minor 3, patch 20,
and v5.3 did not match, because the pattern needs three numbers. That failed test also emptied the array, which
held 0 elements afterwards: a match that fails throws away the groups of the last one that succeeded, so copy the
groups into variables of their own right after the match that set them. [[ v5.3.20 =~ "$re" ]] failed: the manual says
that quoting the variable makes the whole pattern match literally, so Bash looked for the characters of the pattern
itself. An unescaped . matches any character, which is why both a.b and axb matched a.b. A pattern that is
not a valid regular expression is a third outcome: the manual gives the test status 2 then, not 1. Step through the
version match, then the failed one:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- An anchored pattern. The pattern starts with ^ and ends with $, so it must match the whole string, not just a part of it. It has three groups in round brackets, one for each number; the box shortens each ([0-9]+) to (…).
- The first group. ^v matches the v at the start. The first group, [0-9]+, takes as many digits as it can before the dot, which is 5.
- The second group. \. matches a literal dot; without the backslash, . would match any character. The second group takes 3.
- The third group. After the second dot, the third group takes 20, and $ confirms that nothing is left over.
- The whole match. Element 0 holds the whole match, v5.3.20, and the session printed major 5, minor 3, patch 20 from elements 1 to 3.
- A string that does not match. For v5.3 the third group finds no dot and digits after 3, so the anchored pattern cannot match and the test returns 1. The failed test also empties BASH_REMATCH: the session counted 0 elements afterwards.
ifsc.sh uses the same tools for a shape check. In its NEFT questions and answers, the Reserve Bank of India gives the
IFSC as an 11-character code: the bank in the first four letters, 0 as the fifth character, and the branch in the last
six. The pattern
^[A-Z]{4}0[A-Z0-9]{6}$ accepted ABCD0123456 and ABCD0A1B2C3 and refused a fifth character of 1, lower-case
letters and a code one character short. It checks the format only; whether a branch exists needs a lookup, which
MySmartCoPilot’s IFSC code finder does, and the
regex tester helps build and try a pattern first.
Keep the pattern in a variable
re='^[0-9]+$'; [[ $x =~ $re ]] is easier to read and quote than writing the pattern inline, and the variable can be
reused by every test of the script.
Checkpoint
Check: =~ and BASH_REMATCH
Practise: patterns and regular expressions
The questions use the names, versions and codes of the sessions with new values. Decide what each test returns and what
BASH_REMATCH holds before you compare it with the recorded output:
Practice that adapts to you
Practise this lesson
9 questions about 3 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (2–5 years): how is [[ ]] different from [ ] in Bash?
[ ] is the test command, so the shell expands and splits its arguments first: unquoted empty or spaced values change
the test, and &&, ||, < and > would be read as shell operators. [[ ]] is a keyword that Bash parses itself:
no word splitting or filename expansion happens inside it, && and || combine tests, == matches the right side as a
glob pattern unless it is quoted, and =~ matches an extended regular expression with groups in BASH_REMATCH. The
cost is portability: POSIX does not define it and dash does not have it, so #!/bin/sh scripts use [ ] and case.
Two harder questions follow, about a filename check that stopped matching after someone added quotes and about a validation that accepts more than it should. Decide what you would check first, then open each first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A log rotation script had if [[ $file == app-.log ]]. After a team rule to "quote everything" was applied, it became if [[ $file == "app-.log" ]], and since then no file is rotated. What happened, and what is the right way to quote here?
-
Mid to senior
A form handler validates a pincode with re='[0-9]{6}' and [[ $pin =~ $re ]]. Testers report that 4000012, 560001x and abc560001 are all accepted. Explain why, fix the check, and say what the check still cannot tell you.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Why are values inside [[ ]] never split or globbed?
[[ ]] is a keyword that Bash parses itself, not a command whose arguments are expanded first. Inside it, && and || combine tests, and < and > compare text, not numbers.
-
What does the right side of == mean inside [[ ]]?
A glob pattern, so [[ $file == *.log ]] matches any name ending in .log. Quoting the right side, or a part of it, makes that part literal text.
-
Where in the string does [[ $s =~ regex ]] look for a match?
Anywhere, unless the expression is anchored with ^ and $. The right side is a POSIX extended regular expression.
-
Where do the capture groups of an =~ match end up?
In the array BASH_REMATCH: element 0 holds the whole match and elements 1 onwards the text of each group in round brackets.
-
Why does [[ $s =~ "$re" ]] not use re as a regular expression?
Quoting makes the right side literal text, so the whole pattern is matched as a plain string. Store the expression in a variable and leave the variable unquoted after =~.
-
What happens to [[ ]] in a script run by dash?
dash has no [[ ]], so the line fails, and ShellCheck reports it as SC3010 in sh scripts. Portable scripts keep to [ ] and case.
Key takeaways
[[ ]]is a Bash keyword: values inside it are not split or globbed,&&and||combine tests, and<and>compare text, not numbers.- After
==and!=the right side is a glob pattern, and quoting it, or part of it, makes that part literal. =~matches a POSIX extended regular expression anywhere in the string unless it is anchored, and the groups land inBASH_REMATCH[1]onwards; a quoted variable makes the whole pattern literal.- dash has no
[[ ]]and ShellCheck reports it as SC3010 in sh scripts, so portable scripts keep to[ ]andcase.
Terms in this lesson
- capture group
- A part of a regular expression in round brackets, such as ([0-9]+), whose matched text is saved; Bash puts the saved texts in BASH_REMATCH[1] onwards.
References
- Bash Reference Manual: Conditional Constructs (Chet Ramey (GNU Bash maintainer))
- Bash Reference Manual: Bash Conditional Expressions (Chet Ramey (GNU Bash maintainer))
- POSIX.1-2024: test (rationale on the double-bracket command) (The Open Group and IEEE)
- NEFT frequently asked questions (IFSC format) (Reserve Bank of India)
- ShellCheck wiki: SC3010 (ShellCheck)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress