Regular Expressions in Bash

Chapter 11 — PCRE: Lookahead, Lookbehind, and Advanced Features

What Is PCRE?

PCRE — Perl-Compatible Regular Expressions — is a regex engine originally written to match Perl 5's regex behaviour. It extends ERE with features that are impossible in BRE or ERE: lookahead and lookbehind assertions, lazy quantifiers, named capture groups, non-capturing groups, and more.

In the Bash toolkit, PCRE is available via:

  • grep -P — GNU grep with PCRE engine (Linux; not available in BSD/macOS grep)
  • perl -ne — inline Perl one-liners, available everywhere Perl is installed
  • pcregrep — standalone PCRE grep (installable on macOS via Homebrew)
grep -P is GNU grep only. On macOS, grep -P will print an error. Use perl -ne or install pcregrep for portable PCRE across platforms.

Lookahead and Lookbehind Assertions

Assertions test what is around the match position without consuming characters — they are zero-width. The matched text does not include the assertion's content.

(?=...) Positive lookahead Match position where the pattern ahead succeeds. The ahead text is not included in the match.
(?!...) Negative lookahead Match position where the pattern ahead does NOT match. Useful for exclusions.
(?<=...) Positive lookbehind Match position where the pattern behind succeeds. The behind text is not included in the match.
(?<!...) Negative lookbehind Match position where the pattern behind does NOT match. Excludes by context.
Positive lookahead: \w+(?=:) — word followed by colon, colon not captured Input: host: localhost port: 8080 timeout: 30 Match: host (colon stays in input, not consumed) Match: port Match: timeout Positive lookbehind: (?<=\$)[0-9]+ — digits preceded by $, $ not captured Input: price is $42.00 and $199.99 Match: 42 ($ was required but not included in match) Match: 199
# Positive lookahead: extract keys (words before a colon)
echo "host: localhost  port: 8080" | grep -oP '\w+(?=:)'
host
port

# Positive lookbehind: extract values (digits after $)
echo "price $42 or $199" | grep -oP '(?<=\$)[0-9]+'
42
199

# Negative lookahead: match "foo" not followed by "bar"
echo "foobar foobaz fooqux" | grep -oP 'foo(?!bar)\w*'
foobaz
fooqux

# Negative lookbehind: match digits NOT preceded by a minus
echo "count=42 temp=-7 size=100" | grep -oP '(?<!-)[0-9]+'
42
100

# Lookahead + lookbehind together: extract value between delimiters
echo 'name="Alice"' | grep -oP '(?<=")[^"]+'
Alice

# Validate a password: must contain a digit AND an uppercase letter
pw="Secret42"
if echo "$pw" | grep -qP '^(?=.*[0-9])(?=.*[A-Z]).{8,}$'; then
    echo "password strong"
fi

Lazy Quantifiers

All ERE/BRE quantifiers are greedy — they match as much as possible. PCRE adds lazy (minimal) quantifiers by appending ? to any quantifier. A lazy quantifier matches as little as possible while still allowing the overall pattern to succeed.

Greedy: .* .+ .{2,5} Match as MUCH as possible, then back off only if needed. The engine consumes the whole string first, then retreats.
Lazy: .*? .+? .{2,5}? Match as LITTLE as possible, then expand only if needed. The engine tries the shortest match first.
Input: "<b>bold</b> and <i>italic</i>" Greedy: <.+> matches <b>bold</b> and <i>italic</i> (one huge match) Lazy: <.+?> matches <b> then </b> then <i> then </i>
# Greedy: matches from first < to LAST > on the line
echo "<b>bold</b> and <i>italic</i>" | grep -oP '<.+>'
<b>bold</b> and <i>italic</i>   # one match — greedy consumed everything

# Lazy: matches each individual tag
echo "<b>bold</b> and <i>italic</i>" | grep -oP '<.+?>'
<b>
</b>
<i>
</i>

# Lazy: extract content of the FIRST quoted string
echo '"Alice" and "Bob"' | grep -oP '".*?"'
"Alice"
"Bob"

# Lazy: match shortest balanced {...} block
echo "{a} text {b} more {c}" | grep -oP '\{.+?\}'
{a}
{b}
{c}

# All lazy quantifier forms
# *?   zero or more, lazy
# +?   one or more, lazy
# ??   zero or one, lazy
# {n,m}?  between n and m, lazy
ERE workaround without PCRE: [^delimiter]* is the ERE/BRE equivalent for lazy matching up to a delimiter. Use [^"]* instead of .*? to match inside double quotes. This works in all tools but only when the delimiter is a single character.

Named Capture Groups

PCRE allows capture groups to be named, making patterns self-documenting and the code that uses them easier to maintain. Names are referenced in the pattern and in replacement strings.

# Named group syntax: (?P<name>...) or (?<name>...) in PCRE

# grep -oP: extract named match (prints the whole match — use perl for the name)
echo "2026-06-11" | grep -oP '(?P<year>[0-9]{4})-(?P<month>[0-9]{2})-(?P<day>[0-9]{2})'
2026-06-11

# perl -ne: access named groups via %+ hash
echo "2026-06-11" | perl -ne '
    if (/(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})/) {
        print "year=$+{year} month=$+{month} day=$+{day}\n"
    }
'
year=2026 month=06 day=11

# perl: named group in replacement with \k<name>
echo "Smith, John" | perl -pe 's/(?P<last>\w+), (?P<first>\w+)/$+{first} $+{last}/'
John Smith

# Parse a log line with named groups for readability
echo "2026-06-11 14:32:00 ERROR connection failed" | perl -ne '
    if (/(?P<date>\S+) (?P<time>\S+) (?P<level>\S+) (?P<msg>.+)/) {
        printf "LEVEL=%-8s MSG=%s\n", $+{level}, $+{msg}
    }
'

Group Types — Complete Reference

(...) Capturing group Captures the matched text. Numbered left-to-right by opening paren. Back-referenced as \1\9.
(?:...) Non-capturing group Groups for alternation or quantification without consuming a capture slot. Keeps group numbers tidy.
(?P<name>...) (?<name>...) Named capturing group Captures and assigns a name. Also numbered. Referenced as (?P=name) or \k<name>.
(?=...) (?!...) Lookahead assertion Zero-width. Does not capture or consume. Tests what follows the current position.
(?<=...) (?<!...) Lookbehind assertion Zero-width. Tests what precedes the current position. Must be fixed-width in most PCRE implementations.
(?>...) Atomic group Once matched, the engine does not backtrack into this group. Prevents catastrophic backtracking.
(?i:...) (?s:...) (?m:...) Inline modifier group Apply flags to a portion of the pattern. i=case-insensitive, s=dot-all, m=multiline.
(?|...) Branch reset group Each branch of alternation uses the same group numbers. Useful for equivalent alternatives.
# Non-capturing group: group for alternation without a capture slot
echo "colour color" | grep -oP 'colo(?:u?r)'
colour
color

# Contrast: capturing group uses a slot even if you don't need it
# Non-capturing keeps group numbers clean when you only care about \1
echo "2026-06-11" | grep -oP '(?:[0-9]{4})-([0-9]{2})-([0-9]{2})'
# \1 = month (06), \2 = day (11) — year group not numbered

# Inline modifier: case-insensitive only within a group
echo "HTTP https FTP" | grep -oP '(?i:https?)'
HTTP
https

# Atomic group: prevent backtracking into a completed group
# Useful for performance; prevents runaway backtracking on long strings
echo "aaaaab" | grep -P '(?>a+)b'   # a+ consumed, no backtrack needed

perl -ne — PCRE in Shell Pipelines

perl -ne runs a Perl script line-by-line over stdin or a file. It is the most portable way to use full PCRE in shell pipelines, available on virtually every Unix system.

# -n: loop over lines, no auto-print; -e: inline script
# Basic filter: print matching lines (equivalent to grep -P)
perl -ne 'print if /ERROR/' app.log

# -p: loop and auto-print (like sed); substitution with s///
perl -pe 's/foo/bar/g' file.txt

# Extract only the matched portion (like grep -oP)
perl -ne 'print "$1\n" while /([0-9]{4}-[0-9]{2}-[0-9]{2})/g' log.txt

# Named groups + formatted output
perl -ne '
    if (/(?P<ip>\d{1,3}(?:\.\d{1,3}){3}).*?"(?P<method>GET|POST|PUT|DELETE)/) {
        print "$+{ip}  $+{method}\n"
    }
' access.log

# Lazy match: extract each JSON string value
echo '{"name":"Alice","city":"Paris"}' | \
perl -ne 'print "$1\n" while /"([^"]+)":\s*"(.+?)"/g and print "key=$1 val=$2\n" and 0'

# Cleaner version for key:value JSON extraction
echo '{"name":"Alice","city":"Paris"}' | \
perl -ne 'while (/"(\w+)":\s*"([^"]+)"/g) { print "$1 = $2\n" }'
name = Alice
city = Paris

# Lookahead in perl: extract words before colons
echo "host: localhost  port: 8080" | \
perl -ne 'print "$1\n" while /(\w+)(?=:)/g'
host
port

# Multi-line mode: . matches newlines with /s, ^ and $ match line ends with /m
perl -0777 -ne 'print "$1\n" while /BEGIN(.*?)END/gs' file.txt
# -0777: slurp entire file into $_; /s: dot matches newline; /g: all matches

Practical PCRE Recipes

Extraction

# Extract all IPv4 addresses from a file
grep -oP '\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b' access.log | sort -u

# Extract all URLs (http and https)
grep -oP 'https?://[^\s">]+' page.html

# Extract email addresses
grep -oP '[\w.+-]+@[\w-]+\.[\w.]+' mail.txt | sort -u

# Extract value of a specific JSON field (not for production — use jq)
echo '{"user": "alice", "role": "admin"}' | \
grep -oP '(?<="user": ")[^"]+'
alice

# Extract all words after a specific keyword
echo "imported module requests as req" | grep -oP '(?<=as )\w+'
req

Validation and filtering

# Password policy: min 8 chars, must have digit + uppercase + special
grep -P '^(?=.*[0-9])(?=.*[A-Z])(?=.*[!@#$%^&*]).{8,}$' passwords.txt

# Match lines that do NOT start with a comment or blank line
grep -P '^(?!#|$)' config.txt

# Find duplicate consecutive words
grep -P '\b(\w+)\s+\1\b' document.txt

# Validate that a version string follows MAJOR.MINOR.PATCH-PRERELEASE format
echo "2.31.0-beta.1" | grep -P '^[0-9]+\.[0-9]+\.[0-9]+(?:-[\w.]+)?$'

Substitution with perl -pe

# Reformat date using named groups
echo "2026-06-11" | perl -pe 's/(?P<y>\d{4})-(?P<m>\d{2})-(?P<d>\d{2})/$+{d}\/$+{m}\/$+{y}/'
11/06/2026

# Mask credit card numbers: keep last 4 digits
echo "card: 4111-1111-1111-1234" | \
perl -pe 's/\b\d{4}(?:-\d{4}){2}(?=-\d{4}\b)/****-****-****/g'
card: ****-****-****-1234

# Add a thousands separator to large numbers
echo "1234567" | perl -pe 's/(\d)(?=(\d{3})+(?!\d))/$1,/g'
1,234,567

# Remove ANSI colour codes from terminal output
cat coloured.log | perl -pe 's/\x1b\[[0-9;]*m//g'

# Normalise line endings: CRLF to LF
perl -pi -e 's/\r\n/\n/g' file.txt    # in-place edit

Catastrophic Backtracking — and How to Avoid It

Poorly written PCRE patterns on adversarial input can cause the regex engine to run for an exponentially long time — a ReDoS (Regular Expression Denial of Service) attack. Understanding the cause is essential for writing safe patterns.

# DANGEROUS: nested quantifiers on overlapping patterns
# Pattern (a+)+ on input "aaaaaaaaaaaab" causes exponential backtracking
# Each 'a' can be assigned to inner or outer group in 2^N ways
# grep -P '(a+)+b' <<< "aaaaaaaaaaaab"  — hangs!

# SAFE alternatives:
# 1. Atomic group — commit to match, no backtracking
grep -P '(?>a+)b'  # atomic: once a+ matched, don't re-try assignments

# 2. Possessive quantifiers (PCRE2 / newer Perl)
grep -P 'a++b'     # ++ = possessive: no backtrack

# 3. Rewrite to eliminate ambiguity
grep -P 'a+b'      # the nested group was unnecessary here
ReDoS rules of thumb: avoid nesting quantifiers ((a+)+), avoid alternation where branches can match the same characters ((a|a)+), and always anchor patterns that validate full strings (^...$). Use (?>...) atomic groups to commit the engine when backtracking serves no purpose.

PCRE Feature Availability

Featuregrep -P (GNU)grep -P (BSD/macOS)perl -nepcregrep
Lookahead (?=...)YesErrorYesYes
Lookbehind (?<=...)YesErrorYesYes
Lazy quantifiers .*?YesErrorYesYes
Named groups (?P<n>...)YesErrorYesYes
Non-capturing (?:...)YesErrorYesYes
Atomic group (?>...)YesErrorYesYes
Inline flags (?i:...)YesErrorYesYes
Slurp mode -0777N/AN/AYespcregrep -M

Quick Reference — Chapter 11

Assertions

(?=pat)Positive lookahead — position followed by pat
(?!pat)Negative lookahead — position NOT followed by pat
(?<=pat)Positive lookbehind — position preceded by pat
(?<!pat)Negative lookbehind — position NOT preceded by pat

Lazy Quantifiers

*? +? ?? {n,m}?Match as little as possible (PCRE only)

Group Types

(?:...)Non-capturing group
(?P<name>...)Named capturing group
(?>...)Atomic group — no backtracking
(?i:...)Inline flag — case-insensitive within group

Shell Tools

grep -oP 'pat'Extract PCRE matches (GNU grep only)
grep -P 'pat' fileFilter lines with PCRE (GNU grep only)
perl -ne 'print if /pat/'PCRE filter — portable alternative to grep -P
perl -pe 's/pat/rep/'PCRE substitution — portable alternative to sed -E
perl -0777 -ne '...'Slurp entire file; enables multi-line matching
What is coming next: Chapter 12 — the final chapter — covers building and maintaining a personal regex toolkit: a library of tested patterns, a test harness for validating your regex against edge cases, combining all the tools (grep, sed, awk, Bash, Perl) in real pipeline scripts, and a complete course reference card.