# Regular Expressions

> Source: https://learn-bash.net/regular-expressions/
> Part of Learn Bash, free to read.

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.

```bash
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.

```bash
[[ "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.

```bash
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.

```bash
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
```

```text
2026-09-04 | ERROR | db timeout after 30s
```

:::warn BASH_REMATCH is global and it survives
It is overwritten by the next successful match and left untouched by a failed one — so reading it after a match that did not succeed gives you stale values from an earlier one. Always read it inside the `if`.
:::

## What 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`.

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

## Practical patterns

```bash
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:

```bash
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.

:::danger Two things regex should never parse
**JSON and YAML.** Use `jq` and `yq`. A regex that extracts a field from JSON works until the value contains a brace, an escaped quote, or a newline — and then it silently returns the wrong thing.

**HTML.** Same reasoning, more emphatically.

Generated shell reaches for `grep` on JSON constantly because there is a lot of it in the training data. It is always worth replacing.
:::

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

```bash
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

```bash
#!/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.
