Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 5 minutes
  • Examples run with Bash 5.3.20, dash 0.5.13.5, shellcheck 0.11.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 5 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Empty and spaced values, glob patterns, && inside, < and dash Python · brackets_session.py
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 ==

  1. Question 1 of 2 name='Asha Rao'. Which test is true in Bash and needs no quotes around $name?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: [[ $name == 'Asha Rao' ]]

    The session printed "one word, spaces and all" for the first form. Quote the right side only when you want a literal comparison.

  2. Question 2 of 2 f=data.csv. Which test is false?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: [[ $f == "*.csv" ]]

    Quoting the right side of == turns a pattern into text, as "report-*" did in the session.

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:

A version number taken apart, a quoted pattern, and IFSC shapes Python · regex_session.py
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:

Interactive · step by step How =~ fills BASH_REMATCH Step through the match of v5.3.20, then the failed match of v5.3. The string and the pattern are on the left, the array on the right.
How =~ fills BASH_REMATCH, step 6 of 6. Showing the string (v5.3, no match), the pattern in re (^v(…)\.(…)\.(…)$), BASH_REMATCH[0] (unset), BASH_REMATCH[1] (unset), BASH_REMATCH[2] (unset), BASH_REMATCH[3] (unset). In focus: the string, BASH_REMATCH[0], BASH_REMATCH[1], BASH_REMATCH[2], BASH_REMATCH[3].matched againstthe stringv5.3, no matchthe pattern in re^v(…)\.(…)\.(…)$BASH_REMATCH[0]unsetBASH_REMATCH[1]unsetBASH_REMATCH[2]unsetBASH_REMATCH[3]unset

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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 (…).
  2. 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.
  3. The second group. \. matches a literal dot; without the backslash, . would match any character. The second group takes 3.
  4. The third group. After the second dot, the third group takes 20, and $ confirms that nothing is left over.
  5. 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.
  6. 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

  1. Question 1 of 2 re='^([a-z]+)-([0-9]+)$'. After [[ build-42 =~ $re ]], what number is in BASH_REMATCH[2]?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 42

    Element 1 holds the first group, build, and element 2 the second, 42; element 0 is the whole match.

  2. Question 2 of 2 A script checks [[ $version =~ "$re" ]] with re='^v[0-9]+$', and v5 never matches. Why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A quoted variable makes the whole pattern literal, so Bash looks for the text ^v[0-9]+$ itself

    The session showed it: [[ v5.3.20 =~ "$re" ]] failed where the unquoted $re matched. Write [[ $version =~ $re ]].

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.

  1. Question 1 of 9 What status does dash -c '[[ 1 == 1 ]] && echo yes' end with?

    Type a number.

    Show the answer: Question 1 of 9

    Answer: 127

    dash has no [[ keyword, so it looks for a command called [[ and reports "[[: not found", status 127.

  2. Question 2 of 9 What status does [[ 9 < 10 ]] end with in Bash?

    Type a number.

    Show the answer: Question 2 of 9

    Answer: 1

    < compares text in double brackets, and 9 sorts after 10 as text, so the test is false. (( 9 < 10 )) compares the numbers.

  3. Question 3 of 9 Which line tests two conditions inside one test in Bash?

    Choose one answer.

    Show the answer: Question 3 of 9

    Answer: [[ -f notes.txt && $s == *.pdf ]]

    Two separate tests joined by && work too, [[ -f notes.txt ]] && [[ $s == *.pdf ]].

  4. Question 4 of 9 What does [[ $s == *.pdf ]] test?

    Choose one answer.

    Show the answer: Question 4 of 9

    Answer: Whether s ends with .pdf

    The manual reads the right side of == as a pattern, with the *, ? and [ ] of globs, and matches it against the string itself, never against the file names in a folder.

  5. Question 5 of 9 s=report-final.pdf. What does [[ $s == "report-"* ]] give?

    Choose one answer.

    Show the answer: Question 5 of 9

    Answer: True: the quoted part matches literally and the unquoted * is still a pattern

    Only report- is inside the quotes, so only those seven characters have to match as written; the * after the closing quote is still a wildcard and matches the rest of the name, final.pdf.

  6. Question 6 of 9 re='[0-9]+'. Is [[ abc123 =~ $re ]] true, and what is BASH_REMATCH[0]?

    Choose one answer.

    Show the answer: Question 6 of 9

    Answer: True, with BASH_REMATCH[0] = 123

    Anchor validation patterns with ^ and $, as the lesson's version and IFSC patterns do.

  7. Question 7 of 9 [[ v5.3.20 =~ $re ]] succeeded, then [[ v6 =~ $re ]] failed. What does echo "${BASH_REMATCH[1]}" print now?

    Choose one answer.

    Show the answer: Question 7 of 9

    Answer: An empty line, because the failed test emptied BASH_REMATCH

    Save what you need at once, for example major=${BASH_REMATCH[1]}, before the next =~ test runs.

  8. Question 8 of 9 With re='^[A-Z]{4}0[A-Z0-9]{6}$', which code has a valid IFSC shape?

    Choose one answer.

    Show the answer: Question 8 of 9

    Answer: WXYZ0XYZ123

    The pattern follows the RBI's description of the format; whether the branch exists needs a lookup, not a pattern.

  9. Question 9 of 9 Why does the lesson keep regular expressions in a variable, as in re='^v([0-9]+)$'; [[ $x =~ $re ]]?

    Choose one answer.

    Show the answer: Question 9 of 9

    Answer: Single quotes protect the pattern's backslashes and brackets, and the unquoted $re is still read as a regular expression

    The Bash manual itself calls storing the regular expression in a shell variable a useful way to avoid problems with characters that are special to the shell.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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 =~.

  6. 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 in BASH_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 [ ] and case.

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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.