Bash Scripting 21 ๐ง Regular Expressions โ Pattern matching with grep and Bash regex
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 dialect | ERE | ERE |
| Output | Exit status + BASH_REMATCH | Matching lines |
| Input | String variable | Files or stdin |
| Quoting | Quoted portions are literal | Shell quoting separate |
| Groups | BASH_REMATCH[n] | Not captured |
Quoting rules for =~
| Form | Interpretation |
|---|---|
[[ $x =~ [a-z]+ ]] | Regex |
[[ $x =~ "[a-z]+" ]] | Literal string |
pattern='[a-z]+'; [[ $x =~ $pattern ]] | Regex |
[[ $x =~ $pattern ]] with spaces in pattern | Regex, no word splitting |
POSIX character classes
| Class | Matches |
|---|---|
[[:digit:]] | Digits |
[[:alpha:]] | Letters |
[[:alnum:]] | Letters and digits |
[[:lower:]] | Lowercase letters |
[[:upper:]] | Uppercase letters |
[[:space:]] | Whitespace |
[[:punct:]] | Punctuation |
BASH_REMATCH indices
| Index | Contents |
|---|---|
| 0 | Entire match |
| 1 | First group (...) |
| 2 | Second group |
| n | nth 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Quoted pattern does not match | Bash 3.2+ treats quoted pattern as literal | Use a variable and expand unquoted |
[a-z] matches unexpected characters | Locale collation order | Use [[:lower:]] |
grep "[0-9]+" matches literal + | Default BRE, + not special | Use grep -E |
| Pattern with spaces fails inline | Word splitting in [[ ]] | Store in variable, expand unquoted |
BASH_REMATCH[1] empty | Pattern has no capture group | Add parentheses around the portion to capture |
grep prints whole line | grep semantics | Use 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
| Item | Value |
|---|---|
| Bash regex operator | =~ inside [[ ]] |
| Regex dialect in Bash | POSIX ERE |
| Capture array | BASH_REMATCH |
| Quoted pattern behavior | Literal string (Bash 3.2+) |
| Recommended pattern form | Variable, expanded unquoted |
| grep ERE flag | -E |
| grep default dialect | BRE |
| Portable lowercase class | [[:lower:]] |
| Portable digit class | [[:digit:]] |
| grep output | Whole matching lines |
=~ output | Exit 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 -Eand=~use the same ERE dialect but produce different output.grepprints whole lines;=~sets an exit status and populatesBASH_REMATCHwith 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
grepuses BRE, not ERE. The metacharacters+,?,|,(, and)are not special in BRE unless backslash-escaped. Use-Ewhen 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.
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!