Groups & Lookaround
Groups let you pull pieces out of a match and refer to them again. Lookaround lets you require context around a match without consuming it. Together they cover most of what separates “I can read a regex” from “I can write one”.
Capturing groups
Section titled “Capturing groups”Every (...) captures the text it matched, numbered by the position of its opening parenthesis, left to right, starting at 1. Group 0 is always the entire match.
const m = '2024-01-31'.match(/^(\d{4})-(\d{2})-(\d{2})$/);m[0] // => "2024-01-31"m[1] // => "2024"m[2] // => "01"m[3] // => "31"import rem = re.match(r"^(\d{4})-(\d{2})-(\d{2})$", "2024-01-31")m.group(0) # => '2024-01-31'm.groups() # => ('2024', '01', '31')m.span(1) # => (0, 4) start/end offsets of group 1Nesting works and does not change the numbering rule — count opening parens:
'2024-01'.match(/((\d{4})-(\d{2}))/).slice(0, 4)// => ["2024-01", "2024-01", "2024", "01"]// whole group 1 group 2 group 3Groups that did not participate
Section titled “Groups that did not participate”An optional group that never matched is undefined in JavaScript and None in Python — not an empty string.
'ac'.match(/a(b)?c/) // => ["ac", undefined]re.match(r"a(b)?c", "ac").groups() # => (None,)Non-capturing groups (?:...)
Section titled “Non-capturing groups (?:...)”(?:...) groups for precedence without allocating a capture slot. Use it whenever you only need the parentheses for structure.
// "-\d{2}" repeated twice; only the year is captured'2024-01-31'.match(/^(\d{4})(?:-\d{2}){2}$/)// => ["2024-01-31", "2024"]re.findall(r"(?:ab)+(c)", "ababc") # => ['c'] — only the (c) group is returnedThree reasons to prefer it:
- Stable numbering. Adding a grouping paren in the middle of a pattern silently renumbers every later group and breaks
$2in your replacement strings. - Cleaner output.
findall,matchAll, andgroups()return only what you asked for. - Marginally faster. The engine skips the bookkeeping.
Non-capturing groups exist in JS, Python, PCRE, and grep -P — but not in POSIX BRE/ERE. In grep -E and sed -E, every group captures.
Backreferences
Section titled “Backreferences”A backreference matches the same text a previous group matched — not the same pattern. \1 refers to group 1.
// Collapse a doubled word'the the cat'.replace(/\b(\w+)\s+\1\b/g, '$1') // => "the cat"
// Matching quote characters: opens and closes with the same one/^(["']).*\1$/.test("'abc'") // => true/^(["']).*\1$/.test("'abc\"") // => falseimport rere.sub(r"\b(\w+)\s+\1\b", r"\1", "the the cat") # => 'the cat'Backreferences are one of the few features POSIX ERE keeps:
printf 'abab\nabcd\n' | grep -E '(ab)\1'# => abab
printf 'hello hello world\n' | grep -oE '(\w+) \1'# => hello hello
echo 'the the cat' | sed -E 's/\b(\w+) \1\b/\1/g'# => the cat| Context | Whole match | Group 1 |
|---|---|---|
| JS pattern | — | \1 |
| JS replacement | $& |
$1 |
| Python pattern | — | \1 |
| Python replacement | \g<0> |
\1 or \g<1> |
| sed replacement | & |
\1 |
awk sub/gsub |
& |
not available |
echo 'price 42' | sed -E 's/[0-9]+/[&]/' # => price [42]echo 'price 42' | awk '{gsub(/[0-9]+/, "[&]"); print}' # => price [42]Named groups
Section titled “Named groups”Numbered groups get unreadable past two or three. Named groups fix that, but each engine spells them differently.
| Engine | Declare | Backreference in pattern | In replacement |
|---|---|---|---|
| JavaScript | (?<name>...) |
\k<name> |
$<name> |
Python re |
(?P<name>...) |
(?P=name) |
\g<name> |
| PCRE2 | (?<name>...) or (?P<name>...) |
\k<name> or (?P=name) |
tool-dependent |
| .NET / Java | (?<name>...) |
\k<name> |
${name} / ${name} |
| POSIX ERE | not supported | — | — |
const m = '2024-01-31'.match(/(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/);m.groups.y // => "2024"m.groups.d // => "31"
/(?<c>\w)\k<c>/.test('aa') // => true — same character twice/(?<c>\w)\k<c>/.test('ab') // => false
'John Smith'.replace(/(?<first>\w+) (?<last>\w+)/, '$<last>, $<first>')// => "Smith, John"import rem = re.match(r"(?P<y>\d{4})-(?P<m>\d{2})-(?P<d>\d{2})", "2024-01-31")m.group("y") # => '2024'm.groupdict() # => {'y': '2024', 'm': '01', 'd': '31'}
bool(re.search(r"(?P<c>\w)(?P=c)", "aa")) # => True
re.sub(r"(?P<first>\w+) (?P<last>\w+)", r"\g<last>, \g<first>", "John Smith")# => 'Smith, John'Named groups still get numbers, so m[1] and m.groups.y refer to the same thing in JS. Named and numbered access are interchangeable.
Lookaround
Section titled “Lookaround”Lookaround assertions check whether something could match at the current position, then throw away the result and stay put. They match a zero-width position, like ^ or \b.
| Syntax | Name | Meaning |
|---|---|---|
(?=...) |
positive lookahead | what follows must match |
(?!...) |
negative lookahead | what follows must not match |
(?<=...) |
positive lookbehind | what precedes must match |
(?<!...) |
negative lookbehind | what precedes must not match |
The key property: lookaround consumes nothing, so the matched text excludes it.
Lookahead
Section titled “Lookahead”// A word only if it is followed by a digit — the digit is not part of the match'foo1 bar2 baz'.match(/\w+(?=\d)/g) // => ["foo", "bar"]
// A "c" word that is not "ca…"'cat cot'.match(/c(?!a)\w+/g) // => ["cot"]import rere.findall(r"\w+(?=\d)", "foo1 bar2") # => ['foo', 'bar']Multiple lookaheads can be stacked at the same position — each one tests independently from where the engine currently stands. This is how “must contain all of X, Y, Z, in any order” is expressed:
const strong = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/;strong.test('Passw0rdd') // => truestrong.test('password') // => falseprintf 'Passw0rdd\npassword\n' | grep -P '^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$'# => Passw0rddReading it: at position 0, assert “somewhere ahead there is a lowercase letter”, then (still at 0) “somewhere ahead an uppercase”, then “somewhere ahead a digit” — then actually consume 8 or more characters to the end.
Lookbehind
Section titled “Lookbehind”// Grab the number only when it follows a "$"'price $42 and 42'.match(/(?<=\$)\d+/g) // => ["42"]
// A digit not preceded by "a"'a1 b2'.match(/(?<!a)\d/g) // => ["2"]echo 'foo=bar' | grep -oP '(?<=foo=)\w+' # => barLookbehind is what makes “extract the value after a known prefix” a one-liner without a capture group.
Engine support — read this before using lookbehind
Section titled “Engine support — read this before using lookbehind”| Engine | Lookahead | Lookbehind | Variable-length lookbehind |
|---|---|---|---|
| JavaScript (ES2018+) | yes | yes | yes |
Python re |
yes | yes | no — fixed width only |
PCRE2 / grep -P |
yes | yes | no (alternation of fixed widths is allowed) |
| .NET | yes | yes | yes |
| Java | yes | yes | bounded only ({0,n}) |
POSIX ERE (grep -E, sed -E, awk) |
no | no | — |
Go regexp / RE2 / Rust regex |
no | no | — |
JavaScript is unusually generous here — its lookbehind matches right-to-left and accepts any pattern:
/(?<=ab{1,3})c/.test('abbbc') // => true in JSPython refuses the same thing:
re.compile(r"(?<=ab{1,3})c")# => re.error: look-behind requires fixed-width patternWorkarounds when lookbehind is unavailable or too restrictive:
# 1. Capture instead of looking behindre.search(r"\$(\d+)", "price $42").group(1) # => '42'
# 2. Alternate fixed widths (PCRE and Python both accept this)re.compile(r"(?<=USD)|(?<=EUR)") # each branch is 3 chars — OK
# 3. Use the third-party `regex` module, which supports variable-length lookbehind# grep -E has no lookaround; use -P (PCRE) or fall back to sed with a captureecho 'foo=bar' | sed -nE 's/^foo=(.*)$/\1/p' # => barPractical uses
Section titled “Practical uses”Split on a delimiter without consuming it
Section titled “Split on a delimiter without consuming it”'a1b2c3'.split(/(?=\d)/) // => ["a", "1b", "2c", "3"]Insert thousands separators
Section titled “Insert thousands separators”'1234567'.replace(/\B(?=(\d{3})+(?!\d))/g, ',') // => "1,234,567"Reading it: match every position that is not a word boundary (\B, so not the very start) where the remaining text is an exact multiple of three digits ((\d{3})+) with no digit after it ((?!\d)). Insert a comma there. Nothing is consumed, so replace only injects.
Match a line that does not contain a substring
Section titled “Match a line that does not contain a substring”There is no “not” operator for whole strings, but a lookahead at the start does the job:
# Lines containing "error" but not "debug"grep -P '^(?!.*debug).*error' app.log
# Portable alternative: two grepsgrep 'error' app.log | grep -v 'debug're.match(r"^(?!.*debug).*error", line)Replace only in a specific context
Section titled “Replace only in a specific context”// Uppercase a word only when it follows "Mr. "'Mr. smith and smith'.replace(/(?<=Mr\. )\w+/, s => s.toUpperCase())// => "Mr. SMITH and smith"Validate without capturing
Section titled “Validate without capturing”// Semver-ish: three dot-separated numbers, no capture slots wasted/^\d+(?:\.\d+){2}$/.test('1.22.3') // => trueExtract key=value pairs
Section titled “Extract key=value pairs”const line = 'host=db1 port=5432 user=app';const out = Object.fromEntries( [...line.matchAll(/(?<k>\w+)=(?<v>[^\s]+)/g)].map(m => [m.groups.k, m.groups.v]));// => { host: 'db1', port: '5432', user: 'app' }import reline = "host=db1 port=5432 user=app"dict(re.findall(r"(\w+)=(\S+)", line))# => {'host': 'db1', 'port': '5432', 'user': 'app'}Note the shape difference: re.findall with two groups returns a list of tuples, which dict() consumes directly. With one group it returns a flat list of strings; with zero groups it returns whole matches. That behavior surprises people — finditer is the predictable alternative.
Key points
Section titled “Key points”- Groups are numbered by opening paren; group 0 is the whole match.
- Use
(?:...)unless you actually want the capture — it keeps numbering stable. - A non-participating group is
undefined/None, butre.findallreports it as''. - Backreference syntax in the pattern (
\1) differs from replacement syntax ($1in JS,\1in Python and sed,&for the whole match in sed/awk). - Named groups:
(?<n>...)+\k<n>in JS;(?P<n>...)+(?P=n)in Python. They are not interchangeable. - Lookaround matches a position and consumes nothing.
- Lookbehind is variable-length in JS, fixed-length in Python, and absent from POSIX ERE and RE2-based engines.