Skip to content

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

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 re
m = 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 1

Nesting 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 3

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,)

(?:...) 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 returned

Three reasons to prefer it:

  1. Stable numbering. Adding a grouping paren in the middle of a pattern silently renumbers every later group and breaks $2 in your replacement strings.
  2. Cleaner output. findall, matchAll, and groups() return only what you asked for.
  3. 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.

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\"") // => false
import re
re.sub(r"\b(\w+)\s+\1\b", r"\1", "the the cat") # => 'the cat'

Backreferences are one of the few features POSIX ERE keeps:

Terminal window
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
Terminal window
echo 'price 42' | sed -E 's/[0-9]+/[&]/' # => price [42]
echo 'price 42' | awk '{gsub(/[0-9]+/, "[&]"); print}' # => price [42]

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
JavaScript
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"
Python
import re
m = 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 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.

// 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 re
re.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') // => true
strong.test('password') // => false
Terminal window
printf 'Passw0rdd\npassword\n' | grep -P '^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$'
# => Passw0rdd

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

// 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"]
Terminal window
echo 'foo=bar' | grep -oP '(?<=foo=)\w+' # => bar

Lookbehind 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 JS

Python refuses the same thing:

re.compile(r"(?<=ab{1,3})c")
# => re.error: look-behind requires fixed-width pattern

Workarounds when lookbehind is unavailable or too restrictive:

# 1. Capture instead of looking behind
re.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
Terminal window
# grep -E has no lookaround; use -P (PCRE) or fall back to sed with a capture
echo 'foo=bar' | sed -nE 's/^foo=(.*)$/\1/p' # => bar
'a1b2c3'.split(/(?=\d)/) // => ["a", "1b", "2c", "3"]
'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:

Terminal window
# Lines containing "error" but not "debug"
grep -P '^(?!.*debug).*error' app.log
# Portable alternative: two greps
grep 'error' app.log | grep -v 'debug'
re.match(r"^(?!.*debug).*error", line)
// Uppercase a word only when it follows "Mr. "
'Mr. smith and smith'.replace(/(?<=Mr\. )\w+/, s => s.toUpperCase())
// => "Mr. SMITH and smith"
// Semver-ish: three dot-separated numbers, no capture slots wasted
/^\d+(?:\.\d+){2}$/.test('1.22.3') // => true
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 re
line = "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.

  • 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, but re.findall reports it as ''.
  • Backreference syntax in the pattern (\1) differs from replacement syntax ($1 in JS, \1 in 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.