Regular Expressions
How a pattern like ^ORD-\d{4}$ actually finds text: the building blocks, groups and replacements, how backtracking engines (JavaScript) and linear-time engines (Go) walk the input, and why one innocent-looking pattern can freeze a server (ReDoS).
The 30-second version
You've probably used one already: a search box with a "regex" option, a form that checks an email address, a log search for every line ending in a 5xx status code. This page covers how to write them, and also how the engine actually runs them. That second part is where the surprises are.
Try a few. The text is highlighted wherever the pattern matches. Edit anything:
| match | whole match |
|---|---|
| #1 at 6 | “42” |
| #2 at 20 | “3” |
| #3 at 29 | “1024” |
regexp package.The same idea in Go, JavaScript and the shell
re := regexp.MustCompile(`ORD-\d{4}`) // compile the pattern once
fmt.Println(re.FindString("Shipped ORD-1042")) // ORD-1042const re = /ORD-\d{4}/; // a regex literal, between slashes
console.log("Shipped ORD-1042".match(re)[0]); // ORD-1042$ grep -E "ORD-[0-9]{4}" orders.log # print every line that matchesThe pattern is the same in all three (grep has no \d, so there a digit is spelled [0-9]). §02 explains every symbol in it, and §03 shows the Go and JavaScript calls in full. (New to Go's syntax? The five-minute primer in Async / Await §02 covers everything this page uses.)
Why there's a whole page about this
There are two very different ways to build a regex engine, and the languages you use picked different ones:
- JavaScript (and Python, Java, PCRE, Ruby) uses a backtracking engine: it tries one way to match, and when it hits a dead end it backs up and tries another. Flexible, and usually fast, but some patterns make it try billions of ways.
- Go uses a linear-time engine: it explores every possible way at once, so its time grows only in proportion to the text. The price: a few features are missing, such as backreferences (reusing text matched earlier) and lookaround (peeking at nearby text without consuming it).
The difference matters in production. One badly shaped pattern plus one carefully chosen 30-character input can freeze a Node.js server for seconds: that attack is called ReDoS. You'll watch it happen in §07, and learn the defenses in §09.
The building blocks
Here's a real pattern taken apart. It checks an order id like ORD-1042, optionally followed by a dash and a capital letter:
^ORD-\d{4}(-[A-Z])?$1. Literals: most characters match themselves
ORD matches the three letters O, R, D, in that order. Matching is case-sensitive by default, so ORD doesn't match ord. A space in the pattern matches a space in the text.
2. The dot: any one character
. matches any single character except a line break (in Go: except \n; JavaScript also skips \r and two rare Unicode line separators). c.t matches "cat", "cot", "c7t" and "c t", but not "ct" (the dot needs exactly one character) and not "coat" (that's two).
3. Character classes: one character from a set
Square brackets build a character class. It always matches exactly one character:
| Pattern | Matches one… | Example |
|---|---|---|
[aeiou] | vowel | gr[ae]y → gray, grey |
[0-9] | digit (a range: 0 through 9) | [0-9][0-9] → 42 |
[A-Za-z] | ASCII letter, either case | |
[^0-9] | character that is not a digit (^ first means "not") | |
\d | digit, same as [0-9] | \d\d → 42 |
\w | "word" character: letter, digit or _ | \w+ → user_42 |
\s | whitespace: space, tab, newline… | a\sb → "a b" |
\D, \W, \S | the opposite of \d, \w, \s | \S+ → one run of non-spaces |
4. Quantifiers: how many times
A quantifier goes right after the piece it repeats:
| Quantifier | Means | Example | Matches |
|---|---|---|---|
? | 0 or 1 (optional) | colou?r | color, colour |
* | 0 or more | ab*c | ac, abc, abbbc |
+ | 1 or more | \d+ | 7, 42, 2026 |
{4} | exactly 4 | \d{4} | 2026 |
{2,4} | 2 to 4 | \d{2,4} | 42, 2026 |
{2,} | 2 or more | a{2,} | aa, aaaa |
A quantifier applies only to the one piece right before it: ab+ is "a, then one or more b". To repeat several characters together, group them with parentheses: (ab)+ matches "ab", "abab", "ababab". §04 covers groups.
5. Anchors: where in the text
Without anchors, a pattern can match anywhere inside the text. \d{4} finds "2026" inside "Year 2026!". Anchors match a position instead of a character:
^matches at the start of the text,$at the end.\bis a word boundary: the spot between a word character and a non-word character.\bcat\bfinds "cat" in "the cat sat" but not inside "category".
So ^\d{4}$ means "the whole text is exactly four digits". When you validate input, you almost always want both anchors, otherwise "hello 2026 DROP TABLE" passes a \d{4} check.
6. Alternation: this or that
| means "or": cat|dog matches either word. This alternation has the lowest priority of anything in a regex. It splits the whole pattern (or the whole group) in two, so ^cat|dog$ means "^cat or dog$". Wrap it in a group to limit it: ^(cat|dog)$.
7. Escaping: when you mean the symbol itself
Some characters have a special meaning. These metacharacters are . * + ? ^ $ | ( ) [ ] { } \. To match one literally, put a backslash in front: \. is a real dot, \$ a real dollar sign, \( a real parenthesis.
/example.com/.test("examplexcom"); // true ← the dot matched "x"
/example\.com/.test("examplexcom"); // false ← \. only matches a real dotAn unescaped dot in a domain or a file extension is one of the most common regex bugs. It even causes security holes: an allowlist pattern api.example.com also accepts apixexample.com, a domain anyone can register.
Test, find, replace: Go, JavaScript and grep
Here are all three on the same text, side by side. The pattern ORD-(\d{4}) is "ORD-, then four digits". The parentheses mark the digits as group 1, so the replacement can reuse them (§04 has the details).
package main
import (
"fmt"
"regexp"
)
// Compile once, at package level. MustCompile panics on a bad pattern.
var orderID = regexp.MustCompile(`ORD-(\d{4})`)
func main() {
text := "Shipped ORD-1042 and ORD-2077; ORD-12 is invalid."
fmt.Println(orderID.MatchString(text)) // test: is there a match?
fmt.Println(orderID.FindString(text)) // find: the first match
fmt.Println(orderID.FindAllString(text, -1)) // find all (-1 = no limit)
fmt.Println(orderID.ReplaceAllString(text, "#${1}")) // replace, using group 1
}Both print the same thing (real output):
true
ORD-1042
[ORD-1042 ORD-2077]
Shipped #1042 and #2077; ORD-12 is invalid.JavaScript prints the array as [ 'ORD-1042', 'ORD-2077' ], but the values are the same. ORD-12 is skipped because \d{4} needs four digits.
When the pattern is a string: new RegExp
If the pattern is built at runtime, JavaScript has new RegExp(source, flags). The catch: the pattern is now a normal string, so every backslash must be doubled:
new RegExp("\\d{4}", "g"); // same as /\d{4}/g ← "\\d" in a string is the two characters \d
new RegExp("\d{4}"); // wrong: "\d" in a string is just "d", so this is /d{4}/If you insert user text into a pattern, escape it first, or a "." in their input becomes "any character". Go has regexp.QuoteMeta(s). JavaScript added RegExp.escape(s) in Node 24 (and Chrome 136, Firefox 134, Safari 18.2); on older runtimes, use s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"). (In a replacement, $& means "the whole match", so every special character gets a \ put in front of it.)
Flags you'll use
| Flag (JS) | In Go | Effect |
|---|---|---|
g | use the …All… functions | find every match instead of the first |
i | (?i) at the start | ignore case: /ord/i matches "ORD" |
m | (?m) | ^ and $ match at the start and end of each line, not only of the whole text |
s | (?s) | . also matches a newline |
Go has no slash syntax, so flags go inside the pattern: regexp.MustCompile(`(?i)ord-\d+`). JavaScript has more flags (u, v, y, d); §06 and §11 cover the ones worth knowing.
grep -E: regex on files from the shell
grep prints every line of a file that matches a pattern. With -E it uses "extended" syntax, close to what you've seen so far. It has no \d, so write [0-9]:
$ cat access.log
GET /api/users 200
POST /api/orders 500
GET /health 200
GET /api/orders/7 503
$ grep -E " 5[0-9]{2}$" access.log
POST /api/orders 500
GET /api/orders/7 503The pattern means: a space, a 5, two more digits, then the end of the line. Useful options: -i ignores case, -o prints only the matching part, -c counts matching lines, -v prints lines that don't match.
Groups: capture, name, reuse
| applies to all of them, and they capture the text that part matched, so you can read it or reuse it in a replacement.Capture groups: numbered from the left
Each ( starts a capture group. Groups are numbered 1, 2, 3… by the position of their opening parenthesis. Group 0 is always the whole match. In (\d{4})-(\d{2})-(\d{2}) on "2026-10-31", group 1 is "2026", group 2 is "10", group 3 is "31".
Add names and the code gets much easier to read. A named group is written (?<name>…):
var date = regexp.MustCompile(`(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})`)
func main() {
m := date.FindStringSubmatch("Released on 2026-10-31.")
fmt.Println(m) // [whole year month day]
fmt.Println(m[date.SubexpIndex("month")]) // look a group up by name
// Swap to day/month/year in a replacement:
fmt.Println(date.ReplaceAllString("2026-10-31", "${day}/${month}/${year}"))
// Backreferences aren't part of Go's syntax:
_, err := regexp.Compile(`(\w+) \1`)
fmt.Println(err)
}Real output, Go first, then JavaScript:
[2026-10-31 2026 10 31]
10
31/10/2026
error parsing regexp: invalid escape sequence: `\1`2026-10-31 2026 10
31/10/2026
[ 'is is', 'test test' ]
This is a test.Non-capturing groups: grouping without remembering
Sometimes you need parentheses only for grouping, like (?:https?|ftp):// where | must apply to the protocols only. A non-capturing group (?:…) groups without creating a numbered group. It keeps the numbers of the groups you care about stable, and it's slightly cheaper.
Backreferences: "the same text again"
Inside the pattern, \1 is a backreference: it matches the exact text that group 1 matched on this attempt. \b(\w+) \1\b finds a word, a space, and the same word again. That's how the doubled-word finder above works. In JavaScript you can also write \k<name> for a named group.
Go doesn't support backreferences. That's a design choice, not an oversight: matching "whatever text group 1 happened to capture" can't be done in guaranteed linear time, and linear time is the promise Go's engine makes. §08 explains why.
Try it
Every preset uses your browser's real RegExp. Each group gets its own color, and the table shows what each one captured. Notice the optional group preset: a group that didn't take part in the match is undefined in JavaScript (Go gives an empty string "").
| match | $& (whole match) | $1 | $2 | $3 |
|---|---|---|---|---|
| #1 at 8 | “2026-10-31” | “2026” | “10” | “31” |
| #2 at 24 | “2026-11-07” | “2026” | “11” | “07” |
${1} instead of $1.Going through every match with its groups
To get the groups of every match, loop. In JavaScript use matchAll (it requires the g flag); in Go use FindAllStringSubmatch:
re := regexp.MustCompile(`ORD-(?<num>\d{4})`)
for _, m := range re.FindAllStringSubmatch("Shipped ORD-1042 and ORD-2077.", -1) {
fmt.Println(m[0], m[1]) // ORD-1042 1042, then ORD-2077 2077
}For replacements that need logic (look a value up, format a number), JavaScript lets you pass a function instead of a string: text.replace(re, (whole, g1) => …), which receives the whole match and then each group. Go's re.ReplaceAllStringFunc(text, func(s string) string { … }) only receives the whole match, with no groups. When you need them in Go, call re.FindStringSubmatch(s) on that match inside the function, or loop over re.FindAllStringSubmatchIndex(text, -1) (the start and end positions of every match and group) and build the result yourself.
How JavaScript matches: try, fail, back up
That's how the regex engines in JavaScript (V8's engine, called Irregexp), PCRE (used by PHP and many tools), Java, Python, Ruby and .NET work. It's called backtracking.
Where the forks are
A pattern has a fork wherever there's a choice:
- Quantifiers (
*,+,?,{2,5}): "take one more character, or stop here?" - Alternation (
|): "try the left side, or the right side?" - The starting position: if the pattern doesn't match starting at position 0, the engine tries again from position 1, then 2, and so on.
Greedy vs lazy: which way first?
By default every quantifier is greedy: at each fork it tries "take one more" first, and only gives characters back if the rest of the pattern fails. Add a ? after it (*?, +?) and it becomes lazy: it tries "stop here" first, and takes more only when it has to.
Both can find a match; they find different ones. On <b>hi</b>:
console.log("<b>hi</b>".match(/<.+>/)[0]); // <b>hi</b> ← greedy: the longest it can
console.log("<b>hi</b>".match(/<.+?>/)[0]); // <b> ← lazy: the shortest it canStep through both and watch the engine work. In greedy .+, the .+ swallows the whole rest of the text, hits the end of the text and stops (blue: that's not a backtrack yet, nothing has been undone). Then > fails at the end, so the engine backs up: .+ gives back one character at a time until > can match. Here that takes just one character. In lazy .+? it stops as early as possible and is done in 3 tests.
Now try the other two presets:
- alternation:
c(at|ar)striesatfirst, fails on ther, backs up to the|and triesar. You'll also see the engine slide the starting position right four times before thecmatches. - (a+)+ on aaa!: a pattern with a quantifier inside a quantifier, on a text that almost matches. Watch the backtrack counter. Four characters already take 23 tests and 6 backtracks, because there are many ways to split "aaa" into groups and the engine tries every one before it can say "no". That's the seed of the problem in §07.
The first match wins, not the best one
A backtracking engine stops at the first path that succeeds. So for alternation, order matters: cat|category on "category" matches just "cat", because cat is tried first and succeeds. Put the longer alternative first (category|cat), or add a \b so the short one can't stop mid-word. This rule is called leftmost-first: the earliest start position wins, and at that position the first successful path wins.
Why the engine works this way
Backtracking is simple to build and easy to extend. "Match whatever group 1 captured" (a backreference) or "check what comes next without consuming it" (lookahead, §06) are just more things to try along the current path. That flexibility is why most languages chose it. The cost is that the number of paths can explode, and the engine has no way to know that until it has walked them.
Lookahead, lookbehind, and JavaScript's other flags
$ comes right before them."So far every piece of a pattern has consumed characters: matched them and moved past. Anchors like ^ and \b are different: they check a position and consume nothing (they're "zero-width"). Lookarounds are anchors you write yourself:
| Syntax | Name | Succeeds when the text… |
|---|---|---|
X(?=Y) | lookahead | right after X is Y |
X(?!Y) | negative lookahead | right after X is not Y |
(?<=Y)X | lookbehind | right before X is Y |
(?<!Y)X | negative lookbehind | right before X is not Y |
const text = "Total: $42, €17, $1999";
console.log(text.match(/(?<=\$)\d+/g)); // [ '42', '1999' ] digits preceded by $
console.log(text.match(/(?<!\$)\b\d+/g)); // [ '17' ] digits NOT preceded by $The $ isn't part of the result: the lookbehind checked it, but didn't consume it. (In the second pattern, \b stops the engine from starting in the middle of "42" at the "2", which is preceded by "4", not "$".)
A classic use: several rules at once
Lookaheads at the start of a pattern each check one rule from the same starting point, because none of them moves forward:
// at least one digit, one lowercase, one uppercase, and 12+ characters in total
const strong = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{12,}$/;
console.log(strong.test("correcthorse"), strong.test("Correct-horse-9")); // false trueIt works, but three separate checks in code (/\d/.test(pw) && /[a-z]/.test(pw) && …) are easier to read and give better error messages. Regex lookarounds are a tool, not a goal.
Support: JavaScript yes, Go no
- JavaScript: lookahead has always been there. Lookbehind came in ES2018. Every current browser has it, but Safari only added it in version 16.4 (March 2023), so if you support older iPhones, a lookbehind is a syntax error that breaks the whole script file. Node has had it since well before any version that's still supported.
- Go: no lookaround at all;
regexp.Compilereturns an error. Like backreferences, it doesn't fit the linear-time design (§08). The usual workaround is to capture the part you want and match the context normally:
var price = regexp.MustCompile(`\$(\d+)`) // capture the digits instead of looking behind
for _, m := range price.FindAllStringSubmatch("Total: $42, €17, $1999", -1) {
fmt.Println(m[1]) // 42, then 1999
}For "not followed by", it's often simplest to match broadly and then filter with an if in code.
JavaScript's other flags
You met g, i, m and s in §03. Four more exist. Here's what they do and since when:
| Flag | Name | What it does | Since |
|---|---|---|---|
u | unicode | Treats the pattern and text as Unicode characters, not 16-bit pieces; enables \p{…} (§11). Also makes typos like \q a syntax error instead of silently matching "q". | ES2015 |
v | unicodeSets | Everything u does, plus set operations inside classes like [\p{Letter}--[a-z]] (§11). Use u or v, not both. | ES2024 · Node 20, Chrome 112, Firefox 116, Safari 17 |
y | sticky | Matches only exactly at lastIndex, never searching further. Used to write tokenizers (code that splits text into pieces one after another). | ES2015 |
d | hasIndices | Adds .indices to each match: the [start, end] of every group. The playground in §04 uses it to color groups. | ES2022 · Node 16 |
const word = /\w+/y; // y = sticky: match only exactly at lastIndex
word.lastIndex = 4;
console.log(word.exec("say hello")?.[0]); // hello
word.lastIndex = 3;
console.log(word.exec("say hello")); // null: position 3 is a space, and y won't search aheadGo has no flag for "sticky" or "indices": FindStringSubmatchIndex returns the positions directly, and anchoring with ^ on a slice of the text gives sticky behavior.
Catastrophic backtracking
That's catastrophic backtracking. Here's the classic example, run for real in Node:
const re = /^(a+)+$/; // nested quantifiers: the classic ReDoS shape
re.test("aaaa"); // warm-up run, so we time the matching, not V8 preparing the regex
for (const n of [16, 20, 24, 26, 28, 30]) {
const input = "a".repeat(n) + "!"; // almost matches, then fails at the end
const start = performance.now();
re.test(input);
const ms = performance.now() - start;
console.log(`${n} a's + "!": ${ms.toFixed(1)} ms`);
}16 a's + "!": 0.5 ms
20 a's + "!": 6.7 ms
24 a's + "!": 105.7 ms
26 a's + "!": 445.8 ms
28 a's + "!": 1980.8 ms
30 a's + "!": 7250.7 msThirty-one characters, seven seconds, one CPU core at 100%, and during all of that the Node process can't do anything else. The same pattern in Go:
30 a's + "!": false in 40µs
1000 a's + "!": false in 104µs
1000000 a's + "!": false in 70.785msCounting the paths
Why does ^(a+)+$ do this? Read it as "one or more groups, each made of one or more a's". On a run of a's, the engine can split them into groups in several ways, and it tries them one after another. Step through them, then switch from 3 to 4 to 5 a's:
$ needs the end of the text but finds “!”. Fail, back up, try the next split.That's 4 ways for 3 a's, 8 for 4, 16 for 5. Each extra a doubles the number (between every pair of neighboring a's, there's either a group boundary or not: 2 choices, n − 1 times, so 2ⁿ⁻¹ ways). If the text is all a's, the very first split succeeds and nobody notices. But with a ! at the end, $ fails on every split, and the engine can only conclude "no match" after trying all of them. 30 a's: over 500 million splits.
How do 8 splits become the 47 tests the stepper counted for "aaaa!"? Each split costs a few tests (read the a's, try one more a and hit "!", check $), and the engine also checks $ after shorter groupings while a's are still left. Together that's 3·2ⁿ − 1 tests for n a's: still exactly doubling per extra a.
Grow the input yourself. The counts come from the same small engine as the stepper in §05:
"aaaaaaaaaa!"A quantifier inside a quantifier: n a's can be split into groups in 2ⁿ⁻¹ ways, and the engine tries every split before it gives up.
The lockstep line barely moves: that's Go's approach, explained in §08. The button times your browser's real JavaScript engine; the step counts and your browser's times grow at the same rate.
The three dangerous shapes
The rule behind all of them: a stretch of text can be matched in more than one way, and something after it can fail.
- A quantifier inside a quantifier.
(a+)+,(\d+)*,(\w+\s?)*. The last one looks innocent ("words, separated by optional spaces"), but because the space is optional, "abc" can be one word, or "ab"+"c", or "a"+"b"+"c"… - Alternatives that overlap.
(a|aa)+,(\w|\d)+,(.|\s)*. A digit is also a\w, so each digit can be matched by either side.(.|\s)*is a real pattern people write to mean "anything, including newlines"; use thesflag or[\s\S]instead. - Two neighbors that can trade characters, then a failure.
\d+\d+x,.*.*=,\s*\s*$. Each character between them could belong to the left part or the right part. This usually grows like n² or n³ instead of 2ⁿ: slower to blow up, but enough to take down a server with long inputs (§09 has a real outage caused by this).
How the fix looks
You fix catastrophic backtracking by rewriting the pattern so each character can be matched only one way:
const slow = /^(\w+\s?)*$/; // words separated by optional single spaces
const fast = /^(\w+\s)*\w*$/; // matches exactly the same strings, one way each
const input = "a".repeat(26) + "!";
for (const [name, re] of [["slow", slow], ["fast", fast]]) {
re.test("warm up");
const start = performance.now();
const ok = re.test(input);
console.log(name, ok, `${(performance.now() - start).toFixed(1)} ms`);
}slow false 477.9 ms
fast false 0.0 msIn fast, every word in the repeated group must end with a space, so a run of letters can only be read one way. (We checked that both patterns accept exactly the same strings, by testing every string of up to 9 characters made of "a", " " and "!".) The same idea fixes the classic: ^(a+)+$ accepts exactly what ^a+$ accepts.
How Go matches: every path at once
A regex is a map of states
Any regex built from the pieces in §02 (characters, classes, quantifiers, groups, |) can be drawn as a map: circles are states ("how far through the pattern am I?"), and arrows labeled with a character say where reading that character takes you. That map is called an automaton. For c(at|ar)s, state S1 has two arrows labeled a: one for "at", one for "ar". That's a fork.
There are two ways to walk a map with forks. Switch between them:
cars one character at a time.The backtracker (one finger) had to read a twice, because it guessed wrong at the fork and backed up. The lockstep walker (coins) read every character once, and the coin that guessed wrong just disappeared. No guesses, nothing to undo.
Why this can't blow up
The number of coins is limited by the number of states, and a state never holds two coins: if two paths lead to the same state, they merge into one coin. A pattern with m states and a text with n characters means at most m × n moves. Double the text, double the work. That's what "linear time" means, and it holds for any input, including one an attacker wrote.
Remember ^(a+)+$, with its 2ⁿ⁻¹ ways to split the a's? Here it is compiled into the instruction list a lockstep engine runs, close to what Go's regexp/syntax package produces. Before you step through it, here's how to read it.
a”. 1 thread waiting to read (bright rows). Faint rows were passed through to get there. Every thread now tries the next character together.All those different splits of the a's lead to the same instruction ("read an a"), so they collapse into a single thread. One thread per character: 32 steps in total for "aaaa!", while the backtracker needs 47 tests on the same five characters. (A lockstep step is one line visited; a backtracking test is one piece of the pattern tried against the text. The units differ a little, but the trend is what matters.) Add an a and the lockstep count grows by 7; the backtracking count doubles.
What Go gives up, and why
A coin only knows which state it's in. It doesn't carry a copy of the text it has read. That's what keeps the state map small, and it's also why two features can't exist:
- Backreferences (
\1): "match the same text group 1 captured" needs to remember that text, which can be any length. No fixed set of states can do that. (Computer scientists have shown that matching with backreferences is NP-complete: no known method can promise even polynomial time.) - Lookaround (
(?=…),(?<=…)): in principle some of it could be built, but it doesn't fit the one-pass, one-character-at-a-time walk without extra cost, so Go and RE2 leave it out.
Go also limits counted repeats to 1000: a{1001} is a compile error, because {n} copies its piece n times into the program, and a huge program would cost time on every character.
Same answers, mostly
Go uses the same leftmost-first rule as JavaScript: each coin carries a priority, in the order a backtracker would have tried the paths, and the highest-priority coin that reaches the end wins. So a.*?c on "abcbc" gives "abc" in both languages, and cat|category on "category" gives "cat" in both. (If you want "longest match wins" instead, Go has regexp.CompilePOSIX; for cat|category it returns "category".)
The practical differences are about features, not results:
| JavaScript (backtracking) | Go regexp (linear) | |
|---|---|---|
| Worst case | exponential in the text length | proportional to the text length |
Backreferences \1 | yes | no (compile error) |
| Lookahead / lookbehind | yes | no (compile error) |
| Named groups | (?<name>…) | (?<name>…) (Go 1.22+), (?P<name>…) |
Lazy quantifiers *? | yes | yes |
| Untrusted patterns | dangerous | safe to run (still limit their size) |
Staff-level: ReDoS in production
ReDoS is an algorithmic complexity attack: a small request makes your server do enormous work. In Node.js it's especially painful, because a regex runs on the single Event loop thread (see Async / Await §07). While one test() grinds, every other request on that process waits: one 30-byte request can stall thousands of users.
It has happened, at scale
- Stack Overflow, July 2016 (from their public postmortem). A regex that trimmed whitespace,
^[\s\u200c]+|[\s\u200c]+$, (\u200cis an invisible character, the "zero-width non-joiner") met a post containing about 20,000 consecutive whitespace characters. That's the quadratic shape from §07: roughly 200 million steps per page render. The post was on the home page, the load balancer's health check requested the home page, the checks timed out, and servers were taken out of rotation. The site was down for about 34 minutes. - Cloudflare, July 2019 (from their public postmortem). A new rule for their WAF (web application firewall) contained
.*(?:.*=.*): several.*that can trade characters, the third shape from §07. It was deployed to every server at once, and CPUs serving HTTP traffic worldwide hit 100%. Sites behind Cloudflare returned errors for about 27 minutes. Afterwards Cloudflare said it would move to a regex engine with linear-time guarantees. - Libraries, constantly. ReDoS is one of the most common advisory types in the npm ecosystem: for example CVE-2022-25883 in
semver(its version-range parser) and CVE-2022-31129 inmoment(its date-string parser). You often inherit the pattern through a CVE-tagged dependency, as in thenpm auditexample in OWASP Top 10 §07.
Where dangerous patterns hide
- Validation of user input: emails, URLs, phone numbers, user agents. Long, clever patterns copied from the internet are the classic source.
- Log and text processing on data you don't control: parsing headers, scraping, "extract all X from this upload".
- User-supplied patterns. A search box with "regex mode", a rule engine, a MongoDB
$regexfilter built from request JSON (the operator-injection risk in Injection §10). Here the attacker writes the pattern and the input, so no amount of pattern review helps.
Defense 1: write patterns that can't blow up
Make every character matchable in only one way (§07): no quantified groups that contain quantifiers, alternatives that can't match the same text, and specific classes ([^"]*, not .*?) between delimiters. Let a tool check for you: the ESLint plugin eslint-plugin-regexp has a no-super-linear-backtracking rule, and static analyzers like CodeQL flag known-dangerous shapes. A Linter check costs nothing and runs on every pull request.
Other backtracking engines offer an escape hatch JavaScript and Go don't have: atomic groups (?>…) and possessive quantifiers a++ (in PCRE, Java, .NET, and Python's re since 3.11). They tell the engine "once this part has matched, never come back into it", which deletes the extra paths.
Defense 2: limit the input before matching
Exponential patterns need only ~30 characters, so a limit doesn't fix them. But most real ReDoS bugs are polynomial, and for those a length check turns "minutes" into "milliseconds". It's also good Input validation anyway: an email address over 254 characters is invalid, and a username over 64 is suspicious.
if (email.length > 254 || !EMAIL_RE.test(email)) { // ← check the length FIRST
return res.status(400).json({ error: "invalid email" });
}Defense 3: put a time limit on it (JavaScript)
You can't stop a running regex from the same thread: a setTimeout callback only runs after the current code finishes, so it would wait for the regex too. Run the match on a Worker thread instead, and kill the thread if it's too slow:
import { Worker } from "node:worker_threads";
// Run one regex test on a worker thread; kill the thread if it takes too long.
function testWithTimeout(source, input, ms) {
return new Promise((resolve, reject) => {
const worker = new Worker(
`const { parentPort, workerData: w } = require("node:worker_threads");
parentPort.postMessage(new RegExp(w.source).test(w.input));`,
{ eval: true, workerData: { source, input } },
);
const timer = setTimeout(() => {
worker.terminate(); // ← stops the runaway regex
reject(new Error(`regex timed out after ${ms} ms`));
}, ms);
worker.once("message", (ok) => { clearTimeout(timer); worker.terminate(); resolve(ok); });
worker.once("error", (err) => { clearTimeout(timer); reject(err); });
});
}
console.log(await testWithTimeout("^(a+)+$", "aaaa", 200));
try {
await testWithTimeout("^(a+)+$", "a".repeat(40) + "!", 200);
} catch (err) {
console.log(err.message);
}true
regex timed out after 200 ms
real 0m0.407sWithout the worker, the 40-character test would run for hours. Starting a thread costs a few milliseconds, so in a real service keep a small pool of workers instead of one per call. A lighter option is Node's vm module: vm.runInContext(code, ctx, { timeout: 100 }) also interrupts a runaway regex (we got ERR_SCRIPT_EXECUTION_TIMEOUT after 101 ms), but it blocks the event loop for those 100 ms.
(.NET has this built in: new Regex(pattern, options, TimeSpan.FromMilliseconds(100)). Java, Python and JavaScript don't.)
Defense 4: use a linear-time engine
This is the only defense that also covers user-supplied patterns.
- Go: you already have one.
regexpis linear in the input for every pattern. Two caveats: a user can still send a huge pattern or a huge text (linear isn't free), so limit both sizes; and useregexp.Compile, notMustCompile, so a bad pattern returns an error instead of panicking. - Node: the
re2package on npm wraps Google's RE2 library (C++) with an API close toRegExp:new RE2("^(a+)+$").test(input). It's a native addon, so it needs a compiler at install time or a prebuilt binary for your platform. (We didn't run it for this page.) Patterns using backreferences or lookaround throw, exactly like Go. - V8's own linear engine: V8 contains an experimental non-backtracking engine. In Node 22 (tested on this machine),
node --enable-experimental-regexp-engineadds anlflag (/^(a+)+$/lran 100,000 a's in ~10 ms, and/(a)\1/lwas rejected with "Cannot be executed in linear time").--enable-experimental-regexp-engine-on-excessive-backtracksswitches a regex to it automatically after too many backtracks; with that flag,redos.mjsfrom §07 finished every size in under 1 ms. We checked this on Node 22.23 only (October 2026): both are V8 flags, off by default and described as experimental, not a supported Node.js feature. Checknode --v8-optionson your own Node version before relying on them, and don't build your safety on them.
When not to use a regex
Why nesting is out of reach
Think back to the state map in §08. A state map has a fixed number of states, so it can only remember a fixed amount about what it has read. To check that ((())) is balanced, you have to remember how many parentheses are still open, and that number can be anything. No fixed set of states can count without limit. (In computer-science terms: regular expressions describe regular languages, and balanced brackets aren't one. That's literally where the name "regular expression" comes from.)
HTML, JSON, XML, programming languages, and even URLs with all their rules have nesting or context that changes the meaning of the next character. A regex that "works" on them works on the examples you tried.
HTML: the regex finds links that aren't there
const html = `<a href="/a">A</a> <a title="x>y" href="/b">B</a> <!-- <a href="/old">old</a> -->`;
const links = [...html.matchAll(/<a href="([^"]*)">/g)].map((m) => m[1]);
console.log(links);[ '/a', '/old' ]It misses /b (the attribute order is different) and "finds" /old, which is inside a comment and isn't a link at all. Every fix adds a new case, and attackers know the cases you forgot: regex-based HTML "sanitizers" are a classic source of XSS holes. Use a real HTML parser: in the browser, new DOMParser().parseFromString(html, "text/html").querySelectorAll("a[href]"); in Go, the golang.org/x/net/html package.
JSON: right text, wrong meaning
const body = '{"invitedBy": {"name": "Ada", "admin": true}, "admin": false}';
console.log(/"admin":\s*true/.test(body)); // regex: "admin is true somewhere"
console.log(JSON.parse(body).admin); // parser: this user's admin is falsetrue
falseThe regex can't tell the inviter's admin from this user's admin, because "which object am I inside?" is nesting. A check like this in an authorization path is a privilege-escalation bug. Go behaves the same way: regexp says true, json.Unmarshal into a struct with an Admin bool field says false. Always parse JSON with JSON.parse / encoding/json, then look at the fields.
Often you don't need a regex at all
| Task | Instead of a regex | Use |
|---|---|---|
| Starts with / contains | /^https:/, /foo/ | s.startsWith("https:"), s.includes("foo"); Go strings.HasPrefix, strings.Contains |
| Split on one character | /,/ | s.split(","); Go strings.Split(s, ",") |
| Key=value | /^(\w+)=(.*)$/ | Go key, value, ok := strings.Cut(s, "="); JS s.indexOf("=") + slice |
| Is it a number? | /^-?\d+$/ | Go strconv.Atoi; JS Number.isInteger(Number(s)) (watch out: Number("") is 0) |
| Trim whitespace | /^\s+|\s+$/g | s.trim(); Go strings.TrimSpace (and it can't ReDoS, see §09) |
| Parse a URL | anything | new URL(s); Go url.Parse(s) |
| Is it an email? | a 400-character pattern | a simple sanity check (^[^@\s]+@[^@\s]+$), then send a confirmation email. That's the only real test. |
The string functions are clearer, faster, and can't backtrack.
Staff-level: Unicode and performance
What is "one character"?
Text is stored as numbers. Unicode gives every character a number (a Code point); an encoding decides how that number becomes bytes. JavaScript strings are made of 16-bit pieces (UTF-16 code units), and characters outside the first 65,536 code points, like most emoji, take two of them. Go strings are UTF-8 bytes, and Go's regexp reads them one code point (a Rune) at a time. The Text & Encodings topic covers this in depth.
That's why, without a flag, a JavaScript regex can see half an emoji:
console.log("😀".length); // 2: JS strings count UTF-16 units
console.log(/^.$/.test("😀")); // without u: . sees half an emoji
console.log(/^.$/u.test("😀")); // with u: . is one code point
console.log("Zoë Ångström".match(/\p{Letter}+/gu)); // any script's letters
console.log("café, naïve".match(/[\p{Letter}--[a-z]]/gv)); // v: set subtraction
console.log(/^\p{RGI_Emoji}$/v.test("👍🏽")); // v: multi-code-point emoji2
false
true
[ 'Zoë', 'Ångström' ]
[ 'é', 'ï' ]
trueGo: . already matches one code point, and \p{L} works with no flag. But \w, \d and \b are ASCII-only in both languages, even with u:
regexp.MustCompile(`^.$`).MatchString("😀") // true: Go works on UTF-8 code points
regexp.MustCompile(`^\w+$`).MatchString("café") // false: \w is ASCII-only
regexp.MustCompile(`^[\p{L}\p{N}_]+$`).MatchString("café") // true: the Unicode version of \wSo a username rule like ^\w{3,20}$ rejects "José" and "Zoë". If that's a product bug for you, use [\p{L}\p{N}_] (JS: with u). Also remember that "é" can be one code point or "e" plus a combining accent; normalize text first (s.normalize("NFC") in JS, golang.org/x/text/unicode/norm in Go) if you compare or validate it.
Compile once, not on every call
Turning a pattern into an engine-ready program takes real work. In Go it's explicit, so it's easy to do in the wrong place:
var email = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[a-z]{2,}$`)
func BenchmarkCompileEveryCall(b *testing.B) {
for b.Loop() {
re := regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[a-z]{2,}$`) // ← compiled on every call
re.MatchString("ada@example.com")
}
}
func BenchmarkCompileOnce(b *testing.B) {
for b.Loop() {
email.MatchString("ada@example.com")
}
}BenchmarkCompileEveryCall-4 129205 8574 ns/op 5938 B/op 69 allocs/op
BenchmarkCompileOnce-4 3027198 402.8 ns/op 0 B/op 0 allocs/opAbout 20× slower and 69 memory allocations per call, versus none. Put patterns in package-level vars (a benchmark like this one is how you'd catch it). A compiled *regexp.Regexp is safe to use from many goroutines at once.
In JavaScript the gap is much smaller: V8 keeps a cache of compiled patterns keyed by source and flags, so a regex literal inside a loop usually reuses the compiled code. Hoisting the regex out of the loop is still the clearer habit, and it avoids the lastIndex surprises of §03 when the regex has a g flag.
What makes a regex fast (or slow)
- A literal start helps. Engines look for a fixed prefix like
ORD-with a fast string search, and only run the full matcher where it appears. A pattern starting with.*or\w*can't use that. - Anchor what you validate.
^…$lets a backtracker give up after one starting position instead of trying every one. - Be specific.
"[^"]*"beats".*?": the class can never run past the closing quote, so there's nothing to back up into. - Go's regexp isn't the fastest on easy patterns. Its lockstep engine has a higher cost per character than a backtracker that guesses right, so for simple patterns V8 or PCRE can be several times faster. Go picks among three internal strategies (a one-pass matcher for patterns with no ambiguity, a backtracker that remembers which instruction/position pairs it has already tried so it never repeats work, and the lockstep engine), and all three are linear. It trades some average speed for a guaranteed worst case. If a profile shows regexp on a hot path, the first fix is usually a plain
stringsfunction.
Pitfalls gallery
^, a dot that should have been \., or a flag that does more than you thought.Validating without anchors
/\d{5}/.test(zip) is true for "12345", and also for "abc123456789xyz". Without ^ and $, the pattern only has to appear somewhere. For validation, always anchor the whole thing: /^\d{5}$/. Watch alternation too: /^yes|no$/ accepts "yesterday" and "piano", because | splits the anchors apart. Write /^(yes|no)$/.
The unescaped dot
/^api.example.com$/ matches "apixexample.com". In an allowlist of hosts or redirect URLs, that's an attacker-registrable domain. Escape it (api\.example\.com), or better, don't use a regex: parse the URL and compare url.hostname === "api.example.com".
g on a regex you test() with (JavaScript)
A g regex remembers lastIndex between calls, so the same input alternates between true and false (§03). Keep g for match, matchAll and replace, and never on a shared regex used for test().
$1x in a Go replacement
re := regexp.MustCompile(`(\d+)`)
fmt.Printf("%q\n", re.ReplaceAllString("order 42", "$1st")) // "order "
fmt.Printf("%q\n", re.ReplaceAllString("order 42", "${1}st")) // "order 42st"Go reads the longest name it can after $, so $1st means "the group named 1st", which doesn't exist and becomes empty. No error, no warning. Always use braces in Go: ${1}, ${name}.
Greedy .* eating too much
/"(.*)"/ on say "hi" and "bye" captures hi" and "bye: the .* runs to the end and backs up only to the last quote (§05). Use "([^"]*)" (any characters except a quote), which is also immune to backtracking blow-ups.
MustCompile on a pattern you didn't write (Go)
regexp.MustCompile(r.URL.Query().Get("q")) panics on the first invalid pattern a user sends. Use regexp.Compile and return a 400 on error. And limit the pattern's length: linear time doesn't help if the pattern itself is a megabyte.
A pattern copied from the internet
That 300-character email regex from a forum answer may contain nested quantifiers, and you'll find out from a CPU alert. Before using a found pattern on outside input: run it through a linter rule like regexp/no-super-linear-backtracking (§09), add a length limit, and write tests for what it should reject, not only what it should accept.
Assuming \w means 'letter'
\w is [A-Za-z0-9_] in both Go and JavaScript. It rejects "José", and it accepts "_" and digits. Say what you mean: [A-Za-z], or \p{L} for letters in any language (§11).
A lookbehind that breaks old browsers
Lookbehind (?<=…) is a syntax error in Safari before 16.4. Because regex literals are parsed when the script loads, one lookbehind can stop your whole bundle from running on those devices, not just the one feature. Check your browser-support targets, or capture instead of looking behind (§06).
Check yourself
Predict the answer before you click. Q1 is covered in §05, Q2 in §03, Q3 in §07, Q4 in §04 and §08, Q5 in §12, and Q6 in §09.
console.log('<a href="x">link</a>'.match(/<.+>/)[0]);const re = /cat/g;
console.log(re.test("cat"), re.test("cat"), re.test("cat"));The input is attacker-controlled and up to 10,000 characters long.
re, err := regexp.Compile(`(\w+) \1`)
fmt.Println(re == nil, err != nil)re := regexp.MustCompile(`(\d+)`)
fmt.Printf("%q\n", re.ReplaceAllString("order 42", "$1st"))const timer = setTimeout(() => abort(), 100);
const ok = /^(a+)+$/.test(userInput); // 40 a's + "!"
clearTimeout(timer);Cheat sheet
^…$ around the whole pattern, and a group around any |: ^(yes|no)$.. * + ? ^ $ | ( ) [ ] { } \ need a \ to match themselves. Escape user text: regexp.QuoteMeta, RegExp.escape (Node 24+).* + take as much as they can, then give back. *? takes as little as it can. [^"]* usually beats both.( ) captures, (?: ) only groups, (?<name> ) names. Replace with $1 / $<name> (JS) or ${1} / ${name} (Go).\1, no lookaround.(a+)+, overlapping alternatives (a|aa)+, neighbors that trade characters .*.*=, then a failure.| Syntax | Meaning |
|---|---|
. | any character except newline (s flag: including newline) |
[abc] [a-z] [^0-9] | one character from the set / range / not in the set |
\d \w \s (\D \W \S) | digit, word character, whitespace (and their opposites). \d and \w are ASCII-only; \s is Unicode whitespace in JS, ASCII in Go |
? * + {n} {n,m} | 0–1, 0+, 1+, exactly n, n to m; add ? for lazy |
^ $ \b | start, end (per line with m), word boundary |
a|b | a or b (lowest priority) |
(x) (?:x) (?<n>x) | capture, group only, named capture |
\1 \k<n> | backreference (JS only) |
(?=x) (?!x) (?<=x) (?<!x) | lookahead / lookbehind (JS only) |
\p{L} | any letter in any script (JS needs u or v; Go: no flag) |
| I want to… | Go (regexp) | JavaScript |
|---|---|---|
| Compile | regexp.MustCompile(pattern) in a package-level var; regexp.Compile for user input | /…/flags or new RegExp(str, flags) |
| Test | re.MatchString(s) | re.test(s) (no g!) |
| First match + groups | re.FindStringSubmatch(s) | s.match(re) (no g) or re.exec(s) |
| All matches | re.FindAllString(s, -1) / FindAllStringSubmatch | s.match(/…/g) / s.matchAll(/…/g) |
| Replace all | re.ReplaceAllString(s, "${1}") | s.replace(/…/g, "$1") or s.replaceAll(/…/g, …) |
| Flags | (?i), (?m), (?s) inside the pattern | i, m, s, g, u/v, y, d after the slash |
| Search files | grep -E "pattern" file (-i, -o, -c, -v) |