A PR lands with a one-line diff. The line is 180 characters of punctuation. The commit message says "fix version parsing." You find the opening paren, lose your place somewhere around the fourth backslash, notice CI is green, and hit approve.

Regex doesn't feel unreadable because it's conceptually hard. It feels unreadable because roughly thirty tokens do all the work, and you personally meet each one about twice a year. That's not a skill problem, it's a recall problem — which is exactly what a reference is for.
So: below is the syntax, grouped the way you actually look it up, with the cross-engine differences that bite. Everything is JavaScript unless noted, because that's where most of us hit it first. If you want a specific pattern narrated back at you in English rather than looked up token by token, the regex explainer does that part.
Read a pattern outside-in, not left to right. Check the anchors first (is it pinned to the whole string or free-floating?), then split on the top-level alternations, then peel the groups one nesting level at a time. Left-to-right is how the engine reads it; outside-in is how a human can.
Anchors and boundaries
The tokens that match a position, not a character. Getting these wrong is how a "validator" ends up accepting a string that merely contains something valid.
| Token | Matches | Worth knowing |
|---|---|---|
^ | Start of string | With the m flag, start of every line instead |
$ | End of string | In JS, strictly the end. Python's $ also matches just before a trailing newline — \Z is Python's absolute end |
\b | Word boundary | The seam between a \w char and a non-\w char. \bcat\b won't match inside "concatenate" |
\B | Not a word boundary | Rare, but handy for "inside a word" searches |
\A \z | Absolute start / end | .NET, Java, PCRE, Ruby, Go — not JavaScript. Go has \A and \z but no \Z |
Character classes
| Token | Matches |
|---|---|
. | Any character except line terminators, unless the s flag is set |
\d \D | A digit / not a digit |
\w \W | A word char / not one |
\s \S | Whitespace / not — includes tabs, newlines, NBSP and the Unicode space family |
[abc] | Any one of those characters |
[^abc] | Any one character that isn't one of those |
[a-z0-9_] | Ranges and members, combined |
\p{L}, \p{Script=Greek} | A Unicode property — requires the u or v flag |
Two of these lie to you if you assume Unicode. In JavaScript \d is exactly [0-9] — never the Arabic-Indic or Devanagari digits — while Python 3's \d on a str pattern matches Unicode digits by default. And JS \w is exactly [A-Za-z0-9_]: no accents, no CJK, no Cyrillic. A username validator built on \w quietly rejects "José". Reach for \p{L} with the u flag when you mean "a letter" rather than "an ASCII letter."
Quantifiers
| Greedy | Lazy | Meaning |
|---|---|---|
* | *? | Zero or more |
+ | +? | One or more |
? | ?? | Zero or one — optional |
{3} | — | Exactly three |
{2,} | {2,}? | Two or more |
{2,4} | {2,4}? | Two to four |
Greedy is the default: the quantifier grabs as much as it can, then gives characters back one at a time until the rest of the pattern fits. Lazy takes the minimum first and grows only when forced. There's a third mode you can't use in JavaScript — possessive quantifiers (*+, ++, {2,4}+), which grab everything and refuse to give any of it back. Java, PCRE and Ruby have had them for years; Python added them in 3.11 alongside atomic groups. They're the cleanest fix for the backtracking problem further down, and their absence in JS is why [^>]* is the idiomatic JS workaround.
Greedy-versus-lazy is the single most common source of "the pattern matches, just not what I meant" — there's a fuller treatment in regex patterns every developer should know.
Groups, alternation and backreferences
| Syntax | What it is |
|---|---|
(...) | Capturing group — numbered left to right by opening paren |
(?:...) | Non-capturing group — grouping only, no capture slot |
(?<name>...) | Named capture, ES2018. Read it back as m.groups.name |
\1, \2 | Backreference — "the same text group 1 matched" |
\k<name> | Named backreference |
a|b | Alternation — try a, else b |
(?>...) | Atomic group — PCRE/Java/.NET/Python 3.11+, not JavaScript |
The one that catches people: alternation in a backtracking engine is leftmost-first, not longest-match. /foo|foobar/ run against "foobar" returns "foo" and stops, because the first branch succeeded and nothing forced it to reconsider. Order your alternatives longest-first when the branches share a prefix.
Prefer (?:...) unless you actually need the capture. Every capturing group you add renumbers everything after it, so a "harmless" refactor silently reassigns \2 to a different piece of the string — which is exactly the kind of bug that survives code review.
Lookaheads and lookbehinds
All four are zero-width: they assert something about what's next to the current position and then consume nothing, so the match position doesn't move.
| Syntax | Name | Reads as |
|---|---|---|
(?=...) | Positive lookahead | "…followed by" |
(?!...) | Negative lookahead | "…not followed by" |
(?<=...) | Positive lookbehind | "…preceded by" |
(?<!...) | Negative lookbehind | "…not preceded by" |
The everyday use is asserting several independent things about one string without consuming it — a password rule expressed as (?=.*[a-z])(?=.*[A-Z])(?=.*\d) checks three conditions at position zero, each one scanning ahead and rewinding. The other everyday use is subtraction: \d+(?!%) finds numbers that aren't percentages, and (?<!\$)\d+ finds ones that aren't prices.
Lookbehind was the last piece to land in browsers — V8 shipped it in 2018 with ES2018, but Safari didn't until 16.4 in March 2023. If you're supporting anything older than that, a lookbehind is a SyntaxError at parse time, which takes out the entire script file, not just the match.
Flags
| Flag | Property | Effect |
|---|---|---|
g | global | Find all matches, not just the first |
i | ignoreCase | Case-insensitive |
m | multiline | ^ and $ match at line breaks |
s | dotAll | . also matches newlines |
u | unicode | Proper code-point handling, enables \p |
v | unicodeSets | ES2024 superset of u: set intersection/difference inside classes, multi-code-point properties |
y | sticky | Match must start exactly at lastIndex |
d | hasIndices | ES2022 — populates match.indices with start/end offsets per group |
The
gflag makes a regex stateful, and it will burn you. ARegExpobject withgcarries a mutablelastIndex, and.test()advances it. Callre.test(s)twice on the same string with the same object and you gettrue, thenfalse. Any regex declared at module scope withgand used with.test()or.exec()is a latent heisenbug — drop theg, or build a freshRegExpper call, or useString.prototype.matchAllwhich requiresgand handles the state for you.
Worked example: the official semver pattern, line by line
Semver.org publishes a suggested regular expression for validating a version string. It is 179 characters of exactly the syntax above — near enough to that PR — and once you can read it, you can read most things:
^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$
Peeled apart:
^ Pinned to the start. Without it, "v1.2.3" matches from index 1.
(0|[1-9]\d*) MAJOR. Alternation: the literal 0, OR a non-zero digit followed
by any digits. That branch structure is the whole point — it is
what makes "01.0.0" invalid while keeping "0.1.0" valid.
\. A literal dot. Unescaped, "." would match any character, and
"1x2x3" would sail through.
(0|[1-9]\d*) MINOR — identical rule.
\.
(0|[1-9]\d*) PATCH — identical rule.
(?:-( Optional pre-release, introduced by a hyphen. Outer group is
non-capturing (?: because we don't want the hyphen in the
capture; the inner ( is capturing, so capture group 4
comes back as "beta.1" rather than "-beta.1".
(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)
One pre-release identifier. Three alternatives: bare 0, a
numeric identifier without leading zeros, or anything that
contains at least one letter or hyphen. The third branch is
what allows "beta", "rc-2" and "0alpha" while the second
still rejects "007".
(?:\.(?: ...same... ))*
Zero or more further ".identifier" segments — this is how
"1.0.0-beta.11.x" is legal. The * is on the whole dot-plus-
identifier group, not on the dot.
))? The trailing ? makes the entire pre-release section optional.
(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?
Optional "+build.metadata", same repeat shape. Note \+ is
escaped — a bare + is a quantifier, and "(?:+..." throws.
$ Pinned to the end. Together with ^, this is what turns
"find a version in here" into "this string IS a version".
The takeaway isn't the pattern, it's the shape: two anchors holding a sequence of alternation groups, with (?:...)? marking every optional section and (?:...)* marking every repeatable one. Almost every long real-world regex is that same skeleton with different characters inside. Paste it into the regex tester with 1.0.0-beta.11+exp.sha.5114f85 and watch which group catches what — the numbered captures make the structure obvious in a way that staring at the source never does.
The three mistakes that keep showing up
1. Assuming .test() means "the whole string is valid"
/[0-9]+/.test("12px") is true. So is /[0-9]+/.test("drop table users; 42"). Without ^ and $ you have written a search, not a validation — the engine only has to find your pattern somewhere. Every validator needs both anchors, and ^...$ with the m flag isn't good enough either, because m lets $ match at a newline and a multi-line payload slips through. If you need line anchoring and whole-string validation at once, ^ plus $ without m is the only combination that means what you think.
2. Escaping too little outside a class, too much inside one
Outside a character class, fourteen characters are special: . * + ? ^ $ ( ) [ ] { } | \. Miss one and the pattern still compiles — it just means something else. /^api.example.com$/ happily matches apiXexampleYcom; you wanted /^api\.example\.com$/.
Inside a class, almost nothing is special, and over-escaping is how patterns get unreadable. [.*+?] matches a literal dot, asterisk, plus or question mark — no backslashes needed. What does need care inside a class is position-dependent:
-is a range operator between two characters, and a literal anywhere it can't be:[-az],[az-]and[a\-z]all match a literal hyphen;[a-z]does not.^negates only in the first position.[a^]matches a literal caret.]has to be escaped:[\]]. In JavaScript[]isn't "an empty set of exceptions", it's a class that matches nothing at all, and[^]matches anything including newlines.
And when you build a pattern from a string rather than a literal, every backslash doubles: new RegExp("\\d+") is /\d+/. If any part of that string came from a user, escape it — skip that step and a search box containing ( throws SyntaxError: Invalid regular expression: Unterminated group.
// RegExp.escape() landed with ES2025 (Node 24+). Fall back where you can't rely on it.
const escapeLiteral = (s) =>
typeof RegExp.escape === "function" ? RegExp.escape(s) : s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3. Nested quantifiers, and the outage they cause
This is the one with a body count. When a quantifier is applied to a group that itself contains a quantifier over an overlapping character set — (a+)+, (\s*)*, (\w+\s?)+ — the number of ways the engine can split the input grows exponentially, and on a string that nearly matches, it tries all of them before admitting failure.
// Fine.
/^(a+)+$/.test("aaaaaaaaaaaaaaaaaaaaaaaaa"); // true, instant
// Add one character that can't match, and the engine explores 2^n splits.
/^(a+)+$/.test("aaaaaaaaaaaaaaaaaaaaaaaaa!"); // still running
Every character you add to that input roughly doubles the runtime. That's ReDoS, and it isn't theoretical: Cloudflare took a global 502 outage on 2 July 2019 from a single WAF rule containing .*(?:.*=.*) — a pattern their own postmortem simplifies to .*.*=.*. CPU hit ~100% across every machine serving HTTP traffic worldwide, and traffic dropped by about 80% for roughly 27 minutes before the WAF could be turned off. One sub-expression, in one rule, in one deploy.
Defences, in order of how much they actually help:
- Don't nest quantifiers over overlapping sets.
(a+)+isa+. Most instances of this are accidental and simplify away to nothing. - Use a negated class to state the boundary —
[^>]*instead of.*?— so the engine can't cross the delimiter and never has to backtrack over it. - Never compile a user-supplied pattern in a request path. If you must, run it somewhere you can kill: a Worker, a subprocess, a separate service.
- Use a non-backtracking engine for untrusted input. RE2 (and Go's
regexp, and Rust'sregex) guarantee linear time by giving up backreferences and lookarounds. That's usually a fine trade. - Lint for it.
safe-regexandeslint-plugin-security'sdetect-unsafe-regexcatch the obvious shapes. JavaScript has no regex timeout — .NET and Java can bound execution,RegExp.prototype.execcannot — so static detection is most of what you get.
The short version
Anchors decide whether you're validating or searching. Greediness decides where a match stops. Non-capturing groups keep your capture numbering stable across refactors. Lookarounds assert without consuming. And nested quantifiers over the same characters are the one construct worth treating as a bug on sight.
Everything else is lookup, which is what this page is for. Bookmark it, and next time that 180-character diff shows up, read it outside-in — anchors, then top-level alternation, then one nesting level at a time — instead of approving it because CI was green.
Cover photo by Godfrey Atima on Pexels.
