| |

Bash Scripting 21 ๐Ÿง Regular Expressions โ€” Pattern matching with grep and Bash regex

โฑ๏ธ Estimated read: 12 min | |

Regular expressions are the formal language for describing text patterns. Bash uses them in two distinct places: the =~ operator inside [[ ]] for conditional matching, and the grep command for searching files and streams. The two share a common syntax but differ in important waysโ€”quoting behavior, what they return, and which regex dialect they speak. A pattern that works in grep -E may behave differently in Bash’s =~, and a pattern that works unquoted in Bash may become a literal string if quoted incorrectly.

This chapter covers the practical use of regular expressions in Bash scripts: the =~ operator and BASH_REMATCH, the grep command and its -E flag, the POSIX character classes that replace locale-dependent ranges like [a-z], and the quoting rules that determine whether a pattern is interpreted as a regex or as a literal string. By the end, you will be able to write patterns that match reliably across locales and across both tools.

Key point: In Bash 3.2 and later, quoting the right-hand side of =~ forces literal string matching. An unquoted pattern is a regex; a quoted pattern is a fixed string. This is the opposite of what many scripts assume.


Why Bash regex has these quirks

The quoting change in Bash 3.2. Before Bash 3.2, quoting the pattern argument to =~ had unspecified behavior. Bash 3.2 adopted the convention used by other pattern-matching operators: any quoted portion of the right-hand side is treated as a literal string rather than a regular expression . This means [[ $x =~ "abc" ]] checks whether $x contains the literal substring abc, not whether it matches the regex abc. For simple patterns the result is the same, but for patterns containing metacharacters, the difference is critical.

The variable-holding-pattern convention. Because quoting the pattern changes its meaning, the recommended practice is to store the pattern in a variable and expand it unquoted. The GNU Bash FAQ explains that this solves two problems at once: it preserves the pattern’s regex meaning and it allows patterns containing whitespace, since word splitting is not performed on variable expansions inside [[ ]] . A pattern like ^[a-z]+ [0-9]+$ contains a space; assigning it to a variable and using $pattern unquoted works, while writing it inline requires escaping the space.

The locale problem with [a-z]. The range [a-z] means “characters between a and z in the current collation order.” In some locales, that range includes characters other than the 26 lowercase ASCII letters, or excludes some of them. The Bash maintainers recommend [[:lower:]] instead, which matches the locale’s definition of a lowercase letter regardless of collation order . The same advice applies to grep, where POSIX character classes are the portable choice .

The grep dialect problem. grep understands basic regular expressions (BRE) by default and extended regular expressions (ERE) with -E. In GNU grep, BRE and ERE have the same functionality, but the metacharacters differ: +, ?, |, (, and ) are special in ERE and must be backslash-escaped in BRE . Bash’s =~ uses ERE syntax. A pattern written for grep -E is usually compatible with =~, but a pattern written for plain grep may not be.


a. The =~ operator and BASH_REMATCH

The =~ operator is used inside [[ ]]. It returns success (exit status 0) if the string on the left matches the regular expression on the right.

string="abc123"
if [[ "$string" =~ [a-z]+[0-9]+ ]]; then
    echo "Letters followed by numbers"   # prints
fi

When the pattern contains parentheses for grouping, the matched substrings are stored in the BASH_REMATCH array. Index 0 holds the entire match; index 1 holds the first group, index 2 the second, and so on .

key="abc=value1"
pattern='abc=(.*)'
if [[ "$key" =~ $pattern ]]; then
    echo "${BASH_REMATCH[1]}"   # prints value1
fi

The pattern is stored in a variable and expanded unquoted. Writing [[ "$key" =~ "abc=(.*)" ]] with quotes would match the literal string abc=(.*) instead of capturing value1 .

b. grep and the -E flag

grep searches files or standard input for lines matching a pattern. By default it uses BRE. The -E flag switches to ERE, which is the same dialect Bash’s =~ uses.

# Search a file for lines containing a number
grep -E "[0-9]+" file.txt

# Search command output
echo "This is an example of 123." | grep -E "[0-9]+"

The grep -E output is whole lines that match, not just the matching portion. BASH_REMATCH stores the matching portion and its groups; grep prints the entire line . This is a fundamental difference between the two tools and a common source of confusion when translating a pattern from one to the other.

For patterns that need Perl-compatible syntax, GNU grep offers -P, but this is not portable and is outside the scope of this chapter. For Bash scripts, -E is the portable choice.

c. POSIX character classes

POSIX character classes are enclosed in brackets and used inside a bracket expression: [[:digit:]], [[:alpha:]], [[:alnum:]], [[:space:]], [[:lower:]], [[:upper:]], and others . They are locale-aware and portable.

# Match a single digit
[[ "$char" =~ [[:digit:]] ]]

# Match one or more letters
[[ "$word" =~ [[:alpha:]]+ ]]

# grep equivalent
grep -E "[[:digit:]]+" file.txt

The class name includes its own brackets. [[:digit:]] is a bracket expression containing the class [:digit:]. Writing [[:digit:]]+ means “one or more digits” because the + applies to the whole bracket expression.

The alternative [0-9] works for ASCII digits in the C locale, but [[:digit:]] is the documented portable form. For letters, [a-z] is actively discouraged in favor of [[:lower:]] because the range depends on collation order .

d. Quoting rules and pattern variables

The quoting rules for =~ are the single most important detail in Bash regex usage. The rule is: an unquoted pattern is a regex; any quoted portion is a literal string.

# Unquoted pattern: regex
[[ "abc123" =~ [a-z]+ ]] && echo "regex match"

# Quoted pattern: literal string
[[ "abc123" =~ "[a-z]+" ]] || echo "literal, does not match"

The second test fails because [a-z]+ is treated as the literal characters [, a, -, z, ], +, not as a regex. The string abc123 does not contain that literal sequence.

The variable convention preserves the regex:

pattern='[a-z]+'
[[ "abc123" =~ $pattern ]] && echo "regex match"   # unquoted expansion

If the pattern contains spaces, the variable convention is mandatory:

pattern='^[a-z]+ [0-9]+$'
[[ "abc 123" =~ $pattern ]] && echo "match"

Writing the pattern inline would require escaping the space: [[ "abc 123" =~ ^[a-z]+\ [0-9]+$ ]]. The variable form is cleaner and less error-prone.


Complete Example Session

# ============================================
# PART 1: BASIC =~ MATCH
# ============================================
string="abc123"

if [[ "$string" =~ [a-z]+[0-9]+ ]]; then
    echo "Match: letters followed by numbers"
fi

# Unquoted pattern is a regex.
# ============================================
# PART 2: QUOTING CHANGES THE MEANING
# ============================================
# Unquoted: regex
[[ "abc123" =~ [a-z]+ ]] && echo "regex match"

# Quoted: literal string
[[ "abc123" =~ "[a-z]+" ]] || echo "literal, no match"

# The second test fails because the brackets are literal.
# ============================================
# PART 3: USING A PATTERN VARIABLE
# ============================================
pattern='[a-z]+'
[[ "abc123" =~ $pattern ]] && echo "regex match"

# Variable expansion unquoted preserves regex meaning.
# This is the recommended convention.
# ============================================
# PART 4: CAPTURING WITH BASH_REMATCH
# ============================================
key="abc=value1"
pattern='abc=(.*)'

if [[ "$key" =~ $pattern ]]; then
    echo "Captured: ${BASH_REMATCH[1]}"
fi

# BASH_REMATCH[0] = entire match (abc=value1)
# BASH_REMATCH[1] = first group (value1)
# ============================================
# PART 5: MULTIPLE GROUPS
# ============================================
line="user:alice:1000"
pattern='^([^:]+):([^:]+):([0-9]+)$'

if [[ "$line" =~ $pattern ]]; then
    echo "Field 1: ${BASH_REMATCH[1]}"   # user
    echo "Field 2: ${BASH_REMATCH[2]}"   # alice
    echo "Field 3: ${BASH_REMATCH[3]}"   # 1000
fi
# ============================================
# PART 6: GREP WITH -E
# ============================================
# Create a test file
cat > /tmp/test.txt <<'EOF'
line 1: hello
line 2: world
line 3: 12345
EOF

# Search for lines containing digits
grep -E "[0-9]+" /tmp/test.txt
# Output: line 3: 12345

# grep prints the whole matching line.
# ============================================
# PART 7: GREP AND PIPES
# ============================================
echo "This is an example of 123." | grep -E "[0-9]+"
# Output: This is an example of 123.

# The whole line is printed, not just "123".
# ============================================
# PART 8: POSIX CHARACTER CLASSES
# ============================================
# Instead of [0-9]
[[ "5" =~ [[:digit:]] ]] && echo "digit"

# Instead of [a-z]
[[ "x" =~ [[:lower:]] ]] && echo "lowercase"

# grep equivalent
grep -E "[[:digit:]]+" /tmp/test.txt
# ============================================
# PART 9: THE LOCALE RANGE PROBLEM
# ============================================
# [a-z] depends on collation order.
# In some locales, it may not match only ASCII a-z.
# Use [[:lower:]] for portability.

# Demonstrate with a non-ASCII character if available
# (result depends on locale)
[[ "รฉ" =~ [a-z] ]] && echo "matched by [a-z]" || echo "not matched"

# The safe form:
[[ "รฉ" =~ [[:lower:]] ]] && echo "matched by [[:lower:]]"
# ============================================
# PART 10: TRANSLATING BETWEEN GREP AND BASH
# ============================================
# grep -E pattern file
# [[ string =~ pattern ]]

# Same ERE syntax, different output:
# grep: whole matching line
# =~: exit status, matching portion in BASH_REMATCH

# Example:
# grep -E "^[[:digit:]]+$" file.txt
# [[ "$line" =~ ^[[:digit:]]+$ ]]

The ten parts covered basic matching, quoting behavior, pattern variables, BASH_REMATCH captures, multiple groups, grep -E, piping to grep, POSIX classes, the locale range problem, and translating between the two tools.


Quick Reference

Bash =~ versus grep

Feature[[ =~ ]]grep -E
Regex dialectEREERE
OutputExit status + BASH_REMATCHMatching lines
InputString variableFiles or stdin
QuotingQuoted portions are literalShell quoting separate
GroupsBASH_REMATCH[n]Not captured

Quoting rules for =~

FormInterpretation
[[ $x =~ [a-z]+ ]]Regex
[[ $x =~ "[a-z]+" ]]Literal string
pattern='[a-z]+'; [[ $x =~ $pattern ]]Regex
[[ $x =~ $pattern ]] with spaces in patternRegex, no word splitting

POSIX character classes

ClassMatches
[[:digit:]]Digits
[[:alpha:]]Letters
[[:alnum:]]Letters and digits
[[:lower:]]Lowercase letters
[[:upper:]]Uppercase letters
[[:space:]]Whitespace
[[:punct:]]Punctuation

BASH_REMATCH indices

IndexContents
0Entire match
1First group (...)
2Second group
nnth group

Best Practices

โœ… Do This:

# Store patterns in variables and expand unquoted
pattern='[a-z]+'
[[ "$x" =~ $pattern ]]                                    # โœ…
# Use POSIX classes instead of ranges
[[ "$x" =~ [[:lower:]] ]]                                 # โœ…
# Use grep -E for ERE syntax
grep -E "[[:digit:]]+" file.txt                           # โœ…
# Use BASH_REMATCH for captures in Bash
[[ "$x" =~ ([0-9]+) ]] && echo "${BASH_REMATCH[1]}"       # โœ…

โŒ Don’t Do This:

# Quote the pattern and expect regex
[[ "$x" =~ "[a-z]+" ]]                                    # โŒ (literal)
# Use [a-z] for portable lowercase matching
[[ "$x" =~ [a-z] ]]                                       # โŒ (locale-dependent)
# Use plain grep for ERE metacharacters
grep "[0-9]+" file.txt                                    # โŒ (BRE, + is literal)
# Assume grep prints the match
# grep prints the whole line                               # โŒ

Common Pitfalls

PitfallWhy It HappensFix
Quoted pattern does not matchBash 3.2+ treats quoted pattern as literalUse a variable and expand unquoted
[a-z] matches unexpected charactersLocale collation orderUse [[:lower:]]
grep "[0-9]+" matches literal +Default BRE, + not specialUse grep -E
Pattern with spaces fails inlineWord splitting in [[ ]]Store in variable, expand unquoted
BASH_REMATCH[1] emptyPattern has no capture groupAdd parentheses around the portion to capture
grep prints whole linegrep semanticsUse grep -o for only the match, or BASH_REMATCH in Bash

Real-World Examples

1. Validate a number in Bash

[[ "$input" =~ ^[[:digit:]]+$ ]] && echo "valid"

2. Capture a key-value pair

pattern='^([^=]+)=(.+)$'
[[ "$line" =~ $pattern ]] && echo "Key: ${BASH_REMATCH[1]}"

3. Extract an IP address

pattern='([0-9]{1,3}\.){3}[0-9]{1,3}'
[[ "$text" =~ $pattern ]] && echo "${BASH_REMATCH[0]}"

4. Search a log for errors

grep -E "(error|fail|critical)" /var/log/syslog

5. Filter digits from a command

echo "abc123" | grep -E "[[:digit:]]+"

6. Match whitespace-separated fields

pattern='^[[:alnum:]]+[[:space:]]+[[:digit:]]+$'
[[ "user 123" =~ $pattern ]] && echo "match"

7. Use a variable for a complex pattern

pattern='^([[:alpha:]]+):([[:digit:]]+)$'
[[ "alice:42" =~ $pattern ]] && echo "Name: ${BASH_REMATCH[1]}"

8. Count matching lines with grep

grep -cE "^[[:digit:]]+$" file.txt

9. Negate a match in Bash

if [[ ! "$input" =~ ^[[:digit:]]+$ ]]; then
    echo "not a number"
fi

10. Extract only the matching portion with grep

echo "abc123def" | grep -oE "[0-9]+"
# Output: 123

Visual

Quoting and =~

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  QUOTING THE PATTERN                                         โ”‚
โ”‚                                                              โ”‚
โ”‚  [[ "abc123" =~ [a-z]+ ]]                                    โ”‚
โ”‚       โ”‚         โ”‚                                            โ”‚
โ”‚       โ”‚         โ””โ”€โ”€ unquoted: REGEX                          โ”‚
โ”‚       โ”‚                                                      โ”‚
โ”‚       โ””โ”€โ”€ matches: "abc" is letters                          โ”‚
โ”‚       Result: exit 0 (success)                               โ”‚
โ”‚                                                              โ”‚
โ”‚  [[ "abc123" =~ "[a-z]+" ]]                                  โ”‚
โ”‚       โ”‚         โ”‚                                            โ”‚
โ”‚       โ”‚         โ””โ”€โ”€ quoted: LITERAL                          โ”‚
โ”‚       โ”‚                                                      โ”‚
โ”‚       โ””โ”€โ”€ looks for the literal string [a-z]+               โ”‚
โ”‚       Result: exit 1 (no match)                              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

=~ versus grep output

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  SAME PATTERN, DIFFERENT OUTPUT                              โ”‚
โ”‚                                                              โ”‚
โ”‚  Input: "line 3: 12345"                                      โ”‚
โ”‚  Pattern: [0-9]+                                             โ”‚
โ”‚                                                              โ”‚
โ”‚  grep -E:                                                    โ”‚
โ”‚    Prints the whole matching line:                           โ”‚
โ”‚    "line 3: 12345"                                           โ”‚
โ”‚                                                              โ”‚
โ”‚  [[ =~ ]]:                                                   โ”‚
โ”‚    Sets BASH_REMATCH[0] to the matching portion:             โ”‚
โ”‚    "12345"                                                   โ”‚
โ”‚    Does not print anything.                                  โ”‚
โ”‚                                                              โ”‚
โ”‚  grep gives lines. =~ gives the match.                       โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

POSIX class vs range

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  [a-z] VERSUS [[:lower:]]                                    โ”‚
โ”‚                                                              โ”‚
โ”‚  [a-z]                                                       โ”‚
โ”‚    Depends on locale collation order.                        โ”‚
โ”‚    In some locales, may match characters outside ASCII.      โ”‚
โ”‚    May exclude some ASCII lowercase letters.                 โ”‚
โ”‚                                                              โ”‚
โ”‚  [[:lower:]]                                                 โ”‚
โ”‚    Matches the locale's definition of lowercase.             โ”‚
โ”‚    Portable across locales.                                  โ”‚
โ”‚    Recommended by Bash maintainers.                          โ”‚
โ”‚                                                              โ”‚
โ”‚  Use [[:lower:]] when correctness matters.                   โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

BASH_REMATCH structure

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  BASH_REMATCH AFTER MATCH                                    โ”‚
โ”‚                                                              โ”‚
โ”‚  String: "abc=value1"                                        โ”‚
โ”‚  Pattern: 'abc=(.*)'                                         โ”‚
โ”‚                                                              โ”‚
โ”‚  BASH_REMATCH[0] = "abc=value1"  (entire match)             โ”‚
โ”‚  BASH_REMATCH[1] = "value1"       (first group)             โ”‚
โ”‚                                                              โ”‚
โ”‚  Groups are created by parentheses in the pattern.           โ”‚
โ”‚  Index 0 is always the full match.                           โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Summary

ItemValue
Bash regex operator=~ inside [[ ]]
Regex dialect in BashPOSIX ERE
Capture arrayBASH_REMATCH
Quoted pattern behaviorLiteral string (Bash 3.2+)
Recommended pattern formVariable, expanded unquoted
grep ERE flag-E
grep default dialectBRE
Portable lowercase class[[:lower:]]
Portable digit class[[:digit:]]
grep outputWhole matching lines
=~ outputExit status + BASH_REMATCH

Key takeaways:

  • Quoting changes the meaning of =~ in Bash 3.2 and later. An unquoted pattern is a regex; a quoted pattern is a literal string. Store patterns in variables and expand them unquoted to preserve regex behavior and avoid word-splitting issues .
  • grep -E and =~ use the same ERE dialect but produce different output. grep prints whole lines; =~ sets an exit status and populates BASH_REMATCH with the matching portion and its groups .
  • POSIX character classes are the portable choice. [[:lower:]] and [[:digit:]] work regardless of locale, while [a-z] and [0-9] depend on collation order and character encoding .
  • Plain grep uses BRE, not ERE. The metacharacters +, ?, |, (, and ) are not special in BRE unless backslash-escaped. Use -E when writing ERE patterns .
  • BASH_REMATCH[0] is the full match; indices 1 and up are capture groups. Parentheses in the pattern create the groups. Without parentheses, only index 0 is populated.
  • Patterns containing whitespace must be stored in variables. Word splitting does not occur on variable expansions inside [[ ]], so a pattern variable can contain spaces without escaping them inline .

Remember: Bash regex is a tool with two faces: the =~ operator for in-script conditional matching, and grep for searching streams and files. They share ERE syntax but differ in quoting rules and output. The three mistakes that cause the most trouble are quoting the =~ pattern (which turns it into a literal string), using [a-z] where [[:lower:]] is required for locale safety, and forgetting -E when passing ERE metacharacters to grep. Store patterns in variables, use POSIX classes, and remember that grep prints lines while BASH_REMATCH holds matches.

Was this helpful?


Stop using slow, ad-bloated tool sites! ๐Ÿคฎ

๐Ÿ”Ž Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
โœ… Finance (Mortgage, Interest, Inflation)
โœ… Tech (Base64, JSON, Dev Suite, IP)
โœ… Health (BMI, BMR, TDEE)
โœ… Productivity (Timer, Workspace, QR)

โšก๏ธ Fast & Private
๐Ÿ”’ No data leaves your device
๐Ÿ’Ž 100% Free

๐Ÿ”— Use it now: https://tools.kandz.me
๐Ÿ”– Bookmark itโ€”youโ€™ll need it later!