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 -E and awk
  • No subprocess — handled entirely by the shell; faster than any external tool for single-value tests
  • Populates BASH_REMATCH on 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

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