ToolzYard Blog

Developer guides and tutorials

Text & Regex Guide

Regex Cheat Sheet: Common Patterns Every Developer Should Know

Published: June 26, 2026 • Updated: July 10, 2026 • By , Founder of ThreeWorks

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

PatternMatches
.Any character except a newline
\dAny digit (0–9)
\DAny non-digit
\wWord character (letters, digits, underscore)
\WNon-word character
\sWhitespace (space, tab, newline)
\SNon-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.

PatternMeaning
*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.

PatternMatches at
^Start of the string (or line in multiline mode)
$End of the string (or line)
\bA word boundary
\BNot 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

PatternMeaning
(abc)Capturing group — remembers the match
(?:abc)Non-capturing group — groups without remembering
(?<name>abc)Named capturing group
a|bMatch a or b (alternation)
\1Backreference 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

FlagEffect
gGlobal — find all matches, and advance lastIndex between calls
iCase-insensitive
mMultiline — ^ and $ match at every line break
sdotAll — . also matches newline characters
uUnicode — enables \p{...} and correct surrogate-pair handling
ySticky — match only exactly at lastIndex, no scanning ahead
dhasIndices — 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 i handles Unicode casing. Case-insensitive matching without /u is ASCII-only, so /straße/i will not match STRASSE, 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.