You can handle most real regex jobs with about a dozen pieces of syntax: character classes, quantifiers, anchors, groups, alternation and a few flags. This guide covers those, shows patterns we ran in JavaScript with their real results, and then explains the two things that catch out experienced people: greedy matching and catastrophic backtracking.
The syntax that does most of the work
Characters and classes
. any character except a line break
\d a digit \D not a digit
\w letter, digit or underscore \W anything else
\s whitespace \S not whitespace
[abc] a, b or c
[a-z0-9] a range
[^abc] anything except a, b, c
\. a literal dot (escape . * + ? ( ) [ ] { } ^ $ | \ to match them)
Quantifiers say how many times:
? 0 or 1
* 0 or more
+ 1 or more
{3} exactly 3
{2,5} 2 to 5
Anchors match a position, not a character:
^ start of string (or line, with the m flag)
$ end of string (or line, with m)
\b a boundary between a word character and a non-word character
Groups and alternatives
(abc) capture group
(?:abc) group without capturing
cat|dog either one
Flags: g finds all matches, i ignores case, m makes ^ and $ work per line, s lets . match line breaks, u turns on full Unicode.
Patterns we tested
Every result below comes from Node.js 22. Run them against your own data, since the point is to see where they stop being right.
Word match. \b is the difference between finding a word and finding a substring:
/\bcat\b/g on "cat concat cat's" → ["cat", "cat"]
Note it matched inside cat's, because the apostrophe isn’t a word character.
Date shape, and capture groups to rearrange it:
"2026-11-03".replace(/(\d{4})-(\d{2})-(\d{2})/, "$3/$2/$1")
// "03/11/2026"
"2026-11-03".replace(/(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/, "$<d>/$<m>/$<y>")
// "03/11/2026"
Named groups read better once a pattern has more than two groups.
URL-safe slug:
/^[a-z0-9]+(?:-[a-z0-9]+)*$/
"my-slug-2" matches. "My-Slug" and "a--b" do not.
Hex color:
/^#(?:[0-9a-f]{3}){1,2}$/i
"#FFF" and "#ff00aa" match. "#ffff" does not.
URLs in text, stopping before a closing bracket:
/https?:\/\/[^\s)]+/g
on "see https://example.com/a?b=1) and http://x.io"
→ ["https://example.com/a?b=1", "http://x.io"]
Lookbehind, to get a value after a marker without including the marker:
/(?<=\$)\d+(?:\.\d{2})?/g on "$19.99 and 20 and $5" → ["19.99", "5"]
Negative lookahead, for “lines without this word”:
/^(?!.*password).*$/i
"my secret" matches. "my Password" does not.
Trimming: /^\s+|\s+$/g on " hi " matches both ends, though most languages have a trim that is clearer.
Greedy versus lazy
Quantifiers take as much as they can. That produces the most common wrong answer in regex:
/<.+>/ on "<b>one</b> and <b>two</b>" → the entire string
/<.+?>/ on the same text → "<b>"
Adding ? makes a quantifier lazy, so it takes as little as possible. A negated class is often better still. <[^>]+> says what you really mean and avoids needless backtracking.
A JavaScript bug worth knowing
A regex with the g flag remembers where it stopped, in lastIndex. Calling .test() on it repeatedly gives alternating results:
const re = /\d{3}/g;
re.test("123 456"); // true, lastIndex is now 3
re.test("123"); // false, it started searching at index 3
re.test("123"); // true again, lastIndex reset
We got exactly that output. Don’t use g with .test(), or create the regex inside the function.
Catastrophic backtracking
This is the one that takes down production systems. Backtracking engines, which include those in JavaScript, Python, Java, Ruby and PCRE, try alternatives one at a time. Nested quantifiers give a string a huge number of ways to be divided, and on a failed match the engine tries all of them.
We timed /^(a+)+$/ on a string of a characters ending in !, so the match must fail, in Node.js 22 on a Mac. For comparison, we also timed the equivalent pattern with no nesting:
| Length of a’s | /^(a+)+$/ | /^a+$/ |
|---|---|---|
| 20 | 43 ms | under 0.1 ms |
| 24 | 336 ms | under 0.1 ms |
| 26 | 1,593 ms | under 0.1 ms |
| 28 | 5,936 ms | under 0.1 ms |
Roughly, each extra character doubles the time. By 40 characters you’d be waiting for hours, and the thread can do nothing else in that time. When an attacker triggers this on purpose, it’s called ReDoS.
Patterns like these look innocent. /^(\w+\s?)*$/, a plausible check for “words separated by spaces”, took 3,741 ms on the same 28-character input in our test. Watch for:
- A quantifier inside a quantified group:
(a+)+,(\w+\s?)* - Alternatives that overlap inside a loop:
(a|a)*,(\d|\w)+
To fix one, remove the ambiguity so there is only one way to match any text. /^(a+)+$/ is /^a+$/. /^(\w+\s?)*$/ becomes /^\w+(\s\w+)*$/, where each space must separate two words.
For patterns or input you don’t control, cap the input length and consider an engine with a linear-time guarantee. RE2, and Go’s regexp package, which uses the same approach, do not backtrack, at the cost of giving up backreferences and lookaround.
When not to use a regex
HTML and XML. Tags nest and attributes come in many forms, and a regex will fail on something eventually. Use a parser: DOMParser in a browser, or BeautifulSoup or lxml in Python.
JSON, YAML, CSV. The same reasoning applies. Quoted fields and escapes defeat simple patterns, as CSV escaping shows.
Validating dates and numbers by value. /^\d{4}-\d{2}-\d{2}$/ accepted 2026-13-45 in our test. Likewise, /\b\d{1,3}(?:\.\d{1,3}){3}\b/ matched 999.1.1.1 as an IP address. Regex checks shape, not meaning. Check shape, then parse with a real date or IP library.
Email. The pattern /^[^\s@]+@[^\s@]+\.[^\s@]+$/ is a sensible sanity check: it took [email protected] and rejected a [email protected] and a@b. Don’t go further and attempt the full address grammar, which isn’t worth it. The only true test of an address is sending it a message.
URLs. Use new URL() in JavaScript or urllib.parse in Python. They handle ports, encoding and edge cases. To see the parts of one address, use our URL Parser.
Plain substring checks. text.includes("error") is faster and easier to read than /error/. If there’s no pattern, no regex is needed.
Flavors differ
A pattern from one language won’t always work in another. Some differences that come up:
- Python 3’s
\dmatches digits in any script forstrpatterns, not only 0 to 9. Use[0-9]when you mean ASCII. - Python writes named groups as
(?P<name>...), while JavaScript uses(?<name>...). - JavaScript has no atomic groups or possessive quantifiers, which PCRE and Java offer as a way to stop backtracking.
grepwithout-Etreats+,?and|differently, and\disn’t available unless you use-P.
Testing your patterns
Build up gradually, and include inputs that should not match. A pattern that matches your examples proves little if it also matches everything else.
Our Regex Tester runs your pattern in the browser’s own JavaScript engine and highlights matches as you type, so what you see is what JavaScript will do. It isn’t a test of Python, PCRE or Java behaviour. It runs patterns in a background worker with a time limit of 1.5 seconds, so a catastrophic pattern gets stopped rather than freezing the tab. If you hit that limit, treat it as a warning about the pattern, because in production nothing will stop it for you. For a one-off edit across a block of text, Find and Replace may be all you need.