Regular Expressions
Bash has a regex operator built in, and it is better than piping to grep for most jobs — once you know the two quoting rules that trip everyone up.
Bash has a regex match operator, =~, inside [[ ]]. It uses POSIX Extended Regular Expressions and it is faster and clearer than spawning grep for a single test.
if [[ "$email" =~ ^[^@]+@[^@]+\.[a-z]{2,}$ ]]; then
echo "looks like an email"
fiThe two rules that cause every bug#
1. Do not quote the pattern. A quoted right-hand side is matched as a literal string, not a regex.
[[ "abc123" =~ [0-9]+ ]] # true — pattern is a regex
[[ "abc123" =~ "[0-9]+" ]] # FALSE — looks for the literal text [0-9]+This is the single most common =~ mistake, and it fails silently: the condition is simply never true.
2. Put a complex pattern in a variable. It avoids escaping wars and keeps the pattern readable.
re='^([0-9]{4})-([0-9]{2})-([0-9]{2})$'
if [[ "$date" =~ $re ]]; then # unquoted variable, still a regex
echo "valid date"
fiNote the variable is used unquoted too. Quoting it makes it a literal again.
Capture groups#
After a successful match, BASH_REMATCH holds the whole match at index 0 and each group after it.
log='2026-09-04T14:22:01Z ERROR db timeout after 30s'
re='^([0-9-]+)T([0-9:]+)Z ([A-Z]+) (.*)$'
if [[ "$log" =~ $re ]]; then
date="${BASH_REMATCH[1]}"
time="${BASH_REMATCH[2]}"
level="${BASH_REMATCH[3]}"
message="${BASH_REMATCH[4]}"
printf '%s | %-5s | %s\n' "$date" "$level" "$message"
fi2026-09-04 | ERROR | db timeout after 30sWhat ERE gives you#
Bash uses POSIX ERE, not PCRE. The everyday syntax is the same; a few conveniences are missing.
| Works | Meaning | |
|---|---|---|
. * + ? | any char, zero-or-more, one-or-more, optional | |
^ $ | anchors | |
[abc] [^abc] [a-z] | character classes | |
| `(a\ | b)` | alternation and grouping |
{2,5} | repetition count | |
[[:digit:]] [[:alpha:]] [[:space:]] | POSIX classes |
Not available: \d, \w, \s, lookahead, lookbehind, non-greedy *?, named groups. Use [[:digit:]] instead of \d — it is also the portable spelling in grep -E, sed -E and awk.
# these are equivalent, and the second one works everywhere
[[ "$s" =~ ^[0-9]+$ ]]
[[ "$s" =~ ^[[:digit:]]+$ ]]Practical patterns#
is_int() { [[ "$1" =~ ^-?[0-9]+$ ]]; }
is_semver(){ [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; }
is_ipv4() { local o='(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])'
[[ "$1" =~ ^$o\.$o\.$o\.$o$ ]]; }
if is_int "${count:-}"; then echo "count is a number"; fiWrapping the test in a function that returns its exit status is the idiomatic way to make these reusable — is_int "$x" reads well in a condition.
When not to use =~#
Bash's regex is for testing one string. The moment you are processing a stream, reach for the right tool:
grep -E 'ERROR|FATAL' app.log # filter lines
grep -oE '[0-9]{1,3}(\.[0-9]{1,3}){3}' f # extract every match
sed -E 's/([0-9]{4})-([0-9]{2})/\2\/\1/' # substitute
awk '$3 ~ /ERROR/ { print $1, $4 }' app.log # match a specific fieldUse -E with grep and sed so the syntax matches what you already wrote for =~. Without it you are in Basic Regular Expressions, where +, ?, | and () all need backslashes — a needless second dialect to hold in your head.
Portability#
=~ is a bash feature and needs #!/usr/bin/env bash, not #!/bin/sh. If you must be POSIX, use case for globs or grep -qE for regex:
case "$file" in
*.tar.gz|*.tgz) echo "tarball" ;;
esac
if printf '%s' "$s" | grep -qE '^[0-9]+$'; then echo numeric; fiAnd a note on catastrophic backtracking: a pattern with nested quantifiers like (a+)+$ can take exponential time on adversarial input. If you are matching data from outside your control, keep patterns flat and anchored.
Exercise#
#!/bin/bash
# Extract the timestamp, level and message from each line and print them as
# LEVEL timestamp message
# Skip any line that does not match the format.
set -euo pipefail
LINES=(
"2026-09-04T14:22:01Z ERROR db timeout after 30s"
"not a log line at all"
"2026-09-04T14:22:09Z WARN retrying connection"
"2026-09-04T14:23:00Z INFO connected"
)
# write your code hereCommon questions#
Why does my pattern work in grep but not in [[ =~ ]]?#
Almost always quoting. A quoted right-hand side is matched literally, so [[ "$s" =~ "^[0-9]+$" ]] looks for that exact text. Leave the pattern unquoted, or put it in a variable and use the variable unquoted.
Can I use \d and \w?#
No — those are PCRE, and Bash uses POSIX ERE. Use [[:digit:]] and [[:alnum:]_]. The POSIX classes have the advantage of working identically in grep -E, sed -E and awk.
How do I do a case-insensitive match?#
shopt -s nocasematch before the test and shopt -u nocasematch after — it is a shell option, not a flag on the operator. For a one-off, a character class like [Ee][Rr][Rr][Oo][Rr] is uglier but has no global side effect.
Get the Bash agent pack
A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for Bash. One email, then occasional updates when the tooling shifts. No course pitch.
AGENTS.md now — no email needed.