Regex Cheat Sheet: Common Patterns Every Developer Should Know
A regular expression is a compact pattern for matching, searching, and replacing text. The tokens
below — character classes, quantifiers, anchors, groups — are shared broadly across engines, but the
details differ enough to bite you. This sheet describes the JavaScript
(ECMAScript) flavor by default, because that is what runs in every browser and in Node, and it
calls out where PCRE, Java, or Python diverge. The reference tables are here to copy from; the prose in
between is where the money is — greedy-versus-lazy semantics, the Unicode traps in \w, and
the class of pattern that has taken down production systems at Cloudflare and Stack Exchange.
Keep the Regex Tester open in another tab and paste each pattern as you read. Watching matches highlight live — especially watching a lazy quantifier stop early — builds intuition faster than any table.
Character classes
Character classes match "one character of a certain kind."
| Pattern | Matches |
|---|---|
. | Any character except a newline |
\d | Any digit (0–9) |
\D | Any non-digit |
\w | Word character (letters, digits, underscore) |
\W | Non-word character |
\s | Whitespace (space, tab, newline) |
\S | Non-whitespace |
[abc] | Any one of a, b, or c |
[^abc] | Any character except a, b, or c |
[a-z] | Any lowercase letter in the range |
Here is the first trap that catches people who ship to real users. In JavaScript, \w is
exactly [A-Za-z0-9_] and \d is exactly [0-9] — both are
ASCII-only. A validator built on ^\w+$ silently rejects José,
Müller, Владимир, and every CJK name, and \d will not match the
Arabic-Indic or Devanagari digits that real keyboards produce. The fix is the Unicode flag: add
/u and use Unicode property escapes such as \p{L} (any letter
in any script) or \p{N} (any numeric character). One catch that trips people up: without the
/u flag, \p{L} does not throw — it silently means something else.
In a non-Unicode regex \p is treated as a legacy identity escape, so /^\p{L}+$/
actually matches the literal text p{L}, not "one or more letters." That silent
misinterpretation is worse than an error, because your validation quietly passes garbage. Always pair
property escapes with /u: /^\p{L}+$/u is the real "letters only" check.
Quantifiers
Quantifiers say "how many" of the preceding item to match.
| Pattern | Meaning |
|---|---|
* | 0 or more |
+ | 1 or more |
? | 0 or 1 (optional) |
{3} | Exactly 3 |
{2,5} | Between 2 and 5 |
{2,} | 2 or more |
Every quantifier above is greedy by default: it grabs as many characters as it can,
then backtracks — gives characters back one at a time — until the rest of the pattern can match. Append a
? to make it lazy (*?, +?, {2,5}?),
and it does the opposite: match the minimum, then expand only if forced. The key point that beginners
miss is that this changes which text you capture, not just how fast the engine runs:
Greedy: /<.+>/ on "<a><b>" captures "<a><b>" (one match, spanning both) Lazy: /<.+?>/g on "<a><b>" captures "<a>" then "<b>" (two matches)
The greedy version starts at the first <, races to the end of the string, then walks
backward looking for a > — so it stops at the last one and swallows everything
in between. If you were trying to pull out individual tags, greedy silently returns the whole line and
your downstream code processes garbage. This is the single most common reason a "working" pattern
extracts too much. When you want the smallest sensible chunk — one tag, one quoted string, one bracketed
token — reach for the lazy form or, better, a negated class like <[^>]+> that
cannot cross a > in the first place and needs no backtracking at all.
Anchors and boundaries
Anchors do not match characters; they match positions.
| Pattern | Matches at |
|---|---|
^ | Start of the string (or line in multiline mode) |
$ | End of the string (or line) |
\b | A word boundary |
\B | Not a word boundary |
For example, \bcat\b matches "cat" as a whole word but not the "cat" inside "category" or
"scatter." But \b inherits the same ASCII limitation as \w: in JavaScript a
word boundary is defined purely in terms of [A-Za-z0-9_]. That means \bété\b
behaves strangely around the accented characters, because the engine sees the boundary between the ASCII
t and the non-ASCII é as a word boundary. If you need script-aware boundaries,
you cannot get them from \b in JS — you build them out of \p{L} lookarounds
under the /u flag, or you switch to an engine (ICU, .NET with the right options) that offers
Unicode-aware boundaries.
Groups and alternation
| Pattern | Meaning |
|---|---|
(abc) | Capturing group — remembers the match |
(?:abc) | Non-capturing group — groups without remembering |
(?<name>abc) | Named capturing group |
a|b | Match a or b (alternation) |
\1 | Backreference to the first captured group |
Groups are what make find-and-replace powerful. With capturing groups you can reorder a date from
YYYY-MM-DD to DD/MM/YYYY in a single replace using $1,
$2, and $3 in the replacement string. Prefer non-capturing
groups ((?:...)) whenever you only need grouping for alternation or a quantifier — they
keep your numbered captures meaningful and avoid the "why is $3 undefined" confusion when
someone later wraps part of the pattern. Named groups ((?<year>\d{4}),
referenced as \k<year> inside the pattern and $<year> in a
replacement) arrived in ES2018 and are the readable choice for anything with more than
two captures.
A version note that still causes real bugs: lookbehind — (?<=...) and
(?<!...) — also landed in ES2018, but shipped years apart across browsers. Chrome and
Firefox had it long before Safari/WebKit, which only added lookbehind in Safari 16.4
(2023). If you support older iOS Safari, a pattern like (?<=\$)\d+ that works fine in
your Chrome devtools will throw a SyntaxError at parse time on a user's iPhone and take your
whole script down with it. Lookahead ((?=...)/(?!...)) has no such gap; it has
been universal for far longer.
Flags
| Flag | Effect |
|---|---|
g | Global — find all matches, and advance lastIndex between calls |
i | Case-insensitive |
m | Multiline — ^ and $ match at every line break |
s | dotAll — . also matches newline characters |
u | Unicode — enables \p{...} and correct surrogate-pair handling |
y | Sticky — match only exactly at lastIndex, no scanning ahead |
d | hasIndices — expose start/end offsets of each group (ES2022) |
Two flags cause more confusion than the rest combined, because both concern how the pattern treats line
breaks — and they are independent. By default . does not match a newline;
/^.*$/ against a multi-line string matches only up to the first \n. The
s (dotAll) flag is what makes . span lines. Separately, ^ and
$ anchor to the start and end of the whole string — not each line — unless
you add m (multiline), which makes them fire at every line boundary. People routinely add
m hoping . will cross newlines; it does not. If you want to match across an
entire multi-line block, you usually want s, sometimes both, and the two do genuinely
different jobs.
One more that quietly causes bugs: a regex with the g flag is stateful. It
carries a lastIndex property, so calling .test() on the same regex
object in a loop returns true, false, true… as
lastIndex walks forward and wraps. Never reuse a global regex across independent checks —
create a fresh one, or drop the g flag for one-shot tests.
Catastrophic backtracking (ReDoS): the failure that takes down servers
This is the section to read twice. The default regex engines in JavaScript, Python, Java, PCRE, and
most mainstream languages are backtracking engines, and a certain shape of pattern can
make them run in exponential time. The classic offenders have nested or
overlapping quantifiers: (a+)+, (a|a)*, or (.*)*. The
trap springs on an input that almost matches — a long run of as followed by a
single character that fails the pattern. Because there are many ways to partition that run of
as between the inner and outer quantifier, the engine tries every partition before
concluding failure, and the number of partitions doubles with each additional character.
Concretely: a pattern like /^(a+)+$/ against a 30-character string of as
capped with a ! can force on the order of 2³⁰ (roughly a billion) attempts and hang a
thread for seconds; add a few more characters and it is effectively forever. On a server, one crafted
request string that a user controls becomes a denial-of-service vector — this is called
ReDoS. It is not theoretical: Cloudflare's global outage on 2 July 2019
was traced to a single runaway regular expression in a WAF rule, and Stack Exchange had a
site-wide outage in 2016 from a regex trimming whitespace on a post that happened to contain a
very long line. Both were legitimate-looking patterns.
How to stay safe:
- Avoid ambiguity. If two parts of your pattern can match the same character in
multiple ways, rewrite so the split is unique — e.g. replace
(.*)*with a single.*, or use a negated class like[^"]*that cannot overlap with its delimiter. - Anchor and bound. Anchoring with
^/$and using explicit upper bounds ({1,64}rather than+) shrinks the search space. - Atomic groups and possessive quantifiers (
(?>...),a++) tell the engine never to backtrack into that section — they kill catastrophic backtracking outright, but note they exist in PCRE, Java, .NET, and Ruby, not in classic JavaScript (they were only proposed for a future ECMAScript). In JS you emulate the effect with a lookahead-plus-backreference trick, or you avoid the shape entirely. - Use a linear-time engine for untrusted input. Google's RE2
(available in Go's
regexp, and as bindings for Node, Python, and others) guarantees linear time by compiling to an automaton — the trade-off is that it flatly refuses backreferences and lookaround, the very features that make backtracking necessary. If you are matching user-supplied patterns or matching against user-supplied text at scale, RE2 removes the entire class of problem.
Ready-to-use patterns
These are practical starting points. Adapt them to your needs and always test against real data — there is no single "perfect" pattern for messy real-world input.
Email (practical, not RFC-perfect)
^[\w.+-]+@[\w-]+\.[\w.-]+$
URL (http/https)
^https?:\/\/[^\s/$.?#].[^\s]*$
Date (YYYY-MM-DD)
^\d{4}-\d{2}-\d{2}$
Time (24-hour HH:MM)
^([01]\d|2[0-3]):[0-5]\d$
Hex color
^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$
Strong-ish password (8+ chars, upper, lower, digit)
^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$
That last one uses lookahead: (?=...) checks that a condition holds at the
current position without consuming characters, so all three checks run from the same start point.
Because the lookaheads never advance and never overlap ambiguously, this pattern is safe from the
backtracking blow-up described above — it is a good template precisely because it consumes nothing until
the final .{8,}.
The one thing regex genuinely cannot do
"Don't parse HTML with regex" gets repeated so often it sounds like superstition, but it is a precise,
provable statement from formal language theory, and it is worth understanding rather than memorizing. A
classic regular expression describes a regular language — one a finite-state machine
can recognize. HTML, XML, JSON, and source code are arbitrarily nested: a
<div> can contain a <div> can contain a <div>,
to any depth. Counting matched, nested pairs requires unbounded memory, which a finite-state machine
does not have — so no true regular expression can correctly parse them. (Modern engines add
backreferences and recursion that step outside the formal definition, but you gain catastrophic
backtracking and unreadable patterns in exchange, which is a bad trade.) Use a real parser —
DOMParser in the browser, an HTML/XML library on the server, JSON.parse for
JSON. Regex is the right tool for flat, well-defined token extraction: pulling every
href value out of a known-shaped string, validating a date format, splitting a log line.
Reach for it there, and hand nesting to a parser.
Three more mistakes to avoid
- Forgetting to escape special characters. A literal dot is
\.; an unescaped.matches anything. The same applies to. * + ? ( ) [ ] { } ^ $ | \ /. When you build a pattern from a user string, escape it first or you will match far more than you meant to. - Assuming
ihandles Unicode casing. Case-insensitive matching without/uis ASCII-only, so/straße/iwill not matchSTRASSE, and Turkish dotless-i rules are simply beyond what the flag does. - Testing only the happy path. The dangerous input for a backtracking pattern is one that nearly matches, so always throw a long non-matching string at any pattern with nested quantifiers before you ship it.
Test and build your patterns
The fastest way to learn regex is to experiment with instant feedback. These tools help:
Conclusion
The tokens in this sheet cover the vast majority of real tasks — validation, search-and-replace, log
parsing, data cleanup. The parts worth internalizing beyond the tables are the ones that fail quietly or
loudly in production: greedy quantifiers that capture more than you meant, \w and
\b that quietly exclude anyone with an accent in their name, the /u flag that
\p{...} depends on, the lookbehind that older Safari cannot parse, and above all the nested
quantifiers that turn one crafted request into an outage. Build patterns incrementally against real and
worst-case data, prefer non-capturing and named groups for readability, and when your input is nested or
untrusted, hand it to a parser or a linear-time engine instead of a cleverer regex.
Frequently Asked Questions
What is the difference between greedy and lazy quantifiers?
Greedy quantifiers (*, +, {2,5}) match as much as they can and backtrack; adding ? makes them lazy (*?, +?) so they match the minimum and expand only if forced. This changes which text you capture, not just speed: /<.+>/ on <a><b> grabs the whole string, while /<.+?>/ stops at the first >. When you can, a negated class like <[^>]+> beats both because it needs no backtracking.
What is ReDoS and how do I know if my pattern is vulnerable?
ReDoS (regular-expression denial of service) is when a pattern with nested or overlapping quantifiers — (a+)+, (.*)*, (a|a)* — runs in exponential time on a long input that nearly matches, hanging the thread. The tell is ambiguity: if the engine can split the same characters between two quantifiers in many ways, it will try them all before failing. Test with a long non-matching string, remove the ambiguity, or run untrusted input through a linear-time engine like Google RE2.
Why does my regex reject accented names and non-Latin scripts?
In JavaScript \w is only [A-Za-z0-9_], \d is only [0-9], and \b is defined from those — all ASCII. So ^\w+$ rejects José or Müller. Add the /u flag and use Unicode property escapes: \p{L} for any letter, \p{N} for any number. Without /u, \p{L} does not error — it silently matches the literal text p{L}, so your check quietly passes the wrong thing.
Why doesn't my dot match across newlines?
By default . matches any character except a line break. Add the s (dotAll) flag to make . span newlines. People often add m instead, but m only changes what ^ and $ anchor to (each line rather than the whole string) — it does nothing to .. The two flags solve different problems and are frequently needed together.
Why should I not parse HTML with regex?
HTML can nest to any depth, and matching nested pairs requires unbounded memory that a finite-state machine (what a true regular expression compiles to) does not have — so it is provably impossible, not just hard. Use a real parser like DOMParser or an HTML library. Regex is the right tool for flat, well-defined token extraction: validating a date, splitting a log line, or pulling one known-shaped value out of a string.