Regular Expressions in Bash

Chapter 12 — Your Regex Toolkit: Patterns, Testing, and Real Pipelines

Building a Personal Pattern Library

The most productive regex habit is maintaining a library of tested, annotated patterns you reach for repeatedly. Instead of rewriting "what was that IPv4 pattern again?" from scratch, you keep one authoritative version that you know works correctly — including the edge cases that bit you before.

Below is a complete regex_lib.sh — source it in any script with source regex_lib.sh.

#!/usr/bin/env bash
# regex_lib.sh — tested, reusable regex patterns and validation functions
# Usage: source regex_lib.sh

# ── Anchored validation patterns ─────────────────────────────────────────
RE_INTEGER='^-?[0-9]+$'
RE_POS_INT='^[1-9][0-9]*$'
RE_FLOAT='^-?[0-9]+(\.[0-9]+)?$'
RE_HEX='^(0[xX])?[0-9a-fA-F]+$'

RE_IPV4='^([0-9]{1,3}\.){3}[0-9]{1,3}$'
RE_IPV6='^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$'
RE_CIDR='^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[1-2][0-9]|3[0-2])$'
RE_MAC='^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$'

RE_EMAIL='^[[:alnum:]._%+-]+@[[:alnum:].-]+\.[[:alpha:]]{2,}$'
RE_URL='^https?://[[:alnum:]._~:/?#\[\]@!$&()*+,;=%-]+'
RE_HOSTNAME='^[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])?)*$'

RE_ISO_DATE='^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$'
RE_TIME_24='^([01][0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$'
RE_SEMVER='^[0-9]+\.[0-9]+\.[0-9]+(-[[:alnum:].-]+)?(\+[[:alnum:].-]+)?$'

RE_SLUG='^[a-z0-9]+(-[a-z0-9]+)*$'
RE_HEX_COLOUR='^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$'
RE_UUID='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
RE_BASE64='^[A-Za-z0-9+/]+={0,2}$'

# ── Validation functions ──────────────────────────────────────────────────
# Each returns 0 (true) if valid, 1 (false) if not

is_integer()    { [[ "$1" =~ $RE_INTEGER   ]]; }
is_pos_int()    { [[ "$1" =~ $RE_POS_INT   ]]; }
is_float()      { [[ "$1" =~ $RE_FLOAT     ]]; }
is_email()      { [[ "$1" =~ $RE_EMAIL     ]]; }
is_url()        { [[ "$1" =~ $RE_URL       ]]; }
is_hostname()   { [[ "$1" =~ $RE_HOSTNAME  ]]; }
is_iso_date()   { [[ "$1" =~ $RE_ISO_DATE ]]; }
is_semver()     { [[ "$1" =~ $RE_SEMVER    ]]; }
is_uuid()       { [[ "$1" =~ $RE_UUID      ]]; }
is_hex_colour() { [[ "$1" =~ $RE_HEX_COLOUR ]]; }
is_slug()       { [[ "$1" =~ $RE_SLUG      ]]; }

is_ipv4() {
    [[ "$1" =~ $RE_IPV4 ]] || return 1
    local IFS='.' octet
    for octet in $1; do
        (( octet >= 0 && octet <= 255 )) || return 1
    done
}

A Regex Test Harness

Every pattern in your library should have tests. This harness lets you declare positive cases (must match) and negative cases (must not match), then run them all and report failures — just like a unit test suite, but for regex.

#!/usr/bin/env bash
# test_regex.sh — regex unit test harness
source regex_lib.sh

PASS=0; FAIL=0

assert_match() {       # assert_match "label" pattern "value"
    local label="$1" re="$2" val="$3"
    if [[ "$val" =~ $re ]]; then
        printf "  \e[32mPASS\e[0m  %s: '%s'\n" "$label" "$val"
        (( PASS++ ))
    else
        printf "  \e[31mFAIL\e[0m  %s: '%s' should match /%s/\n" \
               "$label" "$val" "$re"
        (( FAIL++ ))
    fi
}

assert_no_match() {   # assert_no_match "label" pattern "value"
    local label="$1" re="$2" val="$3"
    if ! [[ "$val" =~ $re ]]; then
        printf "  \e[32mPASS\e[0m  %s: '%s' correctly rejected\n" "$label" "$val"
        (( PASS++ ))
    else
        printf "  \e[31mFAIL\e[0m  %s: '%s' should NOT match /%s/\n" \
               "$label" "$val" "$re"
        (( FAIL++ ))
    fi
}

# ── Tests ─────────────────────────────────────────────────────────────────
echo "=== RE_ISO_DATE ==="
assert_match    "valid date"         "$RE_ISO_DATE"  "2026-06-11"
assert_match    "leap day"           "$RE_ISO_DATE"  "2024-02-29"
assert_no_match "month 13"          "$RE_ISO_DATE"  "2026-13-01"
assert_no_match "day 00"            "$RE_ISO_DATE"  "2026-06-00"
assert_no_match "US format"         "$RE_ISO_DATE"  "06/11/2026"
assert_no_match "partial"           "$RE_ISO_DATE"  "2026-06"

echo "=== RE_EMAIL ==="
assert_match    "simple"             "$RE_EMAIL"     "user@example.com"
assert_match    "plus tag"           "$RE_EMAIL"     "user+tag@example.co.uk"
assert_no_match "no @"              "$RE_EMAIL"     "userexample.com"
assert_no_match "no TLD"            "$RE_EMAIL"     "user@example"
assert_no_match "spaces"            "$RE_EMAIL"     "user @example.com"

echo "=== is_ipv4 (with octet check) ==="
assert_match    "valid"              "$RE_IPV4"      "192.168.1.100"
assert_no_match "octet 256 (regex)" "$RE_IPV4"      "999.1.1.1"

# Summary
echo
printf "Results: \e[32m%d passed\e[0m  \e[31m%d failed\e[0m\n" "$PASS" "$FAIL"
(( FAIL == 0 ))   # exit 0 if all passed, 1 if any failed

Sample output:

=== RE_ISO_DATE ===
  PASS  valid date: '2026-06-11'
  PASS  leap day: '2024-02-29'
  PASS  month 13 correctly rejected
  PASS  day 00 correctly rejected
  PASS  US format correctly rejected
  PASS  partial correctly rejected
=== RE_EMAIL ===
  PASS  simple: 'user@example.com'
  PASS  plus tag: 'user+tag@example.co.uk'
  PASS  no @ correctly rejected
  PASS  no TLD correctly rejected
  FAIL  spaces: 'user @example.com' should NOT match /^[[:alnum:]...

Results: 10 passed  1 failed

Tool Picker — Choosing the Right Tool

Do you need to match/filter lines from a file or stream?
→ yes, single pattern
grep 'pattern' file
→ yes, ERE syntax (|, +, ?)
grep -E 'pat1|pat2' file
→ yes, extract only the matched text
grep -oE 'pattern' file
→ yes, PCRE (lookahead, lazy, named groups)
grep -P 'pattern' file — or — perl -ne 'print if /pat/'
Do you need to transform / substitute text?
→ simple find-and-replace on every line
sed 's/old/new/g' file
→ ERE with capture groups, case modifiers
sed -E 's/(group)/\U\1/g' file
→ PCRE substitution (lazy, lookahead in pattern)
perl -pe 's/pattern/replacement/g' file
Do you need to process fields, aggregate, or do arithmetic?
→ split on delimiter, match per-field
awk -F, '$3 ~ /pattern/ { print $1 }'
→ count, sum, group matches
awk '/ERROR/ { count++ } END { print count }'
Are you validating a single variable inside a script?
→ no subprocess needed
[[ "$var" =~ ^pattern$ ]]

Real Pipeline Scripts

Script 1 — Log report: errors by hour

#!/usr/bin/env bash
# hourly_errors.sh — count ERROR lines per hour from an app log
# Input format: "2026-06-11 14:32:00 ERROR message..."

awk '$3 == "ERROR" {
    match($2, /^([0-9]{2})/, t)
    hours[t[1]]++
}
END {
    print "Hour  Errors"
    print "----  ------"
    for (h = 0; h < 24; h++) {
        if (h in hours)
            printf "%02d:00  %d\n", h, hours[h]
    }
}' "${1:-app.log}"

Script 2 — Deploy guard: validate all inputs before proceeding

#!/usr/bin/env bash
# deploy.sh — validate arguments before running deployment
source regex_lib.sh

die() { echo "ERROR: $*" >&2; exit 1; }

VERSION="$1"
HOST="$2"
PORT="${3:-8080}"

is_semver  "$VERSION" || die "'$VERSION' is not a valid version (need MAJOR.MINOR.PATCH)"
is_hostname "$HOST"   || is_ipv4 "$HOST" || die "'$HOST' is not a valid host"
is_pos_int "$PORT"   || die "'$PORT' is not a valid port"
(( PORT <= 65535 ))  || die "Port $PORT exceeds 65535"

echo "Deploying v$VERSION to $HOST:$PORT ..."
# ... rest of deployment

Script 3 — Config auditor: scan for insecure settings

#!/usr/bin/env bash
# audit_config.sh — grep multiple files for security red flags

TARGET_DIR="${1:-/etc}"
ISSUES=0

check() {
    local label="$1" pattern="$2"
    shift 2
    local hits
    hits=$(grep -rnE --include='*.conf' --include='*.cfg' \
              "$pattern" "$@" 2>/dev/null)
    if [[ -n "$hits" ]]; then
        printf "\e[33mWARN\e[0m  %s\n%s\n\n" "$label" "$hits"
        (( ISSUES++ ))
    fi
}

check "PermitRootLogin enabled"     '^PermitRootLogin\s+yes'        "$TARGET_DIR"
check "PasswordAuthentication on"   '^PasswordAuthentication\s+yes' "$TARGET_DIR"
check "Plaintext password in config" '(?i)password\s*=\s*\S+'        "$TARGET_DIR"
check "Hardcoded API key"            '(?i)api.?key\s*=\s*["\x27]\S+'  "$TARGET_DIR"

(( ISSUES == 0 )) && echo "No issues found."
exit $(( ISSUES > 0 ))

Script 4 — Access log analyser

#!/usr/bin/env bash
# access_summary.sh — parse nginx/apache combined log format
# Format: IP - - [date] "METHOD /path HTTP/x.x" STATUS bytes

awk '{
    # Extract status code (field 9) and request path
    status = $9
    match($7, /^\/[^?]*/, m)
    path = m[0]

    status_count[status]++
    if (status ~ /^[45]/) {
        errors[path]++
    }
    bytes += $10
}
END {
    print "=== Status codes ==="
    for (s in status_count)
        printf "  %s  %d\n", s, status_count[s]

    print "\n=== Top error paths ==="
    for (p in errors)
        printf "  %d  %s\n", errors[p], p
    | "sort -rn | head -10"

    printf "\n=== Total bytes: %.1f MB ===\n", bytes / 1024 / 1024
}' "${1:-/var/log/nginx/access.log}"

Complete Course Reference Card

Anchors
^Start of line
$End of line
\bWord boundary (GNU)
\< \>Word boundary (POSIX)
^...$Whole-line / whole-string match
Quantifiers
* + ?0+, 1+, 0-or-1 (greedy)
{n} {n,} {n,m}Exact, at-least, between
*? +? ??Lazy (PCRE only)
BRE: \+ \?ERE + and ? in BRE context
Character Classes
[abc]Literal set
[a-z]Range
[^abc]Negated set
[:alpha:] [:digit:]POSIX classes (inside [])
\w \d \sGNU/PCRE shorthands
.Any char except newline
Groups and Alternation
(...)Capturing group — ERE/PCRE
\(...\)Capturing group — BRE
(?:...)Non-capturing group — PCRE
(?P<n>...)Named group — PCRE
| vs \|Alternation ERE / BRE
PCRE Assertions
(?=pat)Positive lookahead
(?!pat)Negative lookahead
(?<=pat)Positive lookbehind
(?<!pat)Negative lookbehind
(?>...)Atomic group — no backtrack
Back-references
\1 … \9Group back-ref in pattern or replacement
&Whole match in sed/awk replacement
\u \U \l \L \ECase modifiers in GNU sed replacement
BASH_REMATCH[N]Capture group N after [[ =~ ]]
grep Flags
-E -F -PERE / fixed-string / PCRE mode
-o -n -cOnly match / line nums / count
-l -L -vFiles with / without / invert
-r -i -wRecursive / case-insensitive / word
-q -A -B -CQuiet / after / before / context
sed / awk / Bash
sed -E 's/pat/rep/g'ERE substitution, all occurrences
sed -i.bak '...'In-place edit with backup
awk '$N ~ /pat/'Field N matches regex
awk 'gsub(/p/,"r")'Replace all matches in $0
[[ "$v" =~ ^pat$ ]]Native Bash ERE test, no subprocess

Flavour Comparison — The One Table

FeatureBREEREPCRE
One or more\+++
Zero or one\???
Grouping\(...\)(...)(...)
Alternation\|||
Intervals {n,m}\{n,m\}{n,m}{n,m}
Lazy quantifiersNoNo*? +? ??
Non-capturing groupNoNo(?:...)
Lookahead / LookbehindNoNo(?=) (?<=)
Named groupsNoNo(?P<n>...)
Tools (typical)grep, sed (default)grep -E, sed -E, awkgrep -P, perl

Chapter Index

01What Are Regular Expressions — engines, flavours, first patterns
02Literals, the Dot, and Escaping — shell quoting, grep -F
03Anchors — ^, $, \b, \<\>, whole-line matching
04Character Classes — ranges, POSIX, \w \d \s shorthands
05Quantifiers — *, +, ?, {n,m}, greedy rules, ERE vs BRE
06Groups, Alternation, Back-references — BASH_REMATCH, &, \1–\9
07grep and egrep in Depth — all flags, -o, -F, exit codes, pipelines
08sed and Regex — s flags, case modifiers, address targeting
09Regex in awk — ~, !~, match(), gsub(), gensub(), ERE
10Bash [[ =~ ]] — BASH_REMATCH, quoting rules, validation library
11PCRE — lookahead, lookbehind, lazy, named groups, perl -ne
12Your Toolkit — pattern library, test harness, real pipeline scripts
Regular Expressions in Bash — Complete 12 chapters · grep · sed · awk · [[ =~ ]] · PCRE BRE · ERE · PCRE · anchors · quantifiers · character classes · groups · back-references · lookahead · lookbehind · lazy matching · named groups · BASH_REMATCH · validation library · test harness