Regex Without Tears
The 20% of regular expressions that solves 95% of problems — tokens, anchors, groups, lookarounds, flags.
A regular expression describes a set of strings; the engine walks the input left to right and reports the first place the pattern fits. Most of the language is three ideas: tokens (what one character may be), quantifiers (how many times), and groups (what to capture or alternate). Anchors and lookarounds add conditions without consuming characters.
The syntax shown is the Perl-style core shared by JavaScript, Python, Java, PCRE and ripgrep. Flavors diverge at the edges — named groups, lookbehind, \d in POSIX tools — and the flavor sections call out where.
Core tokens
- . \d \w \s
- any char · digit · word char [A-Za-z0-9_] · whitespace (capitals negate)
- * + ? {n,m}
- 0+ · 1+ · 0/1 · between n and m — all greedy by default
- Lazy: *? +?
- shortest match —
<.+?>stops at the first > - [abc] [^abc] [a-z]
- set · negated set · range; inside sets most magic is literal
- a|b
- alternation — scope it with groups:
gr(a|e)y - {3} {2,}
- exactly 3 · 2 or more —
\d{3}is three digits - \. \* \( \\
- escape a metacharacter to match it literally —
\.is a real dot - \t \n \r
- tab · newline · carriage return (Windows lines end in
\r\n) - [\s\S]
- truly any character, newlines included — for engines without the
sflag - \p{L} \p{Lu}
- any letter / uppercase letter in any script — JS with
u, PCRE, Java; not Pythonre
Anchors & groups
- ^ $ \b
- line start · line end · word boundary (
\bcat\b≠ category) - (x) (?:x)
- capturing vs non-capturing group
- (?<name>x)
- named capture →
groups.name - \1 backreference
- match the same text again —
(\w+) \1finds doubled words - Replace with $1
- captured groups in replacements:
(\d+)-(\d+)→$2-$1 - \B
- NOT a word boundary —
\Bcatmatches inside bobcat, not the word cat - \k<name>
- backreference by name in JS, .NET and PCRE; Python writes
(?P=name) - $& and $<name>
- JS replacement strings: the whole match · a named group
- (?i)
- inline flag at the pattern start — Python, PCRE, Java, Go; Node 22 JavaScript rejects it
Lookarounds & flags
- (?=x) (?!x)
- lookahead: followed by / NOT followed by (zero-width)
- (?<=x) (?<!x)
- lookbehind: preceded by / not preceded by
- Price example
(?<=\$)\d+matches 42 in $42 without eating the $- Flags g i m s
- all matches · ignore case · ^$ per line · dot matches newline
- ^(?=.*\d)(?=.*[A-Z]).{8,}$
- stacked lookaheads AND conditions: a digit, an uppercase letter, 8+ characters
- Flag u (JS)
- Unicode mode:
\p{…}, whole code points such as emoji, stricter syntax - re.X / (?x)
- verbose mode: whitespace ignored and
#comments allowed — for long patterns - Lookbehind width
- JavaScript allows any length; Python needs a fixed width (
(?<=a+)is an error)
Flavors: JavaScript & Python
- str.match(/x/g)
- all matches as plain strings, groups dropped —
matchAllkeeps groups per match - re.test(s) with /g
- stateful:
lastIndexadvances, so calling it twice on one string can return false - s.replace(/x/g, '$1')
- needs the
gflag (orreplaceAll) to replace every match, not just the first - new RegExp(userInput)
- escape it first —
.*(in user text are live metacharacters - r'\d+'
- Python raw string: backslashes reach the regex engine untouched — always use one
- re.match vs re.search
matchanchors at the start,searchscans,fullmatchmust cover everything- (?P<name>…)
- Python’s named group; reuse it as
(?P=name), replace with\g<name> - re.sub(r"(\d+)", r"<\1>", s)
- Python replacements use
\1or\g<1>, never$1 - \d in Python 3
- matches any Unicode digit, Arabic-Indic too —
re.ASCIIor[0-9]for 0–9 only
Flavors: grep, sed, rg
- grep -E 'a|b+'
- extended regex:
+ ? | ( ) {}work unescaped — plain grep needs backslashes - grep -oE '[0-9]+'
- print only the matching part, one match per line
- \d in grep -E / sed
- not POSIX — GNU grep reads it as a plain
d; use[0-9]or[[:digit:]] - grep -P '(?<=id )\d+'
- Perl-compatible: lookarounds and
\d— GNU grep only; macOS grep has no-P - sed -E 's/(a+)b/\1/g'
- ERE in sed; backreferences are
\1, andgreplaces every match on the line - rg -P '(\w+) \1'
- ripgrep’s default engine has no backreferences or lookaround;
-Pswitches to PCRE2 - Go, Rust, RE2
- linear-time engines: no backreferences or lookaround, but immune to catastrophic backtracking
Performance & safety
- (a+)+$
- nested quantifiers backtrack exponentially on a near miss like
aaaa…b - ReDoS
- never run user-supplied patterns, or fragile ones on user input, without a timeout or linear engine
- a++ (?>…)
- possessive / atomic: never backtrack into it — PCRE, Java, Python 3.11+
- "[^"]*" not ".*"
- a negated class stops at the delimiter; greedy
.*runs to the end, then backtracks
Battle-tested snippets
- Trim
^\s+|\s+$→ replace with empty (needs thegflag)- Number with decimals
-?\d+(\.\d+)?- ISO date
\d{4}-\d{2}-\d{2}— shape only: 2024-13-99 passes, so validate the value in code- Duplicate words
\b(\w+)\s+\1\b- The prime rule
- if the regex needs comments, it should be code. Emails/URLs: use a library
- Thousands separators
\B(?=(\d{3})+(?!\d))→ replace with,turns 1234567 into 1,234,567- Hex color
^#?([0-9a-fA-F]{3}){1,2}$— 3 or 6 hex digits- UUID
^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$with theiflag- URL slug
^[a-z0-9]+(?:-[a-z0-9]+)*$— no leading, trailing or doubled hyphens- Collapse whitespace
\s+→ one space, then trim the ends- key=value pairs
(\w+)=("[^"]*"|\S+)— quoted values may contain spaces
Recipes
Reformat dates (YYYY-MM-DD to DD/MM/YYYY) in three tools
# sed (ERE): \1 \2 \3 are the captured groups
sed -E 's/([0-9]{4})-([0-9]{2})-([0-9]{2})/\3\/\2\/\1/g' report.txt
// JavaScript, with named groups
text.replace(/(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/g, '$<d>/$<m>/$<y>')
# Python
re.sub(r'(\d{4})-(\d{2})-(\d{2})', r'\3/\2/\1', text)One pattern, three replacement syntaxes: \3 in sed and Python, $<d> or $3 in JavaScript. Use [0-9] in sed, where \d is not POSIX.
Validate a password policy with lookaheads
^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{12,}$
(?=.*[a-z]) a lowercase letter somewhere
(?=.*[A-Z]) an uppercase letter somewhere
(?=.*\d) a digit somewhere
(?=.*[^A-Za-z0-9]) a symbol somewhere
.{12,} at least 12 characters in totalEach lookahead checks from the start without consuming anything, so the conditions combine as AND. If you need a specific error message per rule, test the rules separately in code instead.
Extract every match with its groups
// JavaScript
for (const m of log.matchAll(/(?<level>ERROR|WARN) (?<code>\d+)/g)) {
console.log(m.groups.level, m.groups.code);
}
# Python
for m in re.finditer(r'(?P<level>ERROR|WARN) (?P<code>\d+)', log):
print(m['level'], m['code'])
# shell: count them
grep -oE '(ERROR|WARN) [0-9]+' app.log | sort | uniq -cmatchAll throws a TypeError without the g flag. String.match with g would return only the full matches and drop the groups.
Build a pattern from user input safely
// JavaScript: escape metacharacters, then build
const escaped = input.replace(/[.*+?^$()|[\]{}\\]/g, '\\$&');
const re = new RegExp('(?<!\\w)' + escaped + '(?!\\w)', 'i');
# Python
pattern = re.compile(r'(?<!\w)' + re.escape(user_input) + r'(?!\w)', re.I)Unescaped, an input like c++ or ( changes the pattern or throws. The lookarounds act as word boundaries that also work when the input starts or ends with a symbol — \bc\+\+\b never matches “C++ ”.
Spot catastrophic backtracking and fix it
// a 26-letter line ending in "!" (a near miss)
/^(\w+\s?)+$/.test('a'.repeat(26) + '!') // seconds: exponential backtracking
/^(?:\w+\s)*\w+$/.test('a'.repeat(26) + '!') // microseconds: one way to split the inputBecause \s? is optional, the first pattern can split a run of letters into words in 2^n ways and tries every one before failing. Requiring a separator in each repetition removes the ambiguity.
Questions about Regex Without Tears Cheat Sheet
What is the difference between greedy and lazy quantifiers?
A greedy quantifier such as * or + matches as much text as it can, then gives characters back one at a time only if the rest of the pattern fails. A lazy quantifier, written with a trailing question mark as in *? or +?, matches as little as possible and extends only when it has to. On the text "[a][b]", the pattern \[.+\] matches the whole string, while \[.+?\] stops at the first closing bracket and matches "[a]". Lazy is the quick fix for over-matching, but a negated class such as \[[^\]]*\] is usually faster and states the intent more precisely.
How do I match any character including newlines?
By default the dot matches any character except a line break. Turn on single-line or dotall mode to change that: the s flag in JavaScript (as in /a.b/s), re.DOTALL or an inline (?s) in Python, and (?s) in PCRE, Java and Go. In engines without such a flag, use a class that covers everything, such as [\s\S], which means any whitespace or any non-whitespace character. Do not confuse this with the m flag: multiline mode changes what ^ and $ match, letting them match at each line start and end, and does nothing to the dot.
What is a lookahead in regex?
A lookahead is a zero-width assertion: it checks what comes next without consuming it, so the match position does not move. (?=x) succeeds when x follows, and (?!x) succeeds when it does not. Lookbehinds, written with a less-than sign after the question mark, check what comes before. Because they consume nothing, several lookaheads at the start of a pattern combine as AND conditions, which is how password rules are usually written. They also let you match a number only when it follows a dollar sign, without including the sign in the match.
Why does my regex work in JavaScript but not in grep or Python?
Each tool implements its own flavor. POSIX tools like grep -E and sed -E do not support \d, lazy quantifiers or lookarounds, so use [0-9] there, or GNU grep -P for Perl syntax. Python spells named groups with an extra P after the question mark, uses \1 rather than $1 in replacements, requires fixed-width lookbehind, and lacks \p{L}. ripgrep, Go and RE2 run in linear time and therefore drop backreferences and lookaround entirely. In code, also watch string escaping: a backslash in a normal string literal must be doubled, which is why Python uses raw strings.