Regular Expressions in Bash
Chapter 10 — Regex in Bash: [[ =~ ]] and BASH_REMATCH
Native Regex in the Shell
Since Bash 3.2, the [[ ]] compound command supports a =~ operator for testing a string against a regular expression — without spawning a subprocess. The result is stored in the special array BASH_REMATCH, giving you capture groups directly in shell variables. This is the right tool when you need to validate or parse a value inside a script without the overhead of calling grep, sed, or awk.
[[ string =~ regex ]]
string to test
ERE pattern — unquoted
- Returns exit code 0 (true) if the string contains a match; non-zero otherwise
- The regex is ERE — the same syntax as
grep -Eandawk - No subprocess — handled entirely by the shell; faster than any external tool for single-value tests
- Populates
BASH_REMATCHon a successful match
# Basic test: does the string contain a pattern? input="hello world" if [[ $input =~ world ]]; then echo "matched" fi # Anchored: entire string must be a number if [[ $input =~ ^[0-9]+$ ]]; then echo "is an integer" fi # Negated: ! in front of [[ ]] if ! [[ $input =~ ^[0-9]+$ ]]; then echo "not an integer" fi # ERE features work directly if [[ $input =~ ^(foo|bar)$ ]]; then echo "is foo or bar" fi
The Quoting Rule — Critical
Do NOT quote the regex
The regex must be unquoted (or stored in a variable). Quoting it with
'...' or "..." turns it into a literal string comparison — no special characters are interpreted.
DO quote the string
The left-hand side (the value being tested) must be quoted if it can contain spaces or shell metacharacters:
[[ "$var" =~ regex ]]. An unquoted variable with spaces will cause a syntax error.
Store complex regex in a variable
If your pattern contains spaces or shell-special characters, store it in a variable first and use the variable unquoted on the right-hand side:
re='foo bar'; [[ $x =~ $re ]]
The Bash 3.1 exception
Bash 3.1 required the regex to be quoted. Bash 3.2+ changed this. For maximum compatibility write patterns in a variable — it works in all versions.
# WRONG: quoted regex becomes a literal string match if [[ $date =~ "^[0-9]{4}-[0-9]{2}" ]]; then # won't work as regex # CORRECT: unquoted regex if [[ $date =~ ^[0-9]{4}-[0-9]{2} ]]; then # CORRECT: variable holds the regex — unquoted on RHS re='^[0-9]{4}-[0-9]{2}' if [[ $date =~ $re ]]; then # CORRECT: quoted LHS protects value with spaces input="hello world" if [[ "$input" =~ ^hello ]]; then
BASH_REMATCH — Reading Capture Groups
After a successful =~ match, Bash populates the read-only array BASH_REMATCH:
Pattern: ^([0-9]{4})-([0-9]{2})-([0-9]{2})$ Input: "2026-06-11"
BASH_REMATCH[0] = "2026-06-11" entire match
BASH_REMATCH[1] = "2026" capture group 1: year
BASH_REMATCH[2] = "06" capture group 2: month
BASH_REMATCH[3] = "11" capture group 3: day
# Extract date components date="2026-06-11" if [[ $date =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})$ ]]; then year="${BASH_REMATCH[1]}" month="${BASH_REMATCH[2]}" day="${BASH_REMATCH[3]}" echo "Day: $day Month: $month Year: $year" fi Day: 11 Month: 06 Year: 2026 # Parse a "key=value" string line="timeout=30" if [[ $line =~ ^([a-z_]+)=(.+)$ ]]; then key="${BASH_REMATCH[1]}" val="${BASH_REMATCH[2]}" echo "$key → $val" fi # Parse a URL: scheme, host, path url="https://api.example.com/v2/users" re='^(https?)://([^/]+)(/.*)$' if [[ $url =~ $re ]]; then echo "scheme: ${BASH_REMATCH[1]}" echo "host: ${BASH_REMATCH[2]}" echo "path: ${BASH_REMATCH[3]}" fi # BASH_REMATCH is overwritten by every =~ test — save values immediately if [[ $line =~ ^([0-9]+) ]]; then number="${BASH_REMATCH[1]}" # save BEFORE the next [[ ]] test fi
Validation Function Library
The most common use of [[ =~ ]] is input validation. Here are battle-tested patterns as reusable shell functions. Source this file in your scripts with source validate.sh.
is_integer
Whole number, optional leading minus
^-?[0-9]+$
is_positive_int
Positive integer, no sign, no zero-pad
^[1-9][0-9]*$
is_float
Decimal number, optional sign and fraction
^-?[0-9]+(\.[0-9]+)?$
is_ipv4
Four 1–3 digit octets separated by dots
^([0-9]{1,3}\.){3}[0-9]{1,3}$
is_email
Practical email format (not RFC-complete)
^[[:alnum:]._%+-]+@[[:alnum:].-]+\.[[:alpha:]]{2,}$
is_iso_date
ISO 8601 date YYYY-MM-DD
^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$
is_hostname
RFC 1123 hostname label rules
^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$
is_semver
Semantic version MAJOR.MINOR.PATCH
^[0-9]+\.[0-9]+\.[0-9]+$
#!/usr/bin/env bash # validate.sh — reusable validation functions using [[ =~ ]] is_integer() { [[ "$1" =~ ^-?[0-9]+$ ]] } is_positive_int() { [[ "$1" =~ ^[1-9][0-9]*$ ]] } is_float() { [[ "$1" =~ ^-?[0-9]+(\.[0-9]+)?$ ]] } is_ipv4() { local re='^([0-9]{1,3}\.){3}[0-9]{1,3}$' [[ "$1" =~ $re ]] || return 1 # also verify each octet is 0-255 local IFS='.' local octet for octet in $1; do (( octet >= 0 && octet <= 255 )) || return 1 done } is_email() { local re='^[[:alnum:]._%+-]+@[[:alnum:].-]+\.[[:alpha:]]{2,}$' [[ "$1" =~ $re ]] } is_iso_date() { local re='^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$' [[ "$1" =~ $re ]] } is_semver() { [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] } is_hex_colour() { [[ "$1" =~ ^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$ ]] } is_slug() { # URL-safe slug: lowercase letters, digits, hyphens [[ "$1" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]] }
Using the Validation Library
# Source and use in a script source validate.sh # Validate script arguments validate_args() { local port="$1" host="$2" if ! is_positive_int "$port" || ((port > 65535)); then echo "Error: '$port' is not a valid port number" >&2 return 1 fi if ! is_ipv4 "$host" && ! is_hostname "$host" 2>/dev/null; then echo "Error: '$host' is not a valid host" >&2 return 1 fi } # Validate user input in a loop while true; do read -rp "Enter an IPv4 address: " ip is_ipv4 "$ip" && break echo "Invalid IP address, try again" done # Use in a case-like dispatch via if/elif describe_input() { local val="$1" if is_integer "$val"; then echo "integer" elif is_float "$val"; then echo "float" elif is_ipv4 "$val"; then echo "IPv4 address" elif is_email "$val"; then echo "email address" elif is_iso_date "$val"; then echo "ISO date" else echo "unknown" fi }
Parsing with BASH_REMATCH — Real Patterns
# Parse a log line into variables line="2026-06-11 14:32:00 ERROR database connection failed" re='^([0-9-]+)[[:space:]]+([0-9:]+)[[:space:]]+([A-Z]+)[[:space:]]+(.+)$' if [[ $line =~ $re ]]; then log_date="${BASH_REMATCH[1]}" log_time="${BASH_REMATCH[2]}" log_level="${BASH_REMATCH[3]}" log_msg="${BASH_REMATCH[4]}" fi # Extract semantic version parts from a dependency line dep="requests==2.31.0" if [[ $dep =~ ^([a-z_-]+)==([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then pkg="${BASH_REMATCH[1]}" major="${BASH_REMATCH[2]}" minor="${BASH_REMATCH[3]}" patch="${BASH_REMATCH[4]}" echo "$pkg v$major.$minor.$patch" fi # Check minimum version (compare captured integers) if [[ $dep =~ ==([0-9]+)\.([0-9]+) ]]; then maj="${BASH_REMATCH[1]}" min="${BASH_REMATCH[2]}" if (( maj > 2 || ( maj == 2 && min >= 28 ) )); then echo "version OK" fi fi # Process lines from a file — parse each one while IFS= read -r line; do if [[ $line =~ ^([a-z_]+)[[:space:]]*=[[:space:]]*(.+)$ ]]; then printf "key=%-20s value=%s\n" "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" fi done < config.ini
Performance — When to Use [[ =~ ]] vs External Tools
Choosing the right tool for regex work
Fast — use [[ =~ ]]
Validating a single variable or argument in a script
Parsing one value into components (URL, version, date)
Type-checking user input in a loop
Conditional branching based on a variable's format
Use grep/awk/sed
Searching or filtering lines across a file or stream
Extracting all matches from many lines (-o pattern)
Transforming text in bulk (substitution across thousands of lines)
Counting matches, aggregating statistics from a file
Avoid [[ =~ ]] inside loops over file lines
Reading a 50,000-line file with while read + [[ =~ ]] is slower
than piping it through grep or awk — spawn the tool once
# SLOW: shell loop with [[ =~ ]] on a large file while IFS= read -r line; do [[ $line =~ ERROR ]] && echo "$line" done < bigfile.log # FAST: let grep do all the line scanning in C grep 'ERROR' bigfile.log # GOOD use of [[ =~ ]]: single-value validation, no file I/O deploy() { local version="$1" if ! [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "Error: version must be MAJOR.MINOR.PATCH" >&2 return 1 fi # ... deploy logic }
Common Traps and Fixes
| Trap | Example | Fix |
|---|---|---|
| Quoted regex becomes literal | [[ $x =~ "^[0-9]+" ]] |
Remove quotes: [[ $x =~ ^[0-9]+ ]] |
| Pattern with spaces causes error | [[ $x =~ foo bar ]] |
Store in variable: re="foo bar"; [[ $x =~ $re ]] |
| Unquoted LHS with spaces splits | [[ $var =~ pat ]] where var="a b" |
Always quote LHS: [[ "$var" =~ pat ]] |
| BASH_REMATCH overwritten | Using BASH_REMATCH after another [[ ]] |
Assign to named variables immediately after the test |
| Partial match surprises | [[ "foobar" =~ foo ]] is true |
Anchor with ^ and $ for full-string match |
Using in [ ] instead of [[ ]] |
[ "$x" =~ pattern ] |
=~ only works in [[ ]] — single brackets do not support it |
| ERE features missing in old Bash | {n,m} intervals on Bash < 3.2 |
Use Bash 3.2+ or rewrite without interval expressions |
Quick Reference — Chapter 10
Syntax
[[ "$var" =~ regex ]]Test — true (exit 0) if var contains a match
! [[ "$var" =~ regex ]]Negated test — true if var does NOT match
re='...'; [[ "$var" =~ $re ]]Store complex pattern in a variable (portable)
${BASH_REMATCH[0]}Entire matched text
${BASH_REMATCH[N]}Capture group N (1-indexed)
Key Rules
Regex — never quotedQuoting the RHS makes it a literal string comparison
String — always quotedQuote the LHS to protect spaces and metacharacters
ERE syntaxSame flavour as grep -E and awk — not BRE
Partial match by defaultUse ^ and $ to anchor and force full-string matching
Save BASH_REMATCH immediatelyIt is overwritten by every subsequent [[ ]] test
No subshell costFaster than grep for validating a single value
What is coming next: Chapter 11 covers PCRE — Perl-Compatible Regular Expressions in depth. Lookahead and lookbehind assertions, lazy quantifiers, named capture groups, non-capturing groups, atomic groups, and how to use PCRE features via
grep -P and perl -ne inside shell pipelines.