Foundations Updated 2026-09 View as Markdown

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.

shell
if [[ "$email" =~ ^[^@]+@[^@]+\.[a-z]{2,}$ ]]; then
  echo "looks like an email"
fi

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

shell
[[ "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.

shell
re='^([0-9]{4})-([0-9]{2})-([0-9]{2})$'
if [[ "$date" =~ $re ]]; then      # unquoted variable, still a regex
  echo "valid date"
fi

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

shell
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"
fi
2026-09-04 | ERROR | db timeout after 30s

What ERE gives you#

Bash uses POSIX ERE, not PCRE. The everyday syntax is the same; a few conveniences are missing.

WorksMeaning
. * + ?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.

shell
# these are equivalent, and the second one works everywhere
[[ "$s" =~ ^[0-9]+$ ]]
[[ "$s" =~ ^[[:digit:]]+$ ]]

Practical patterns#

shell
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"; fi

Wrapping 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:

shell
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 field

Use -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:

shell
case "$file" in
  *.tar.gz|*.tgz) echo "tarball" ;;
esac

if printf '%s' "$s" | grep -qE '^[0-9]+$'; then echo numeric; fi

And 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#

shell
#!/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 here

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

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.